Compare commits
11 Commits
newRemoteU
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c39cb7a7ac | |||
| d785d15c58 | |||
| 00bb9bf9e6 | |||
| f5319cc467 | |||
| 03e5fd06f8 | |||
| 1b7d65b155 | |||
| 6ed4b5380d | |||
| b785516d2c | |||
| 6d3f8d4b4c | |||
| 9e8e027137 | |||
| 7e35dd2f3b |
93
SSO-Frontend-Microsoft.md
Normal file
93
SSO-Frontend-Microsoft.md
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
# Frontend Guide - Microsoft SSO (Entra ID)
|
||||||
|
|
||||||
|
## 1) Muc tieu
|
||||||
|
Tai lieu nay danh cho frontend de tich hop dang nhap Microsoft SSO voi backend TTMT.CompManageWeb.
|
||||||
|
|
||||||
|
## 2) Redirect URI da dang ky
|
||||||
|
Redirect URI cho Microsoft app:
|
||||||
|
|
||||||
|
https://comp.soict.io/api/auth/sso/callback
|
||||||
|
|
||||||
|
Luu y:
|
||||||
|
- Redirect URI trong Azure App Registration phai giong 100% (scheme, domain, path).
|
||||||
|
- Sai ky tu hoac sai slash cuoi co the gay loi redirect_uri mismatch.
|
||||||
|
|
||||||
|
## 3) Endpoint frontend can goi
|
||||||
|
Backend route SSO Microsoft (legacy route):
|
||||||
|
|
||||||
|
- GET /api/auth/sso/login?returnUrl={FRONTEND_RETURN_URL}
|
||||||
|
- POST /api/auth/sso/exchange
|
||||||
|
|
||||||
|
Route alias cung ho tro:
|
||||||
|
- GET /api/sso/login?returnUrl={FRONTEND_RETURN_URL}
|
||||||
|
- POST /api/sso/exchange
|
||||||
|
|
||||||
|
## 4) Login flow cho frontend
|
||||||
|
1. User bam nut "Login with Microsoft".
|
||||||
|
2. Frontend redirect browser den:
|
||||||
|
/api/auth/sso/login?returnUrl={FRONTEND_RETURN_URL}
|
||||||
|
3. Backend redirect sang Microsoft login page.
|
||||||
|
4. Sau khi user xac thuc thanh cong, Microsoft goi ve redirect URI:
|
||||||
|
https://comp.soict.io/api/auth/sso/callback
|
||||||
|
5. Backend tao one-time code va redirect user ve FRONTEND_RETURN_URL kem query:
|
||||||
|
?code={ONE_TIME_CODE}
|
||||||
|
6. Frontend doc code trong URL, goi API exchange de doi code lay JWT noi bo.
|
||||||
|
7. Frontend luu token va thong tin user, dieu huong vao app.
|
||||||
|
|
||||||
|
## 5) API exchange chi tiet
|
||||||
|
Method: POST
|
||||||
|
Path: /api/auth/sso/exchange
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
Request body:
|
||||||
|
{
|
||||||
|
"code": "<one-time-code>"
|
||||||
|
}
|
||||||
|
|
||||||
|
Success response (HTTP 200):
|
||||||
|
{
|
||||||
|
"token": "<jwt>",
|
||||||
|
"name": "Nguyen Van A",
|
||||||
|
"username": "user@hust.edu.vn",
|
||||||
|
"access": [1, 2, 3],
|
||||||
|
"role": {
|
||||||
|
"roleName": "Pending",
|
||||||
|
"priority": 99
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
## 6) Error handling goi y cho frontend
|
||||||
|
- 400 BadRequest: code thieu hoac token khong hop le.
|
||||||
|
- 401 Unauthorized: code het han / da dung / khong hop le.
|
||||||
|
- 404 NotFound: user khong ton tai sau khi exchange.
|
||||||
|
|
||||||
|
One-time code co han su dung ngan (khoang 2 phut) va chi dung 1 lan.
|
||||||
|
Neu exchange that bai, can yeu cau user login lai.
|
||||||
|
|
||||||
|
## 7) Mau frontend pseudo-code
|
||||||
|
const returnUrl = window.location.origin + "/sso/complete";
|
||||||
|
window.location.href = `/api/auth/sso/login?returnUrl=${encodeURIComponent(returnUrl)}`;
|
||||||
|
|
||||||
|
// tai /sso/complete
|
||||||
|
const code = new URLSearchParams(window.location.search).get("code");
|
||||||
|
if (code) {
|
||||||
|
const res = await fetch("/api/auth/sso/exchange", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ code })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
// hien thi thong bao loi va cho user thu lai
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
// save data.token, data.username, data.role, data.access
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
## 8) Checklist truoc khi UAT
|
||||||
|
- Redirect URI trong Azure dung: https://comp.soict.io/api/auth/sso/callback
|
||||||
|
- Frontend returnUrl la URL FE hop le (vi du: https://comp.soict.io/sso/complete)
|
||||||
|
- Frontend parse duoc query code
|
||||||
|
- Frontend goi exchange ngay sau khi nhan code
|
||||||
|
- Frontend xu ly day du HTTP 400/401/404
|
||||||
170
nginx/nginx.conf
170
nginx/nginx.conf
|
|
@ -3,6 +3,7 @@
|
||||||
# server 127.0.0.1:8080;
|
# server 127.0.0.1:8080;
|
||||||
# server 172.18.10.8:8080;
|
# server 172.18.10.8:8080;
|
||||||
# }
|
# }
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name comp.soict.io;
|
server_name comp.soict.io;
|
||||||
|
|
@ -12,8 +13,6 @@ server {
|
||||||
}
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
# root /usr/share/nginx/html;
|
|
||||||
# index index.html index.htm;
|
|
||||||
return 301 https://$host$request_uri;
|
return 301 https://$host$request_uri;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -21,73 +20,190 @@ server {
|
||||||
server {
|
server {
|
||||||
listen 443 ssl;
|
listen 443 ssl;
|
||||||
server_name comp.soict.io;
|
server_name comp.soict.io;
|
||||||
|
|
||||||
ssl_certificate /etc/letsencrypt/live/comp.soict.io/fullchain.pem;
|
ssl_certificate /etc/letsencrypt/live/comp.soict.io/fullchain.pem;
|
||||||
ssl_certificate_key /etc/letsencrypt/live/comp.soict.io/privkey.pem;
|
ssl_certificate_key /etc/letsencrypt/live/comp.soict.io/privkey.pem;
|
||||||
|
|
||||||
ssl_protocols TLSv1.2 TLSv1.3;
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||||
|
|
||||||
set $backend_server 172.18.10.8:8080;
|
# MeshCentral proxied flow can set sizable auth cookies.
|
||||||
|
client_header_buffer_size 16k;
|
||||||
|
large_client_header_buffers 8 32k;
|
||||||
|
|
||||||
|
# Required when proxy_pass uses variables.
|
||||||
|
# In Docker, 127.0.0.11 is the embedded DNS resolver.
|
||||||
|
resolver 127.0.0.11 valid=30s ipv6=off;
|
||||||
|
resolver_timeout 5s;
|
||||||
|
|
||||||
|
set $backend_server ttmt-web:8080;
|
||||||
|
# Internal MeshCentral hop to avoid upstream TLS handshake instability.
|
||||||
|
set $meshserver meshcentral:8082;
|
||||||
|
# Public host MeshCentral expects in Host header.
|
||||||
|
set $meshhost soict-overleaf.tailc51e09.ts.net;
|
||||||
|
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
# Default file to serve for directory requests
|
|
||||||
index index.html index.htm;
|
index index.html index.htm;
|
||||||
|
|
||||||
location / {
|
# MeshCentral auth entrypoint. If iframe/browser lands on /login due to
|
||||||
# Try to serve the requested file directly ($uri)
|
# redirect, keep it on MeshCentral instead of frontend routing.
|
||||||
# If it's a directory, try serving the index file ($uri/)
|
location = /login {
|
||||||
# If neither exists, fall back to serving /index.html
|
proxy_pass http://$meshserver;
|
||||||
|
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $meshhost;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header X-Forwarded-Host $host;
|
||||||
|
|
||||||
|
proxy_cookie_path / "/; HTTPOnly; Secure; SameSite=None";
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
proxy_send_timeout 3600s;
|
||||||
|
proxy_buffering off;
|
||||||
|
}
|
||||||
|
|
||||||
|
# MeshCentral may redirect to "/" with remote params after login.
|
||||||
|
# Detect those requests and proxy them to MeshCentral instead of SPA.
|
||||||
|
location = / {
|
||||||
|
if ($arg_node != "") {
|
||||||
|
rewrite ^ /__mesh_root_proxy__ last;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($arg_viewmode != "") {
|
||||||
|
rewrite ^ /__mesh_root_proxy__ last;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($arg_gotonode != "") {
|
||||||
|
rewrite ^ /__mesh_root_proxy__ last;
|
||||||
|
}
|
||||||
|
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /__mesh_root_proxy__ {
|
||||||
|
proxy_pass http://$meshserver;
|
||||||
|
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $meshhost;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header X-Forwarded-Host $host;
|
||||||
|
|
||||||
|
proxy_cookie_path / "/; HTTPOnly; Secure; SameSite=None";
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
proxy_send_timeout 3600s;
|
||||||
|
proxy_buffering off;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Optional: Add cache control headers for static assets for better performance
|
|
||||||
location ~* \.(?:css|js|jpg|jpeg|gif|png|ico|svg|webp)$ {
|
location ~* \.(?:css|js|jpg|jpeg|gif|png|ico|svg|webp)$ {
|
||||||
expires 1y;
|
expires 1y;
|
||||||
add_header Cache-Control "public";
|
add_header Cache-Control "public";
|
||||||
access_log off; # Optional: Don't log accesses for static files
|
access_log off;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://$backend_server;
|
proxy_pass http://$backend_server;
|
||||||
|
|
||||||
# Cho phép upload file lớn (vd: 200MB)
|
client_max_body_size 900M;
|
||||||
client_max_body_size 200M;
|
|
||||||
|
|
||||||
# Truyền thẳng stream sang backend
|
|
||||||
proxy_request_buffering off;
|
proxy_request_buffering off;
|
||||||
|
|
||||||
# Tăng timeout khi upload
|
|
||||||
proxy_read_timeout 300s;
|
proxy_read_timeout 300s;
|
||||||
proxy_connect_timeout 300s;
|
proxy_connect_timeout 300s;
|
||||||
proxy_send_timeout 300s;
|
proxy_send_timeout 300s;
|
||||||
|
|
||||||
# CORS headers - Comment vi da xu ly o backend C#
|
|
||||||
# add_header 'Access-Control-Allow-Origin' '*' always;
|
|
||||||
# add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
|
|
||||||
# add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization' always;
|
|
||||||
|
|
||||||
if ($request_method = OPTIONS) {
|
if ($request_method = OPTIONS) {
|
||||||
return 204;
|
return 204;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
location /api/Sse/events {
|
location /api/Sse/events {
|
||||||
proxy_pass http://$backend_server/api/Sse/events;
|
proxy_pass http://$backend_server/api/Sse/events;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
|
||||||
# cần thiết cho SSE
|
|
||||||
proxy_set_header Connection '';
|
proxy_set_header Connection '';
|
||||||
proxy_buffering off;
|
proxy_buffering off;
|
||||||
proxy_cache off;
|
proxy_cache off;
|
||||||
proxy_read_timeout 1h;
|
proxy_read_timeout 1h;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /mesh-proxy/ {
|
# MeshCentral client builds WebSocket URL from current location,
|
||||||
proxy_pass https://202.191.59.59/;
|
# e.g. wss://comp.soict.io/control.ashx.
|
||||||
proxy_cookie_path / "/; HTTPOnly; Secure; SameSite=None";
|
location ~ ^/(control|meshrelay|commander|mesh)\.ashx$ {
|
||||||
|
proxy_pass http://$meshserver;
|
||||||
|
|
||||||
# Cấu hình WebSocket cho commander.ashx
|
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
proxy_set_header Connection "upgrade";
|
proxy_set_header Connection "upgrade";
|
||||||
|
|
||||||
|
proxy_set_header Host $meshhost;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header X-Forwarded-Host $host;
|
||||||
|
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
proxy_send_timeout 3600s;
|
||||||
|
proxy_buffering off;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /api/meshcentral/proxy {
|
||||||
|
return 301 /api/meshcentral/proxy/;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ^~ /api/meshcentral/proxy/ {
|
||||||
|
# Forward directly to MeshCentral, but strip proxy prefix first.
|
||||||
|
# Without this, upstream sees /api/meshcentral/proxy/* and can redirect-loop.
|
||||||
|
rewrite ^/api/meshcentral/proxy/(.*)$ /$1 break;
|
||||||
|
proxy_pass http://$meshserver;
|
||||||
|
proxy_cookie_path / "/; HTTPOnly; Secure; SameSite=None";
|
||||||
|
|
||||||
|
proxy_set_header Host $meshhost;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header X-Forwarded-Host $host;
|
||||||
|
|
||||||
|
# Keep browser navigation under /api/meshcentral/proxy/*.
|
||||||
|
proxy_redirect ~^https?://[^/]+(/.*)$ /api/meshcentral/proxy$1;
|
||||||
|
proxy_redirect ~^(/.*)$ /api/meshcentral/proxy$1;
|
||||||
|
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
proxy_send_timeout 3600s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# FE production currently builds mesh proxy path as /meshapi/api/meshcentral/proxy/...
|
||||||
|
location = /meshapi/api/meshcentral/proxy {
|
||||||
|
return 301 /meshapi/api/meshcentral/proxy/;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ^~ /meshapi/api/meshcentral/proxy/ {
|
||||||
|
# Legacy frontend path -> backend MeshCentralProxyController
|
||||||
|
rewrite ^/meshapi/api/meshcentral/proxy/(.*)$ /$1 break;
|
||||||
|
proxy_pass http://$backend_server;
|
||||||
|
proxy_cookie_path / "/; HTTPOnly; Secure; SameSite=None";
|
||||||
|
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
|
||||||
|
proxy_set_header X-Forwarded-Proto https;
|
||||||
|
proxy_set_header X-Forwarded-Host $host;
|
||||||
|
proxy_set_header X-Forwarded-Port 443;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header X-Forwarded-Host $host;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
proxy_send_timeout 3600s;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -8,7 +8,7 @@ import { useGetClientFolderStatusForDevice } from "@/hooks/queries";
|
||||||
import type { ClientFolderStatus } from "@/types/folder";
|
import type { ClientFolderStatus } from "@/types/folder";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { getRemoteDesktopUrl } from "@/services/remote-control.service";
|
import { getRemoteDesktopUrl } from "@/services/remote-control.service";
|
||||||
import { BASE_URL } from "@/config/api";
|
import { buildMeshProxyUrl } from "@/config/api";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
export function ComputerCard({
|
export function ComputerCard({
|
||||||
device,
|
device,
|
||||||
|
|
@ -52,11 +52,7 @@ export function ComputerCard({
|
||||||
const response = await getRemoteDesktopUrl(device.id);
|
const response = await getRemoteDesktopUrl(device.id);
|
||||||
const originalUrl = new URL(response.url);
|
const originalUrl = new URL(response.url);
|
||||||
const pathAndQuery = originalUrl.pathname + originalUrl.search;
|
const pathAndQuery = originalUrl.pathname + originalUrl.search;
|
||||||
const cleanPath = pathAndQuery.startsWith("/")
|
const proxyUrlFull = buildMeshProxyUrl(pathAndQuery);
|
||||||
? pathAndQuery.substring(1)
|
|
||||||
: pathAndQuery;
|
|
||||||
const baseWithoutApi = BASE_URL.replace("/api", "");
|
|
||||||
const proxyUrlFull = `${baseWithoutApi}/api/meshcentral/proxy/${cleanPath}`;
|
|
||||||
|
|
||||||
setProxyUrl(proxyUrlFull);
|
setProxyUrl(proxyUrlFull);
|
||||||
setShowRemote(true);
|
setShowRemote(true);
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import type { LoginResquest } from "@/types/auth";
|
import type { LoginResquest } from "@/types/auth";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { buildGoogleOAuthLoginUrl, login } from "@/services/auth.service";
|
import { buildGoogleOAuthLoginUrl, buildMicrosoftSsoLoginUrl, login } from "@/services/auth.service";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useNavigate, useRouter } from "@tanstack/react-router";
|
import { useNavigate, useRouter } from "@tanstack/react-router";
|
||||||
import { Route } from "@/routes/(auth)/login";
|
import { Route } from "@/routes/(auth)/login";
|
||||||
|
|
@ -46,12 +46,22 @@ export function LoginForm({ className }: React.ComponentProps<"form">) {
|
||||||
|
|
||||||
const handleGoogleLogin = () => {
|
const handleGoogleLogin = () => {
|
||||||
const returnUrl = new URL("/oauth/callback", window.location.origin);
|
const returnUrl = new URL("/oauth/callback", window.location.origin);
|
||||||
|
returnUrl.searchParams.set("provider", "google");
|
||||||
if (search.redirect) {
|
if (search.redirect) {
|
||||||
returnUrl.searchParams.set("redirect", search.redirect);
|
returnUrl.searchParams.set("redirect", search.redirect);
|
||||||
}
|
}
|
||||||
window.location.assign(buildGoogleOAuthLoginUrl(returnUrl.toString()));
|
window.location.assign(buildGoogleOAuthLoginUrl(returnUrl.toString()));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleMicrosoftLogin = () => {
|
||||||
|
const returnUrl = new URL("/oauth/callback", window.location.origin);
|
||||||
|
returnUrl.searchParams.set("provider", "azuread");
|
||||||
|
if (search.redirect) {
|
||||||
|
returnUrl.searchParams.set("redirect", search.redirect);
|
||||||
|
}
|
||||||
|
window.location.assign(buildMicrosoftSsoLoginUrl(returnUrl.toString()));
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setErrorMessage(null);
|
setErrorMessage(null);
|
||||||
|
|
@ -133,6 +143,15 @@ export function LoginForm({ className }: React.ComponentProps<"form">) {
|
||||||
</svg>
|
</svg>
|
||||||
Đăng nhập với Google
|
Đăng nhập với Google
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button type="button" variant="outline" className="w-full gap-2" onClick={handleMicrosoftLogin}>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true" className="h-4 w-4">
|
||||||
|
<rect x="2" y="2" width="9" height="9" fill="#F35325" />
|
||||||
|
<rect x="13" y="2" width="9" height="9" fill="#81BC06" />
|
||||||
|
<rect x="2" y="13" width="9" height="9" fill="#05A6F0" />
|
||||||
|
<rect x="13" y="13" width="9" height="9" fill="#FFBA08" />
|
||||||
|
</svg>
|
||||||
|
Đăng nhập với Microsoft
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ export function UploadVersionForm({ onSubmit, closeDialog }: UploadVersionFormPr
|
||||||
const [isDone, setIsDone] = useState(false);
|
const [isDone, setIsDone] = useState(false);
|
||||||
|
|
||||||
// Match server allowed extensions
|
// Match server allowed extensions
|
||||||
const ALLOWED_EXTENSIONS = [".exe", ".apk", ".conf", ".json", ".xml", ".setting", ".lnk", ".url", ".seb", ".ps1"];
|
const ALLOWED_EXTENSIONS = [".exe", ".apk", ".conf", ".json", ".xml", ".setting", ".lnk", ".url", ".seb", ".ps1", ".zip"];
|
||||||
const isFileValid = (file: File) => {
|
const isFileValid = (file: File) => {
|
||||||
const fileName = file.name.toLowerCase();
|
const fileName = file.name.toLowerCase();
|
||||||
return ALLOWED_EXTENSIONS.some((ext) => fileName.endsWith(ext));
|
return ALLOWED_EXTENSIONS.some((ext) => fileName.endsWith(ext));
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,39 @@
|
||||||
const isDev = import.meta.env.MODE === "development";
|
const isDev = import.meta.env.MODE === "development";
|
||||||
|
|
||||||
|
const trimTrailingSlash = (value: string) => value.replace(/\/+$/, "");
|
||||||
|
|
||||||
export const BASE_URL = isDev
|
export const BASE_URL = isDev
|
||||||
? import.meta.env.VITE_API_URL_DEV
|
? import.meta.env.VITE_API_URL_DEV
|
||||||
: "/api";
|
: "/api";
|
||||||
|
|
||||||
export const BASE_MESH_URL = isDev
|
export const BASE_MESH_URL = isDev
|
||||||
? import.meta.env.VITE_API_MESH_DEV
|
? (import.meta.env.VITE_API_MESH || import.meta.env.VITE_API_MESH_DEV || "")
|
||||||
: "/meshapi";
|
: (import.meta.env.VITE_API_MESH || "");
|
||||||
|
|
||||||
|
export const buildMeshProxyUrl = (meshPathAndQuery: string) => {
|
||||||
|
const cleanPath = meshPathAndQuery.startsWith("/")
|
||||||
|
? meshPathAndQuery.substring(1)
|
||||||
|
: meshPathAndQuery;
|
||||||
|
const proxyPath = `/api/meshcentral/proxy/${cleanPath}`;
|
||||||
|
|
||||||
|
// If an explicit mesh host is configured, always use it.
|
||||||
|
// This allows forcing proxy URLs to https://<IP>:<port>/api/meshcentral/proxy/...
|
||||||
|
if (BASE_MESH_URL && BASE_MESH_URL.startsWith("http")) {
|
||||||
|
return `${trimTrailingSlash(BASE_MESH_URL)}${proxyPath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// In development, BASE_URL is usually absolute (e.g. http://localhost:5218/api).
|
||||||
|
// Build an absolute proxy URL to backend so iframe requests do not hit Vite dev server.
|
||||||
|
if (BASE_URL.startsWith("http")) {
|
||||||
|
const apiBase = trimTrailingSlash(BASE_URL);
|
||||||
|
const backendOrigin = apiBase.endsWith("/api")
|
||||||
|
? apiBase.slice(0, -4)
|
||||||
|
: apiBase;
|
||||||
|
return `${backendOrigin}${proxyPath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return proxyPath;
|
||||||
|
};
|
||||||
|
|
||||||
export const API_ENDPOINTS = {
|
export const API_ENDPOINTS = {
|
||||||
AUTH: {
|
AUTH: {
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { LoaderCircle } from "lucide-react";
|
import { LoaderCircle } from "lucide-react";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { exchangeOAuthCode } from "@/services/auth.service";
|
import { exchangeCodeByProvider } from "@/services/auth.service";
|
||||||
import type { LoginResponse } from "@/types/auth";
|
import type { LoginResponse } from "@/types/auth";
|
||||||
|
|
||||||
const inFlightExchanges = new Map<string, Promise<LoginResponse>>();
|
const inFlightExchanges = new Map<string, Promise<LoginResponse>>();
|
||||||
|
|
@ -18,18 +18,21 @@ export const Route = createFileRoute("/(auth)/oauth/callback/")({
|
||||||
function OAuthCallbackPage() {
|
function OAuthCallbackPage() {
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const search = Route.useSearch() as { code?: string; redirect?: string };
|
const search = Route.useSearch() as { code?: string; redirect?: string; provider?: string };
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
const [isExchanging, setIsExchanging] = useState(false);
|
const [isExchanging, setIsExchanging] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const code = search.code;
|
const code = search.code;
|
||||||
|
const provider = (search.provider || "").toLowerCase();
|
||||||
if (!code) {
|
if (!code) {
|
||||||
setErrorMessage("OAuth code is missing.");
|
setErrorMessage("OAuth code is missing.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (consumedCodes.has(code)) {
|
const exchangeId = `${provider || "auto"}:${code}`;
|
||||||
|
|
||||||
|
if (consumedCodes.has(exchangeId)) {
|
||||||
setErrorMessage("Mã đăng nhập đã được sử dụng. Vui lòng đăng nhập lại.");
|
setErrorMessage("Mã đăng nhập đã được sử dụng. Vui lòng đăng nhập lại.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -38,10 +41,10 @@ function OAuthCallbackPage() {
|
||||||
setIsExchanging(true);
|
setIsExchanging(true);
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
let exchangePromise = inFlightExchanges.get(code);
|
let exchangePromise = inFlightExchanges.get(exchangeId);
|
||||||
if (!exchangePromise) {
|
if (!exchangePromise) {
|
||||||
exchangePromise = exchangeOAuthCode(code);
|
exchangePromise = exchangeCodeByProvider(code, provider);
|
||||||
inFlightExchanges.set(code, exchangePromise);
|
inFlightExchanges.set(exchangeId, exchangePromise);
|
||||||
}
|
}
|
||||||
|
|
||||||
exchangePromise
|
exchangePromise
|
||||||
|
|
@ -53,7 +56,7 @@ function OAuthCallbackPage() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
consumedCodes.add(code);
|
consumedCodes.add(exchangeId);
|
||||||
|
|
||||||
localStorage.setItem("token", data.token);
|
localStorage.setItem("token", data.token);
|
||||||
localStorage.setItem("username", data.username || "");
|
localStorage.setItem("username", data.username || "");
|
||||||
|
|
@ -74,14 +77,14 @@ function OAuthCallbackPage() {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
|
||||||
if (axios.isAxiosError(error) && error.response?.status === 401) {
|
if (axios.isAxiosError(error) && error.response?.status === 401) {
|
||||||
consumedCodes.add(code);
|
consumedCodes.add(exchangeId);
|
||||||
setErrorMessage("Mã đăng nhập đã hết hạn hoặc đã được sử dụng. Vui lòng đăng nhập lại.");
|
setErrorMessage("Mã đăng nhập đã hết hạn hoặc đã được sử dụng. Vui lòng đăng nhập lại.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setErrorMessage("OAuth exchange failed.");
|
setErrorMessage("OAuth exchange failed.");
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
inFlightExchanges.delete(code);
|
inFlightExchanges.delete(exchangeId);
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setIsExchanging(false);
|
setIsExchanging(false);
|
||||||
}
|
}
|
||||||
|
|
@ -90,7 +93,7 @@ function OAuthCallbackPage() {
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [auth, navigate, search.code, search.redirect]);
|
}, [auth, navigate, search.code, search.provider, search.redirect]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-screen bg-gradient-to-br from-background to-muted/20">
|
<div className="flex items-center justify-center min-h-screen bg-gradient-to-br from-background to-muted/20">
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { getRemoteDesktopUrl } from "@/services/remote-control.service";
|
import { getRemoteDesktopUrl } from "@/services/remote-control.service";
|
||||||
import { BASE_URL } from "@/config/api";
|
import { buildMeshProxyUrl } from "@/config/api";
|
||||||
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/_auth/remote-control/")({
|
export const Route = createFileRoute("/_auth/remote-control/")({
|
||||||
|
|
@ -38,9 +38,7 @@ function RemoteControlPage() {
|
||||||
// Chuyển URL MeshCentral thành proxy URL
|
// Chuyển URL MeshCentral thành proxy URL
|
||||||
const originalUrl = new URL(data.url);
|
const originalUrl = new URL(data.url);
|
||||||
const pathAndQuery = originalUrl.pathname + originalUrl.search;
|
const pathAndQuery = originalUrl.pathname + originalUrl.search;
|
||||||
const cleanPath = pathAndQuery.startsWith('/') ? pathAndQuery.substring(1) : pathAndQuery;
|
const proxyUrlFull = buildMeshProxyUrl(pathAndQuery);
|
||||||
const baseWithoutApi = BASE_URL.replace('/api', '');
|
|
||||||
const proxyUrlFull = `${baseWithoutApi}/api/meshcentral/proxy/${cleanPath}`;
|
|
||||||
|
|
||||||
console.log("[RemoteControl] Proxy URL:", proxyUrlFull);
|
console.log("[RemoteControl] Proxy URL:", proxyUrlFull);
|
||||||
setProxyUrl(proxyUrlFull);
|
setProxyUrl(proxyUrlFull);
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,25 @@ function RouteComponent() {
|
||||||
roomName as string,
|
roomName as string,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const sortedFolderStatusList = useMemo(() => {
|
||||||
|
return [...(folderStatusList ?? [])].sort((a, b) => {
|
||||||
|
const aRoom = (a as ClientFolderStatus & { roomName?: string }).roomName;
|
||||||
|
const bRoom = (b as ClientFolderStatus & { roomName?: string }).roomName;
|
||||||
|
|
||||||
|
if (aRoom || bRoom) {
|
||||||
|
return (aRoom ?? "").localeCompare(bRoom ?? "", "vi", {
|
||||||
|
numeric: true,
|
||||||
|
sensitivity: "base",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (a.deviceId ?? "").localeCompare(b.deviceId ?? "", "vi", {
|
||||||
|
numeric: true,
|
||||||
|
sensitivity: "base",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [folderStatusList]);
|
||||||
|
|
||||||
const columnHelper = createColumnHelper<ClientFolderStatus>();
|
const columnHelper = createColumnHelper<ClientFolderStatus>();
|
||||||
|
|
||||||
const columns = useMemo(
|
const columns = useMemo(
|
||||||
|
|
@ -80,7 +99,7 @@ function RouteComponent() {
|
||||||
);
|
);
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data: folderStatusList ?? [],
|
data: sortedFolderStatusList,
|
||||||
columns,
|
columns,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
});
|
});
|
||||||
|
|
@ -88,7 +107,7 @@ function RouteComponent() {
|
||||||
return (
|
return (
|
||||||
<FolderStatusTemplate
|
<FolderStatusTemplate
|
||||||
roomName={roomName as string}
|
roomName={roomName as string}
|
||||||
data={folderStatusList}
|
data={sortedFolderStatusList}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
onBack={() =>
|
onBack={() =>
|
||||||
navigate({ to: "/rooms/$roomName/", params: { roomName } } as any)
|
navigate({ to: "/rooms/$roomName/", params: { roomName } } as any)
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,16 @@ export function buildGoogleOAuthLoginUrl(returnUrl: string): string {
|
||||||
return buildOAuthLoginUrl("google", returnUrl);
|
return buildOAuthLoginUrl("google", returnUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build Microsoft SSO login URL (legacy endpoint)
|
||||||
|
* @param returnUrl - FE callback url
|
||||||
|
*/
|
||||||
|
export function buildMicrosoftSsoLoginUrl(returnUrl: string): string {
|
||||||
|
const base = API_ENDPOINTS.AUTH.SSO_LOGIN;
|
||||||
|
const encoded = encodeURIComponent(returnUrl);
|
||||||
|
return `${base}?returnUrl=${encoded}`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Exchange one-time OAuth code for login payload
|
* Exchange one-time OAuth code for login payload
|
||||||
* @param code - one-time code
|
* @param code - one-time code
|
||||||
|
|
@ -75,6 +85,27 @@ export async function exchangeSsoCode(code: string): Promise<LoginResponse> {
|
||||||
return exchangeOAuthCode(code);
|
return exchangeOAuthCode(code);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exchange one-time code by provider without breaking existing flows.
|
||||||
|
* - azuread/microsoft: force legacy SSO exchange endpoint
|
||||||
|
* - default: use OAuth exchange flow
|
||||||
|
*/
|
||||||
|
export async function exchangeCodeByProvider(
|
||||||
|
code: string,
|
||||||
|
provider?: string
|
||||||
|
): Promise<LoginResponse> {
|
||||||
|
const providerKey = (provider || "").toLowerCase();
|
||||||
|
if (providerKey === "microsoft" || providerKey === "azuread") {
|
||||||
|
const response = await rawAxios.post<LoginResponse>(
|
||||||
|
API_ENDPOINTS.AUTH.SSO_EXCHANGE,
|
||||||
|
{ code }
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
return exchangeOAuthCode(code);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Đăng xuất
|
* Đăng xuất
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ export type UserProfile = {
|
||||||
name: string;
|
name: string;
|
||||||
role: string;
|
role: string;
|
||||||
roleId: number;
|
roleId: number;
|
||||||
accessRooms: number[];
|
accessRooms: string[];
|
||||||
createdAt?: string | null;
|
createdAt?: string | null;
|
||||||
createdBy?: string | null;
|
createdBy?: string | null;
|
||||||
updatedAt?: string | null;
|
updatedAt?: string | null;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user