Compare commits

..

No commits in common. "main" and "HttpsUpgrade" have entirely different histories.

15 changed files with 146 additions and 997 deletions

View File

@ -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

View File

@ -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.

View File

@ -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;
}
}

View File

@ -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>
);
}

View File

@ -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>

View File

@ -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));

View File

@ -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`,

View File

@ -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),
});
}

View File

@ -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)

View File

@ -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 đăng nhập

View File

@ -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);

View File

@ -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)

View File

@ -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;
}
/**

View File

@ -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;

View File

@ -4,7 +4,6 @@
"outDir": "./dist",
"composite": true,
"noEmit": false,
"ignoreDeprecations": "6.0",
"types": []
},
"include": ["src/**/*.ts", "src/**/*.tsx"],