Compare commits
No commits in common. "main" and "HttpsUpgrade" have entirely different histories.
main
...
HttpsUpgra
|
|
@ -1,93 +0,0 @@
|
|||
# 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
|
||||
|
|
@ -1,343 +0,0 @@
|
|||
# Google OAuth (OIDC) + AzureAD Shared SSO Service
|
||||
|
||||
Tai lieu nay tong hop toan bo flow dang nhap OAuth/OIDC theo muc coding cho codebase hien tai.
|
||||
|
||||
Pham vi:
|
||||
- Giu nguyen login username/password cu.
|
||||
- Dung chung 1 service cho AzureAD SSO va Google OAuth OIDC.
|
||||
- Van tao/cap nhat user noi bo trong DB.
|
||||
- User moi vao role Pending (khong co permission), doi admin cap quyen.
|
||||
|
||||
----------------------------------------
|
||||
|
||||
## 1) Trang thai implementation hien tai
|
||||
|
||||
Da hoan tat trong code:
|
||||
- Shared service theo provider key: SsoService.
|
||||
- Controller da provider: OAuthController.
|
||||
- Controller AzureAD cu (SsoController) da chay tren shared service.
|
||||
- Config da provider qua OAuthProviders trong appsettings.
|
||||
|
||||
File chinh:
|
||||
- TTMT.CompManageWeb/Program.cs
|
||||
- TTMT.CompManageWeb/Interfaces/ISsoService.cs
|
||||
- TTMT.CompManageWeb/Services/SsoSerivce.cs
|
||||
- TTMT.CompManageWeb/Controllers/OAuthController.cs
|
||||
- TTMT.CompManageWeb/Controllers/SsoController.cs
|
||||
- TTMT.CompManageWeb/Dtos/Auth/OAuthProviderOptions.cs
|
||||
- TTMT.CompManageWeb/Dtos/Auth/OAuthProvidersOptions.cs
|
||||
- TTMT.CompManageWeb/appsettings.json
|
||||
|
||||
----------------------------------------
|
||||
|
||||
## 2) Kien truc luong dang nhap
|
||||
|
||||
### 2.1 Luong OAuth Google
|
||||
|
||||
1. FE redirect user den:
|
||||
- GET /api/auth/oauth/google/login?returnUrl=<frontend-url>
|
||||
2. Backend build authorize URL va redirect qua Google.
|
||||
3. Google callback ve backend:
|
||||
- GET /api/auth/oauth/google/callback?code=...&state=...
|
||||
4. Backend:
|
||||
- Exchange code -> token endpoint.
|
||||
- Validate id_token.
|
||||
- Lay email/name.
|
||||
- Kiem tra domain.
|
||||
- Upsert user noi bo.
|
||||
- Issue JWT noi bo.
|
||||
- Tao one-time code (2 phut).
|
||||
- Redirect ve returnUrl?code=<one-time-code>.
|
||||
5. FE goi API exchange:
|
||||
- POST /api/auth/oauth/exchange
|
||||
- Body: { "code": "..." }
|
||||
6. Backend consume one-time code va tra payload login (token + role + permission).
|
||||
|
||||
### 2.2 Luong AzureAD cu
|
||||
|
||||
Van giu endpoint cu:
|
||||
- GET /api/auth/sso/login
|
||||
- GET /api/auth/sso/callback
|
||||
- POST /api/auth/sso/exchange
|
||||
|
||||
Nhung ben trong da dung chung service theo provider azuread.
|
||||
|
||||
----------------------------------------
|
||||
|
||||
## 3) Cau hinh bat buoc
|
||||
|
||||
### 3.1 appsettings.json
|
||||
|
||||
```json
|
||||
{
|
||||
"OAuthProviders": {
|
||||
"DefaultProvider": "azuread",
|
||||
"Providers": {
|
||||
"google": {
|
||||
"Authority": "https://accounts.google.com",
|
||||
"AuthorizationEndpoint": "https://accounts.google.com/o/oauth2/v2/auth",
|
||||
"TokenEndpoint": "https://oauth2.googleapis.com/token",
|
||||
"ClientId": "",
|
||||
"ClientSecret": "",
|
||||
"CallbackPath": "/api/auth/oauth/google/callback",
|
||||
"AllowedDomain": "",
|
||||
"PendingRoleName": "Pending",
|
||||
"Scopes": "openid profile email",
|
||||
"Issuer": "https://accounts.google.com",
|
||||
"EmailClaim": "email",
|
||||
"NameClaim": "name",
|
||||
"HostedDomainClaimName": "hd",
|
||||
"EnforceHostedDomainClaim": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Giai thich nhanh:
|
||||
- AllowedDomain:
|
||||
- Rong: cho phep moi domain.
|
||||
- Co gia tri (vd hust.edu.vn): chi cho email domain nay.
|
||||
- EnforceHostedDomainClaim=true:
|
||||
- Neu co claim hd trong token thi bat buoc phai khop AllowedDomain.
|
||||
- Neu token khong co hd, he thong fallback check theo email domain.
|
||||
- PendingRoleName: role duoc gan khi user moi dang nhap lan dau.
|
||||
|
||||
### 3.2 Google Cloud Console
|
||||
|
||||
1. Tao OAuth client (Web application).
|
||||
2. Authorized redirect URIs:
|
||||
- https://<your-domain>/api/auth/oauth/google/callback
|
||||
- (dev) https://localhost:<port>/api/auth/oauth/google/callback
|
||||
3. Lay ClientId, ClientSecret va dien vao appsettings (hoac secrets).
|
||||
|
||||
----------------------------------------
|
||||
|
||||
## 4) Dang ky DI va options
|
||||
|
||||
Trong Program.cs can co:
|
||||
|
||||
```csharp
|
||||
builder.Services.Configure<AzureAdOptions>(
|
||||
builder.Configuration.GetSection(AzureAdOptions.SectionName));
|
||||
|
||||
builder.Services.Configure<OAuthProvidersOptions>(
|
||||
builder.Configuration.GetSection(OAuthProvidersOptions.SectionName));
|
||||
|
||||
builder.Services.AddScoped<ISsoService, SsoService>();
|
||||
```
|
||||
|
||||
OAuthProvidersOptions dung dictionary provider theo key (vd google, azuread, okta).
|
||||
|
||||
----------------------------------------
|
||||
|
||||
## 5) Thiet ke interface va service chung
|
||||
|
||||
### 5.1 Interface ISsoService
|
||||
|
||||
Interface da duoc mo rong thanh provider-aware:
|
||||
- BuildAuthorizeUrl(provider, redirectUri, state)
|
||||
- ExchangeCodeAsync(provider, code, redirectUri, ct)
|
||||
- ValidateIdTokenAsync(provider, idToken, ct)
|
||||
- IsAllowedDomain(provider, email, principal)
|
||||
- UpsertUserAsync(provider, email, name, ct)
|
||||
|
||||
Va van giu overload cu de backward compatibility.
|
||||
|
||||
### 5.2 SsoService - logic tong
|
||||
|
||||
Core y tuong:
|
||||
- Resolve config theo provider key.
|
||||
- Provider nao khong co endpoint explicit thi suy ra tu metadata/authority.
|
||||
- AzureAD duoc fallback tu AzureAdOptions de khong vo luong cu.
|
||||
|
||||
Phan quan trong:
|
||||
|
||||
1) ResolveProvider(...)
|
||||
- Doc provider trong OAuthProviders.
|
||||
- Neu key = azuread ma khong co trong OAuthProviders, fallback AzureAdOptions.
|
||||
|
||||
2) BuildAuthorizeUrl(...)
|
||||
- Build URL authorize theo endpoint cua provider.
|
||||
- Them scope, state, redirect_uri.
|
||||
- Neu bat hosted-domain check thi them hd=<AllowedDomain>.
|
||||
|
||||
3) ExchangeCodeAsync(...)
|
||||
- Goi token endpoint voi client_id, client_secret, code, redirect_uri.
|
||||
- Parse OidcTokenResponse.
|
||||
- Bat buoc co id_token (flow hien tai dang OIDC-centric).
|
||||
|
||||
4) ValidateIdTokenAsync(...)
|
||||
- Lay metadata OIDC (.well-known/openid-configuration).
|
||||
- Validate issuer, audience, signing key, lifetime.
|
||||
|
||||
5) IsAllowedDomain(...)
|
||||
- Neu AllowedDomain rong -> cho phep.
|
||||
- Neu khong rong -> check duoi email.
|
||||
- Neu EnforceHostedDomainClaim bat va token co claim hd -> bat buoc hd trung AllowedDomain.
|
||||
|
||||
6) UpsertUserAsync(...)
|
||||
- Tim user theo email (UserName).
|
||||
- Neu ton tai: cap nhat ten + metadata update.
|
||||
- Neu chua ton tai: tao user moi, Password = null, gan role PendingRoleName.
|
||||
|
||||
7) One-time code
|
||||
- CreateOneTimeCodeAsync(...): tao code, luu bang SsoOneTimeCodes, het han sau 2 phut.
|
||||
- ExchangeOneTimeCodeForLoginAsync(...): consume code, tra payload login final.
|
||||
|
||||
----------------------------------------
|
||||
|
||||
## 6) Controller va contract API
|
||||
|
||||
### 6.1 OAuthController (da provider)
|
||||
|
||||
Route base: api/auth/oauth
|
||||
|
||||
1) Login
|
||||
- GET /api/auth/oauth/{provider}/login?returnUrl=...
|
||||
- Redirect sang provider authorize URL.
|
||||
|
||||
2) Callback
|
||||
- GET /api/auth/oauth/{provider}/callback?code=...&state=...
|
||||
- Xu ly token + domain + upsert + issue token + one-time code.
|
||||
- Redirect ve FE voi query code.
|
||||
|
||||
3) Exchange
|
||||
- POST /api/auth/oauth/exchange
|
||||
|
||||
Request:
|
||||
```json
|
||||
{
|
||||
"code": "one-time-code"
|
||||
}
|
||||
```
|
||||
|
||||
Response thanh cong:
|
||||
```json
|
||||
{
|
||||
"token": "<jwt-noi-bo>",
|
||||
"name": "...",
|
||||
"username": "email@domain",
|
||||
"access": [1, 2, 3],
|
||||
"role": {
|
||||
"roleName": "Pending",
|
||||
"priority": 99
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 SsoController (AzureAD legacy)
|
||||
|
||||
Van giu route cu, nhung da goi service chung voi provider azuread.
|
||||
|
||||
----------------------------------------
|
||||
|
||||
## 7) Rule user va role Pending
|
||||
|
||||
Rule bat buoc:
|
||||
- User OAuth lan dau phai duoc tao trong bang UserAccounts.
|
||||
- User moi phai vao role Pending.
|
||||
- Role Pending khong co permission nao (PermissionRoles.IsChecked = 0 hoac khong co row).
|
||||
- Admin se cap role/permission sau.
|
||||
|
||||
Model lien quan:
|
||||
- UserAccounts
|
||||
- Roles
|
||||
- PermissionRoles
|
||||
- SsoOneTimeCodes
|
||||
|
||||
Kiem tra nhanh trong DB:
|
||||
|
||||
```sql
|
||||
-- 1) Kiem tra role Pending co ton tai
|
||||
SELECT Id, RoleName, Priority
|
||||
FROM "Roles"
|
||||
WHERE "RoleName" = 'Pending';
|
||||
|
||||
-- 2) Kiem tra Pending role khong co permission active
|
||||
SELECT pr.*
|
||||
FROM "PermissionRoles" pr
|
||||
JOIN "Roles" r ON r."Id" = pr."RoleId"
|
||||
WHERE r."RoleName" = 'Pending'
|
||||
AND pr."IsChecked" = 1;
|
||||
|
||||
-- 3) Kiem tra user tao boi OAuth
|
||||
SELECT "Id", "UserName", "Password", "RoleId", "CreatedBy", "UpdatedBy"
|
||||
FROM "UserAccounts"
|
||||
WHERE "UserName" = '<email-user>';
|
||||
```
|
||||
|
||||
Ky vong:
|
||||
- Query #2 tra ve 0 rows.
|
||||
- User moi co Password = null, CreatedBy = 'SSO'.
|
||||
|
||||
----------------------------------------
|
||||
|
||||
## 8) Frontend integration (chi tiet)
|
||||
|
||||
1. Nguoi dung bam nut Google Login:
|
||||
- Window.location -> GET /api/auth/oauth/google/login?returnUrl=<FE_CALLBACK_URL>
|
||||
2. Sau callback backend, FE nhan code tu query string.
|
||||
3. FE goi:
|
||||
|
||||
```http
|
||||
POST /api/auth/oauth/exchange
|
||||
Content-Type: application/json
|
||||
|
||||
{ "code": "<code-tu-query>" }
|
||||
```
|
||||
|
||||
4. FE luu token va xu ly permission/role giong login cu.
|
||||
|
||||
----------------------------------------
|
||||
|
||||
## 9) Luu y bao mat va hardening
|
||||
|
||||
Nen lam tiep:
|
||||
- Validate returnUrl theo allowlist de tranh open redirect.
|
||||
- Luu va verify state/nonce server-side (cache/redis).
|
||||
- Dung IHttpClientFactory thay new HttpClient() de kiem soat timeout/retry.
|
||||
- Dua ClientSecret sang secret manager/env var.
|
||||
- Bat HTTPS va secure cookie policy day du.
|
||||
|
||||
----------------------------------------
|
||||
|
||||
## 10) Troubleshooting
|
||||
|
||||
1) Loi IdToken not found in token response
|
||||
- Provider dang khong tra OIDC token.
|
||||
- Kiem tra scope co openid chua.
|
||||
|
||||
2) Loi Email not found in token
|
||||
- Kiem tra claim trong Google token (email, preferred_username).
|
||||
|
||||
3) Loi Email domain is not allowed
|
||||
- Check AllowedDomain va claim hd.
|
||||
|
||||
4) Loi Role 'Pending' not found
|
||||
- Tao role Pending trong bang Roles.
|
||||
|
||||
5) Loi exchange code het han
|
||||
- One-time code chi song 2 phut va chi dung 1 lan.
|
||||
|
||||
----------------------------------------
|
||||
|
||||
## 11) Checklist test E2E truoc khi merge
|
||||
|
||||
- Login Google thanh cong voi account hop le.
|
||||
- User moi duoc tao trong DB va role = Pending.
|
||||
- Pending role khong co permission active.
|
||||
- User cu login lai thi khong tao duplicate user.
|
||||
- One-time code chi dung 1 lan.
|
||||
- AllowedDomain rong cho phep tat ca domain.
|
||||
- AllowedDomain co gia tri thi chan dung domain.
|
||||
|
||||
----------------------------------------
|
||||
|
||||
## 12) Ghi chu pham vi hien tai
|
||||
|
||||
Hien tai flow moi dang OIDC-centric (bat buoc id_token).
|
||||
Neu can support OAuth thuan (khong co id_token), can bo sung nhanh:
|
||||
- UserInfo endpoint call.
|
||||
- Mapping email/name tu userinfo response.
|
||||
- Branch logic trong callback de fallback userinfo.
|
||||
173
nginx/nginx.conf
173
nginx/nginx.conf
|
|
@ -3,204 +3,91 @@
|
|||
# server 127.0.0.1:8080;
|
||||
# server 172.18.10.8:8080;
|
||||
# }
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name comp.soict.io;
|
||||
server_name comp.soict.io;
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/certbot;
|
||||
}
|
||||
|
||||
location / {
|
||||
# root /usr/share/nginx/html;
|
||||
# index index.html index.htm;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
server{
|
||||
listen 443 ssl;
|
||||
server_name comp.soict.io;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/comp.soict.io/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/comp.soict.io/privkey.pem;
|
||||
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
|
||||
# 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 172.18.10.8: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;
|
||||
# Default file to serve for directory requests
|
||||
index index.html index.htm;
|
||||
|
||||
# MeshCentral auth entrypoint. If iframe/browser lands on /login due to
|
||||
# redirect, keep it on MeshCentral instead of frontend routing.
|
||||
location = /login {
|
||||
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 to serve the requested file directly ($uri)
|
||||
# If it's a directory, try serving the index file ($uri/)
|
||||
# If neither exists, fall back to serving /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)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public";
|
||||
access_log off;
|
||||
access_log off; # Optional: Don't log accesses for static files
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://$backend_server;
|
||||
|
||||
client_max_body_size 900M;
|
||||
# Cho phép upload file lớn (vd: 200MB)
|
||||
client_max_body_size 200M;
|
||||
|
||||
# Truyền thẳng stream sang backend
|
||||
proxy_request_buffering off;
|
||||
|
||||
# Tăng timeout khi upload
|
||||
proxy_read_timeout 300s;
|
||||
proxy_connect_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) {
|
||||
return 204;
|
||||
}
|
||||
}
|
||||
|
||||
location /api/Sse/events {
|
||||
proxy_pass http://$backend_server/api/Sse/events;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
# cần thiết cho SSE
|
||||
proxy_set_header Connection '';
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_read_timeout 1h;
|
||||
}
|
||||
|
||||
# MeshCentral client builds WebSocket URL from current location,
|
||||
# e.g. wss://comp.soict.io/control.ashx.
|
||||
location ~ ^/(control|meshrelay|commander|mesh)\.ashx$ {
|
||||
proxy_pass http://$meshserver;
|
||||
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_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;
|
||||
location /mesh-proxy/ {
|
||||
proxy_pass https://202.191.59.59/;
|
||||
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;
|
||||
|
||||
|
||||
# Cấu hình WebSocket cho commander.ashx
|
||||
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 $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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,15 +1,11 @@
|
|||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Monitor, Wifi, WifiOff, Loader2, Maximize2, X } from "lucide-react";
|
||||
import { Monitor, Wifi, WifiOff, Loader2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { FolderStatusPopover } from "../folder-status-popover";
|
||||
import { useGetClientFolderStatusForDevice } from "@/hooks/queries";
|
||||
import type { ClientFolderStatus } from "@/types/folder";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { getRemoteDesktopUrl } from "@/services/remote-control.service";
|
||||
import { buildMeshProxyUrl } from "@/config/api";
|
||||
import { toast } from "sonner";
|
||||
export function ComputerCard({
|
||||
device,
|
||||
position,
|
||||
|
|
@ -21,10 +17,6 @@ export function ComputerCard({
|
|||
folderStatus?: ClientFolderStatus;
|
||||
isCheckingFolder?: boolean;
|
||||
}) {
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const [showRemote, setShowRemote] = useState(false);
|
||||
const [proxyUrl, setProxyUrl] = useState<string | null>(null);
|
||||
|
||||
if (!device) {
|
||||
return (
|
||||
<div className="relative flex flex-col items-center justify-center w-24 h-24 rounded-lg border-2 border-dashed border-muted-foreground/30 bg-muted/20">
|
||||
|
|
@ -41,42 +33,6 @@ export function ComputerCard({
|
|||
const firstNetworkInfo = device.networkInfos?.[0];
|
||||
const agentVersion = device.version;
|
||||
|
||||
const handleConnect = async () => {
|
||||
if (!device?.id) {
|
||||
toast.error("Không tìm thấy nodeID của thiết bị.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsConnecting(true);
|
||||
const response = await getRemoteDesktopUrl(device.id);
|
||||
const originalUrl = new URL(response.url);
|
||||
const pathAndQuery = originalUrl.pathname + originalUrl.search;
|
||||
const proxyUrlFull = buildMeshProxyUrl(pathAndQuery);
|
||||
|
||||
setProxyUrl(proxyUrlFull);
|
||||
setShowRemote(true);
|
||||
} catch (error: any) {
|
||||
toast.error(
|
||||
error?.response?.data?.message || "Không thể kết nối remote cho thiết bị này."
|
||||
);
|
||||
} finally {
|
||||
setIsConnecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseRemote = () => {
|
||||
setShowRemote(false);
|
||||
setProxyUrl(null);
|
||||
};
|
||||
|
||||
const handleFullscreen = () => {
|
||||
const iframe = document.getElementById(`mesh-iframe-${device.id}`) as HTMLIFrameElement;
|
||||
if (iframe?.requestFullscreen) {
|
||||
iframe.requestFullscreen();
|
||||
}
|
||||
};
|
||||
|
||||
function DeviceFolderCheck() {
|
||||
const deviceId = device.id;
|
||||
const room = device.room;
|
||||
|
|
@ -171,26 +127,6 @@ export function ComputerCard({
|
|||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1">Kết nối</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={handleConnect}
|
||||
disabled={isOffline || isConnecting}
|
||||
className="w-full"
|
||||
>
|
||||
{isConnecting ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Đang kết nối...
|
||||
</>
|
||||
) : (
|
||||
"Connect"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1">Kiểm tra thư mục</div>
|
||||
<DeviceFolderCheck />
|
||||
|
|
@ -212,102 +148,62 @@ export function ComputerCard({
|
|||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex flex-col items-center justify-center w-24 h-24 rounded-lg border-2 transition-all hover:scale-105 cursor-pointer",
|
||||
isOffline
|
||||
? "bg-red-50 border-red-300 hover:border-red-400 hover:shadow-lg"
|
||||
: "bg-green-50 border-green-300 hover:border-green-400 hover:shadow-lg"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex flex-col items-center justify-center w-24 h-24 rounded-lg border-2 transition-all hover:scale-105 cursor-pointer",
|
||||
isOffline
|
||||
? "bg-red-50 border-red-300 hover:border-red-400 hover:shadow-lg"
|
||||
: "bg-green-50 border-green-300 hover:border-green-400 hover:shadow-lg"
|
||||
"absolute -top-2 -left-2 w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold",
|
||||
isOffline ? "bg-red-500 text-white" : "bg-green-500 text-white"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
{position}
|
||||
</div>
|
||||
|
||||
{/* Folder Status Icon */}
|
||||
{device && !isOffline && (
|
||||
<div className="absolute -top-2 -right-2">
|
||||
<FolderStatusPopover
|
||||
deviceId={device.networkInfos?.[0]?.macAddress || device.id}
|
||||
status={folderStatus}
|
||||
isLoading={isCheckingFolder}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Monitor className={cn("h-6 w-6 mb-1", isOffline ? "text-red-600" : "text-green-600")} />
|
||||
{firstNetworkInfo?.ipAddress && (
|
||||
<div className="text-[10px] font-mono text-center mb-1 px-1 truncate w-full">
|
||||
{firstNetworkInfo.ipAddress}
|
||||
{agentVersion && (
|
||||
<div className="text-[10px] font-mono text-center mb-1 px-1 truncate w-full">
|
||||
v{agentVersion}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-1">
|
||||
<span
|
||||
className={cn(
|
||||
"absolute -top-2 -left-2 w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold",
|
||||
isOffline ? "bg-red-500 text-white" : "bg-green-500 text-white"
|
||||
"text-xs font-medium",
|
||||
isOffline ? "text-red-700" : "text-green-700"
|
||||
)}
|
||||
>
|
||||
{position}
|
||||
</div>
|
||||
|
||||
{/* Folder Status Icon */}
|
||||
{device && !isOffline && (
|
||||
<div className="absolute -top-2 -right-2">
|
||||
<FolderStatusPopover
|
||||
deviceId={device.networkInfos?.[0]?.macAddress || device.id}
|
||||
status={folderStatus}
|
||||
isLoading={isCheckingFolder}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Monitor className={cn("h-6 w-6 mb-1", isOffline ? "text-red-600" : "text-green-600")} />
|
||||
{firstNetworkInfo?.ipAddress && (
|
||||
<div className="text-[10px] font-mono text-center mb-1 px-1 truncate w-full">
|
||||
{firstNetworkInfo.ipAddress}
|
||||
{agentVersion && (
|
||||
<div className="text-[10px] font-mono text-center mb-1 px-1 truncate w-full">
|
||||
v{agentVersion}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-1">
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs font-medium",
|
||||
isOffline ? "text-red-700" : "text-green-700"
|
||||
)}
|
||||
>
|
||||
{isOffline ? "Off" : "On"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto" side="top" align="center">
|
||||
<DeviceInfo />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{showRemote && proxyUrl && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/65 p-4">
|
||||
<div className="relative h-[90vh] w-[90vw] overflow-hidden rounded-lg border bg-background shadow-2xl">
|
||||
<div className="flex items-center justify-between border-b bg-muted/50 px-3 py-2">
|
||||
<p className="text-sm font-medium">Remote Session - {device.id}</p>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={handleFullscreen}
|
||||
title="Fullscreen"
|
||||
>
|
||||
<Maximize2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={handleCloseRemote}
|
||||
aria-label="Đóng"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<iframe
|
||||
id={`mesh-iframe-${device.id}`}
|
||||
title="Remote Desktop"
|
||||
src={proxyUrl}
|
||||
className="h-[calc(90vh-44px)] w-full border-0"
|
||||
allowFullScreen
|
||||
allow="clipboard-read; clipboard-write; camera; microphone"
|
||||
/>
|
||||
{isOffline ? "Off" : "On"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto" side="top" align="center">
|
||||
<DeviceInfo />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { Input } from "@/components/ui/input";
|
|||
import { Label } from "@/components/ui/label";
|
||||
import type { LoginResquest } from "@/types/auth";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { buildGoogleOAuthLoginUrl, buildMicrosoftSsoLoginUrl, login } from "@/services/auth.service";
|
||||
import { buildSsoLoginUrl, login } from "@/services/auth.service";
|
||||
import { useState } from "react";
|
||||
import { useNavigate, useRouter } from "@tanstack/react-router";
|
||||
import { Route } from "@/routes/(auth)/login";
|
||||
|
|
@ -44,22 +44,12 @@ export function LoginForm({ className }: React.ComponentProps<"form">) {
|
|||
}
|
||||
});
|
||||
|
||||
const handleGoogleLogin = () => {
|
||||
const returnUrl = new URL("/oauth/callback", window.location.origin);
|
||||
returnUrl.searchParams.set("provider", "google");
|
||||
const handleSsoLogin = () => {
|
||||
const returnUrl = new URL("/sso/callback", window.location.origin);
|
||||
if (search.redirect) {
|
||||
returnUrl.searchParams.set("redirect", search.redirect);
|
||||
}
|
||||
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()));
|
||||
window.location.assign(buildSsoLoginUrl(returnUrl.toString()));
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
|
|
@ -122,33 +112,12 @@ export function LoginForm({ className }: React.ComponentProps<"form">) {
|
|||
</Button>
|
||||
)}
|
||||
<div className="text-center text-sm text-muted-foreground">Hoặc</div>
|
||||
<Button type="button" variant="outline" className="w-full gap-2" onClick={handleGoogleLogin}>
|
||||
<Button type="button" variant="outline" className="w-full gap-2" onClick={handleSsoLogin}>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" className="h-4 w-4">
|
||||
<path
|
||||
fill="#4285F4"
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.02 5.02 0 0 1-2.18 3.29v2.74h3.52c2.05-1.89 3.3-4.67 3.3-8.04Z"
|
||||
/>
|
||||
<path
|
||||
fill="#34A853"
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.52-2.74c-.98.66-2.23 1.06-3.76 1.06-2.89 0-5.33-1.95-6.2-4.56H2.18v2.84A11 11 0 0 0 12 23Z"
|
||||
/>
|
||||
<path
|
||||
fill="#FBBC05"
|
||||
d="M5.8 14.1A6.62 6.62 0 0 1 5.45 12c0-.73.13-1.44.35-2.1V7.06H2.18A11 11 0 0 0 1 12c0 1.77.42 3.44 1.18 4.94L5.8 14.1Z"
|
||||
/>
|
||||
<path
|
||||
fill="#EA4335"
|
||||
d="M12 5.34c1.61 0 3.05.56 4.18 1.64l3.14-3.14C17.45 2.09 14.97 1 12 1a11 11 0 0 0-9.82 6.06L5.8 9.9C6.67 7.29 9.11 5.34 12 5.34Z"
|
||||
/>
|
||||
</svg>
|
||||
Đăng nhập với Google
|
||||
</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" />
|
||||
<rect x="1" y="1" width="10" height="10" fill="#F25022" />
|
||||
<rect x="13" y="1" width="10" height="10" fill="#7FBA00" />
|
||||
<rect x="1" y="13" width="10" height="10" fill="#00A4EF" />
|
||||
<rect x="13" y="13" width="10" height="10" fill="#FFB900" />
|
||||
</svg>
|
||||
Đăng nhập với Microsoft
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ export function UploadVersionForm({ onSubmit, closeDialog }: UploadVersionFormPr
|
|||
const [isDone, setIsDone] = useState(false);
|
||||
|
||||
// Match server allowed extensions
|
||||
const ALLOWED_EXTENSIONS = [".exe", ".apk", ".conf", ".json", ".xml", ".setting", ".lnk", ".url", ".seb", ".ps1", ".zip"];
|
||||
const ALLOWED_EXTENSIONS = [".exe", ".apk", ".conf", ".json", ".xml", ".setting", ".lnk", ".url", ".seb", ".ps1"];
|
||||
const isFileValid = (file: File) => {
|
||||
const fileName = file.name.toLowerCase();
|
||||
return ALLOWED_EXTENSIONS.some((ext) => fileName.endsWith(ext));
|
||||
|
|
|
|||
|
|
@ -1,46 +1,16 @@
|
|||
const isDev = import.meta.env.MODE === "development";
|
||||
|
||||
const trimTrailingSlash = (value: string) => value.replace(/\/+$/, "");
|
||||
|
||||
export const BASE_URL = isDev
|
||||
? import.meta.env.VITE_API_URL_DEV
|
||||
: "/api";
|
||||
|
||||
export const BASE_MESH_URL = isDev
|
||||
? (import.meta.env.VITE_API_MESH || import.meta.env.VITE_API_MESH_DEV || "")
|
||||
: (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;
|
||||
};
|
||||
? import.meta.env.VITE_API_MESH_DEV
|
||||
: "/meshapi";
|
||||
|
||||
export const API_ENDPOINTS = {
|
||||
AUTH: {
|
||||
LOGIN: `${BASE_URL}/login`,
|
||||
OAUTH_LOGIN: (provider: string) =>
|
||||
`${BASE_URL}/auth/oauth/${encodeURIComponent(provider)}/login`,
|
||||
OAUTH_EXCHANGE: `${BASE_URL}/auth/oauth/exchange`,
|
||||
SSO_LOGIN: `${BASE_URL}/auth/sso/login`,
|
||||
SSO_EXCHANGE: `${BASE_URL}/auth/sso/exchange`,
|
||||
LOGOUT: `${BASE_URL}/logout`,
|
||||
|
|
|
|||
|
|
@ -115,17 +115,10 @@ export function useCreateAccount() {
|
|||
}
|
||||
|
||||
/**
|
||||
* Hook để đổi one-time OAuth code lấy payload đăng nhập
|
||||
*/
|
||||
export function useExchangeOAuthCode() {
|
||||
return useMutation<LoginResponse, any, string>({
|
||||
mutationFn: (code) => authService.exchangeOAuthCode(code),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy alias for backward compatibility.
|
||||
* Hook để đổi one-time code SSO lấy payload đăng nhập
|
||||
*/
|
||||
export function useExchangeSsoCode() {
|
||||
return useExchangeOAuthCode();
|
||||
return useMutation<LoginResponse, any, string>({
|
||||
mutationFn: (code) => authService.exchangeSsoCode(code),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ import { Route as AuthRoomsRoomNameIndexRouteImport } from './routes/_auth/rooms
|
|||
import { Route as AuthRoleCreateIndexRouteImport } from './routes/_auth/role/create/index'
|
||||
import { Route as AuthProfileChangePasswordIndexRouteImport } from './routes/_auth/profile/change-password/index'
|
||||
import { Route as AuthProfileUserNameIndexRouteImport } from './routes/_auth/profile/$userName/index'
|
||||
import { Route as authOauthCallbackIndexRouteImport } from './routes/(auth)/oauth/callback/index'
|
||||
import { Route as authSsoCallbackIndexRouteImport } from './routes/(auth)/sso/callback/index'
|
||||
import { Route as AuthUserRoleRoleIdIndexRouteImport } from './routes/_auth/user/role/$roleId/index'
|
||||
import { Route as AuthUserEditUserNameIndexRouteImport } from './routes/_auth/user/edit/$userName/index'
|
||||
import { Route as AuthUserChangePasswordUserNameIndexRouteImport } from './routes/_auth/user/change-password/$userName/index'
|
||||
|
|
@ -132,9 +132,9 @@ const AuthProfileUserNameIndexRoute =
|
|||
path: '/profile/$userName/',
|
||||
getParentRoute: () => AuthRoute,
|
||||
} as any)
|
||||
const authOauthCallbackIndexRoute = authOauthCallbackIndexRouteImport.update({
|
||||
id: '/(auth)/oauth/callback/',
|
||||
path: '/oauth/callback/',
|
||||
const authSsoCallbackIndexRoute = authSsoCallbackIndexRouteImport.update({
|
||||
id: '/(auth)/sso/callback/',
|
||||
path: '/sso/callback/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const AuthUserRoleRoleIdIndexRoute = AuthUserRoleRoleIdIndexRouteImport.update({
|
||||
|
|
@ -186,7 +186,7 @@ export interface FileRoutesByFullPath {
|
|||
'/role': typeof AuthRoleIndexRoute
|
||||
'/rooms': typeof AuthRoomsIndexRoute
|
||||
'/user': typeof AuthUserIndexRoute
|
||||
'/oauth/callback': typeof authOauthCallbackIndexRoute
|
||||
'/sso/callback': typeof authSsoCallbackIndexRoute
|
||||
'/profile/$userName': typeof AuthProfileUserNameIndexRoute
|
||||
'/profile/change-password': typeof AuthProfileChangePasswordIndexRoute
|
||||
'/role/create': typeof AuthRoleCreateIndexRoute
|
||||
|
|
@ -213,7 +213,7 @@ export interface FileRoutesByTo {
|
|||
'/role': typeof AuthRoleIndexRoute
|
||||
'/rooms': typeof AuthRoomsIndexRoute
|
||||
'/user': typeof AuthUserIndexRoute
|
||||
'/oauth/callback': typeof authOauthCallbackIndexRoute
|
||||
'/sso/callback': typeof authSsoCallbackIndexRoute
|
||||
'/profile/$userName': typeof AuthProfileUserNameIndexRoute
|
||||
'/profile/change-password': typeof AuthProfileChangePasswordIndexRoute
|
||||
'/role/create': typeof AuthRoleCreateIndexRoute
|
||||
|
|
@ -242,7 +242,7 @@ export interface FileRoutesById {
|
|||
'/_auth/role/': typeof AuthRoleIndexRoute
|
||||
'/_auth/rooms/': typeof AuthRoomsIndexRoute
|
||||
'/_auth/user/': typeof AuthUserIndexRoute
|
||||
'/(auth)/oauth/callback/': typeof authOauthCallbackIndexRoute
|
||||
'/(auth)/sso/callback/': typeof authSsoCallbackIndexRoute
|
||||
'/_auth/profile/$userName/': typeof AuthProfileUserNameIndexRoute
|
||||
'/_auth/profile/change-password/': typeof AuthProfileChangePasswordIndexRoute
|
||||
'/_auth/role/create/': typeof AuthRoleCreateIndexRoute
|
||||
|
|
@ -271,7 +271,7 @@ export interface FileRouteTypes {
|
|||
| '/role'
|
||||
| '/rooms'
|
||||
| '/user'
|
||||
| '/oauth/callback'
|
||||
| '/sso/callback'
|
||||
| '/profile/$userName'
|
||||
| '/profile/change-password'
|
||||
| '/role/create'
|
||||
|
|
@ -298,7 +298,7 @@ export interface FileRouteTypes {
|
|||
| '/role'
|
||||
| '/rooms'
|
||||
| '/user'
|
||||
| '/oauth/callback'
|
||||
| '/sso/callback'
|
||||
| '/profile/$userName'
|
||||
| '/profile/change-password'
|
||||
| '/role/create'
|
||||
|
|
@ -326,7 +326,7 @@ export interface FileRouteTypes {
|
|||
| '/_auth/role/'
|
||||
| '/_auth/rooms/'
|
||||
| '/_auth/user/'
|
||||
| '/(auth)/oauth/callback/'
|
||||
| '/(auth)/sso/callback/'
|
||||
| '/_auth/profile/$userName/'
|
||||
| '/_auth/profile/change-password/'
|
||||
| '/_auth/role/create/'
|
||||
|
|
@ -344,7 +344,7 @@ export interface RootRouteChildren {
|
|||
IndexRoute: typeof IndexRoute
|
||||
AuthRoute: typeof AuthRouteWithChildren
|
||||
authLoginIndexRoute: typeof authLoginIndexRoute
|
||||
authOauthCallbackIndexRoute: typeof authOauthCallbackIndexRoute
|
||||
authSsoCallbackIndexRoute: typeof authSsoCallbackIndexRoute
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
|
|
@ -482,11 +482,11 @@ declare module '@tanstack/react-router' {
|
|||
preLoaderRoute: typeof AuthProfileUserNameIndexRouteImport
|
||||
parentRoute: typeof AuthRoute
|
||||
}
|
||||
'/(auth)/oauth/callback/': {
|
||||
id: '/(auth)/oauth/callback/'
|
||||
path: '/oauth/callback'
|
||||
fullPath: '/oauth/callback'
|
||||
preLoaderRoute: typeof authOauthCallbackIndexRouteImport
|
||||
'/(auth)/sso/callback/': {
|
||||
id: '/(auth)/sso/callback/'
|
||||
path: '/sso/callback'
|
||||
fullPath: '/sso/callback'
|
||||
preLoaderRoute: typeof authSsoCallbackIndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/_auth/user/role/$roleId/': {
|
||||
|
|
@ -592,7 +592,7 @@ const rootRouteChildren: RootRouteChildren = {
|
|||
IndexRoute: IndexRoute,
|
||||
AuthRoute: AuthRouteWithChildren,
|
||||
authLoginIndexRoute: authLoginIndexRoute,
|
||||
authOauthCallbackIndexRoute: authOauthCallbackIndexRoute,
|
||||
authSsoCallbackIndexRoute: authSsoCallbackIndexRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
|
|
|
|||
|
|
@ -1,63 +1,36 @@
|
|||
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useExchangeSsoCode } from "@/hooks/queries";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LoaderCircle } from "lucide-react";
|
||||
import axios from "axios";
|
||||
import { exchangeCodeByProvider } from "@/services/auth.service";
|
||||
import type { LoginResponse } from "@/types/auth";
|
||||
|
||||
const inFlightExchanges = new Map<string, Promise<LoginResponse>>();
|
||||
const consumedCodes = new Set<string>();
|
||||
|
||||
export const Route = createFileRoute("/(auth)/oauth/callback/")({
|
||||
component: OAuthCallbackPage,
|
||||
export const Route = createFileRoute("/(auth)/sso/callback/")({
|
||||
component: SsoCallbackPage,
|
||||
});
|
||||
|
||||
function OAuthCallbackPage() {
|
||||
function SsoCallbackPage() {
|
||||
const auth = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const search = Route.useSearch() as { code?: string; redirect?: string; provider?: string };
|
||||
const exchangeMutation = useExchangeSsoCode();
|
||||
const search = Route.useSearch() as { code?: string; redirect?: string };
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [isExchanging, setIsExchanging] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const code = search.code;
|
||||
const provider = (search.provider || "").toLowerCase();
|
||||
if (!code) {
|
||||
setErrorMessage("OAuth code is missing.");
|
||||
return;
|
||||
}
|
||||
|
||||
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.");
|
||||
if (!search.code) {
|
||||
setErrorMessage("SSO code is missing.");
|
||||
return;
|
||||
}
|
||||
|
||||
setErrorMessage(null);
|
||||
setIsExchanging(true);
|
||||
|
||||
let cancelled = false;
|
||||
let exchangePromise = inFlightExchanges.get(exchangeId);
|
||||
if (!exchangePromise) {
|
||||
exchangePromise = exchangeCodeByProvider(code, provider);
|
||||
inFlightExchanges.set(exchangeId, exchangePromise);
|
||||
}
|
||||
|
||||
exchangePromise
|
||||
.then(async (data) => {
|
||||
if (cancelled) return;
|
||||
|
||||
exchangeMutation.mutate(search.code, {
|
||||
onSuccess: async (data) => {
|
||||
if (!data.token) {
|
||||
setErrorMessage("OAuth response missing token.");
|
||||
setErrorMessage("SSO response missing token.");
|
||||
return;
|
||||
}
|
||||
|
||||
consumedCodes.add(exchangeId);
|
||||
|
||||
localStorage.setItem("token", data.token);
|
||||
localStorage.setItem("username", data.username || "");
|
||||
localStorage.setItem("name", data.name || "");
|
||||
|
|
@ -72,38 +45,22 @@ function OAuthCallbackPage() {
|
|||
auth.login(data.username || "");
|
||||
|
||||
await navigate({ to: search.redirect || "/dashboard" });
|
||||
})
|
||||
.catch((error) => {
|
||||
if (cancelled) return;
|
||||
|
||||
if (axios.isAxiosError(error) && error.response?.status === 401) {
|
||||
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.");
|
||||
return;
|
||||
}
|
||||
setErrorMessage("OAuth exchange failed.");
|
||||
})
|
||||
.finally(() => {
|
||||
inFlightExchanges.delete(exchangeId);
|
||||
if (!cancelled) {
|
||||
setIsExchanging(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [auth, navigate, search.code, search.provider, search.redirect]);
|
||||
},
|
||||
onError: () => {
|
||||
setErrorMessage("SSO exchange failed.");
|
||||
},
|
||||
});
|
||||
}, [auth, exchangeMutation, navigate, search.code, search.redirect]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-gradient-to-br from-background to-muted/20">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-xl">Đang xác thực OAuth</CardTitle>
|
||||
<CardTitle className="text-xl">Đang xác thực SSO</CardTitle>
|
||||
<CardDescription>Vui lòng đợi trong giây lát.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col items-center gap-4">
|
||||
{isExchanging && (
|
||||
{exchangeMutation.isPending && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<LoaderCircle className="w-4 h-4 animate-spin" />
|
||||
Đang trao đổi mã đăng nhập
|
||||
|
|
@ -6,7 +6,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { getRemoteDesktopUrl } from "@/services/remote-control.service";
|
||||
import { buildMeshProxyUrl } from "@/config/api";
|
||||
import { BASE_URL } from "@/config/api";
|
||||
|
||||
|
||||
export const Route = createFileRoute("/_auth/remote-control/")({
|
||||
|
|
@ -38,7 +38,9 @@ function RemoteControlPage() {
|
|||
// Chuyển URL MeshCentral thành proxy URL
|
||||
const originalUrl = new URL(data.url);
|
||||
const pathAndQuery = originalUrl.pathname + originalUrl.search;
|
||||
const proxyUrlFull = buildMeshProxyUrl(pathAndQuery);
|
||||
const cleanPath = pathAndQuery.startsWith('/') ? pathAndQuery.substring(1) : pathAndQuery;
|
||||
const baseWithoutApi = BASE_URL.replace('/api', '');
|
||||
const proxyUrlFull = `${baseWithoutApi}/api/meshcentral/proxy/${cleanPath}`;
|
||||
|
||||
console.log("[RemoteControl] Proxy URL:", proxyUrlFull);
|
||||
setProxyUrl(proxyUrlFull);
|
||||
|
|
|
|||
|
|
@ -36,25 +36,6 @@ function RouteComponent() {
|
|||
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 columns = useMemo(
|
||||
|
|
@ -99,7 +80,7 @@ function RouteComponent() {
|
|||
);
|
||||
|
||||
const table = useReactTable({
|
||||
data: sortedFolderStatusList,
|
||||
data: folderStatusList ?? [],
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
|
|
@ -107,7 +88,7 @@ function RouteComponent() {
|
|||
return (
|
||||
<FolderStatusTemplate
|
||||
roomName={roomName as string}
|
||||
data={sortedFolderStatusList}
|
||||
data={folderStatusList}
|
||||
isLoading={isLoading}
|
||||
onBack={() =>
|
||||
navigate({ to: "/rooms/$roomName/", params: { roomName } } as any)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import axios from "@/config/axios";
|
||||
import { API_ENDPOINTS } from "@/config/api";
|
||||
import rawAxios from "axios";
|
||||
import type { LoginResquest, LoginResponse, CreateAccountRequest } from "@/types/auth";
|
||||
|
||||
/**
|
||||
|
|
@ -17,93 +16,25 @@ export async function login(credentials: LoginResquest): Promise<LoginResponse>
|
|||
}
|
||||
|
||||
/**
|
||||
* Build OAuth login URL by provider
|
||||
* @param provider - OAuth provider key (e.g. google, azuread)
|
||||
* Build SSO login URL
|
||||
* @param returnUrl - FE callback url
|
||||
*/
|
||||
export function buildOAuthLoginUrl(provider: string, returnUrl: string): string {
|
||||
const base = API_ENDPOINTS.AUTH.OAUTH_LOGIN(provider);
|
||||
const encoded = encodeURIComponent(returnUrl);
|
||||
return `${base}?returnUrl=${encoded}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Google OAuth login URL
|
||||
* @param returnUrl - FE callback url
|
||||
*/
|
||||
export function buildGoogleOAuthLoginUrl(returnUrl: string): string {
|
||||
return buildOAuthLoginUrl("google", returnUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Microsoft SSO login URL (legacy endpoint)
|
||||
* @param returnUrl - FE callback url
|
||||
*/
|
||||
export function buildMicrosoftSsoLoginUrl(returnUrl: string): string {
|
||||
export function buildSsoLoginUrl(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 code for login payload
|
||||
* @param code - one-time code
|
||||
*/
|
||||
export async function exchangeOAuthCode(code: string): Promise<LoginResponse> {
|
||||
try {
|
||||
const response = await rawAxios.post<LoginResponse>(
|
||||
API_ENDPOINTS.AUTH.OAUTH_EXCHANGE,
|
||||
{ code }
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
if (rawAxios.isAxiosError(error)) {
|
||||
const status = error.response?.status;
|
||||
if (status === 401 || status === 404 || status === 405) {
|
||||
const fallbackResponse = await rawAxios.post<LoginResponse>(
|
||||
API_ENDPOINTS.AUTH.SSO_EXCHANGE,
|
||||
{ code }
|
||||
);
|
||||
return fallbackResponse.data;
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy AzureAD SSO URL builder kept for backward compatibility.
|
||||
*/
|
||||
export function buildSsoLoginUrl(returnUrl: string): string {
|
||||
return buildOAuthLoginUrl("azuread", returnUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy SSO exchange alias kept for backward compatibility.
|
||||
*/
|
||||
export async function exchangeSsoCode(code: string): Promise<LoginResponse> {
|
||||
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);
|
||||
const response = await axios.post<LoginResponse>(
|
||||
API_ENDPOINTS.AUTH.SSO_EXCHANGE,
|
||||
{ code }
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ export type UserProfile = {
|
|||
name: string;
|
||||
role: string;
|
||||
roleId: number;
|
||||
accessRooms: string[];
|
||||
accessRooms: number[];
|
||||
createdAt?: string | null;
|
||||
createdBy?: string | null;
|
||||
updatedAt?: string | null;
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
"outDir": "./dist",
|
||||
"composite": true,
|
||||
"noEmit": false,
|
||||
"ignoreDeprecations": "6.0",
|
||||
"types": []
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user