Compare commits

..

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

31 changed files with 246 additions and 1738 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

@ -1,74 +0,0 @@
# User API
Tai lieu mo ta cac endpoint cap nhat role va thong tin nguoi dung.
----------------------------------------
## 1) Cap nhat thong tin nguoi dung
- PUT /api/User/{id}
- Permission: EDIT_USER_ROLE
### Request
```json
{
"name": "Nguyen Van A",
"userName": "nguyenvana",
"accessRooms": [1, 2, 3]
}
```
### Response (200)
```json
{
"success": true,
"message": "User updated successfully",
"data": {
"userId": 12,
"userName": "nguyenvana",
"name": "Nguyen Van A",
"roleId": 3,
"accessRooms": [1, 2, 3],
"updatedAt": "2026-04-03T10:20:30Z",
"updatedBy": "admin"
}
}
```
### Ghi chu
- Neu khong truyen `accessRooms` thi giu nguyen danh sach phong.
- Neu truyen `accessRooms` = [] thi xoa tat ca phong.
- Neu `userName` bi trung hoac khong hop le thi tra ve 400.
----------------------------------------
## 2) Cap nhat role nguoi dung
- PUT /api/User/{id}/role
- Permission: EDIT_USER_ROLE
### Request
```json
{
"roleId": 2
}
```
### Response (200)
```json
{
"success": true,
"message": "User role updated",
"data": {
"userId": 12,
"userName": "nguyenvana",
"roleId": 2,
"roleName": "Manager",
"updatedAt": "2026-04-03T10:20:30Z",
"updatedBy": "admin"
}
}
```
### Ghi chu
- Chi System Admin moi duoc phep cap nhat role System Admin.
----------------------------------------

View File

@ -3,204 +3,79 @@
# server 127.0.0.1:8080;
# server 172.18.10.8:8080;
# }
server {
listen 80;
server_name comp.soict.io;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
return 301 https://$host$request_uri; # Redirect HTTP sang HTTPS
}
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;
ssl_certificate /etc/nginx/ssl/nginx-selfsigned.crt;
ssl_certificate_key /etc/nginx/ssl/nginx-selfsigned.key;
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

@ -22,7 +22,7 @@ export function RoomManagementCard({
<Card className="bg-white/80 backdrop-blur border-muted/60 shadow-sm">
<CardHeader>
<CardTitle>Quản phòng</CardTitle>
<CardDescription>Thông tin tổng quan các phòng đang không sử dụng</CardDescription>
<CardDescription>Thông tin tổng quan phòng cần chú ý</CardDescription>
</CardHeader>
<CardContent>
@ -47,7 +47,7 @@ export function RoomManagementCard({
</div>
<div className="mt-4">
<div className="text-sm font-medium">Phòng không dùng</div>
<div className="text-sm font-medium">Phòng cần chú ý</div>
<div className="mt-2 space-y-2">
{data?.roomsNeedAttention && data.roomsNeedAttention.length > 0 ? (
data.roomsNeedAttention.map((r: RoomHealthStatus) => (

View File

@ -2,7 +2,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/u
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { useEffect, useMemo, useState } from "react";
import { useState, useMemo } from "react";
export interface SelectItem {
label: string;
@ -16,7 +16,6 @@ interface SelectDialogProps {
description?: string;
icon?: React.ReactNode;
items: SelectItem[];
selectedValues?: string[];
onConfirm: (values: string[]) => Promise<void> | void;
}
@ -27,18 +26,11 @@ export function SelectDialog({
description,
icon,
items,
selectedValues,
onConfirm,
}: SelectDialogProps) {
const [selected, setSelected] = useState<string[]>([]);
const [search, setSearch] = useState("");
useEffect(() => {
if (!open) return;
if (!selectedValues) return;
setSelected(selectedValues);
}, [open, selectedValues]);
const filteredItems = useMemo(() => {
return items.filter((item) =>
item.label.toLowerCase().includes(search.toLowerCase())

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`,
@ -51,10 +21,6 @@ export const API_ENDPOINTS = {
CREATE_ACCOUNT: `${BASE_URL}/auth/create-account`,
GET_USERS_LIST: `${BASE_URL}/users-info`,
},
USER: {
UPDATE_INFO: (id: number) => `${BASE_URL}/User/${id}`,
UPDATE_ROLE: (id: number) => `${BASE_URL}/User/${id}/role`,
},
APP_VERSION: {
//agent and app api
GET_VERSION: `${BASE_URL}/AppVersion/version`,
@ -72,7 +38,7 @@ export const API_ENDPOINTS = {
GET_REQUIRED_FILES: `${BASE_URL}/AppVersion/requirefiles`,
ADD_REQUIRED_FILE: `${BASE_URL}/AppVersion/requirefile/add`,
DELETE_REQUIRED_FILE: `${BASE_URL}/AppVersion/requirefile/delete`,
DELETE_FILES: `${BASE_URL}/AppVersion/delete`,
DELETE_FILES: (fileId: number) => `${BASE_URL}/AppVersion/delete/${fileId}`,
},
DEVICE_COMM: {
DOWNLOAD_FILES: (roomName: string) => `${BASE_URL}/DeviceComm/downloadfile/${roomName}`,

View File

@ -176,7 +176,7 @@ export function useDeleteFile() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: { MsiFileIds: number[] }) => appVersionService.deleteFile(data),
mutationFn: (fileId: number) => appVersionService.deleteFile(fileId),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: APP_VERSION_QUERY_KEYS.softwareList(),

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

@ -1,10 +1,6 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import * as userService from "@/services/user.service";
import type {
UserProfile,
UpdateUserInfoRequest,
UpdateUserRoleRequest,
} from "@/types/user-profile";
import type { UserProfile } from "@/types/user-profile";
const USER_QUERY_KEYS = {
all: ["users"] as const,
@ -22,47 +18,3 @@ export function useGetUsersInfo(enabled = true) {
staleTime: 5 * 60 * 1000, // 5 minutes
});
}
/**
* Hook đ cập nhật thông tin người dùng
*/
export function useUpdateUserInfo() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
id,
data,
}: {
id: number;
data: UpdateUserInfoRequest;
}) => userService.updateUserInfo(id, data),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: USER_QUERY_KEYS.list(),
});
},
});
}
/**
* Hook đ cập nhật role người dùng
*/
export function useUpdateUserRole() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
id,
data,
}: {
id: number;
data: UpdateUserRoleRequest;
}) => userService.updateUserRole(id, data),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: USER_QUERY_KEYS.list(),
});
},
});
}

View File

@ -28,9 +28,8 @@ 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'
import { Route as AuthRoomsRoomNameFolderStatusIndexRouteImport } from './routes/_auth/rooms/$roomName/folder-status/index'
import { Route as AuthRoomsRoomNameConnectIndexRouteImport } from './routes/_auth/rooms/$roomName/connect/index'
@ -132,9 +131,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({
@ -142,12 +141,6 @@ const AuthUserRoleRoleIdIndexRoute = AuthUserRoleRoleIdIndexRouteImport.update({
path: '/user/role/$roleId/',
getParentRoute: () => AuthRoute,
} as any)
const AuthUserEditUserNameIndexRoute =
AuthUserEditUserNameIndexRouteImport.update({
id: '/user/edit/$userName/',
path: '/user/edit/$userName/',
getParentRoute: () => AuthRoute,
} as any)
const AuthUserChangePasswordUserNameIndexRoute =
AuthUserChangePasswordUserNameIndexRouteImport.update({
id: '/user/change-password/$userName/',
@ -186,7 +179,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
@ -196,7 +189,6 @@ export interface FileRoutesByFullPath {
'/rooms/$roomName/connect': typeof AuthRoomsRoomNameConnectIndexRoute
'/rooms/$roomName/folder-status': typeof AuthRoomsRoomNameFolderStatusIndexRoute
'/user/change-password/$userName': typeof AuthUserChangePasswordUserNameIndexRoute
'/user/edit/$userName': typeof AuthUserEditUserNameIndexRoute
'/user/role/$roleId': typeof AuthUserRoleRoleIdIndexRoute
}
export interface FileRoutesByTo {
@ -213,7 +205,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
@ -223,7 +215,6 @@ export interface FileRoutesByTo {
'/rooms/$roomName/connect': typeof AuthRoomsRoomNameConnectIndexRoute
'/rooms/$roomName/folder-status': typeof AuthRoomsRoomNameFolderStatusIndexRoute
'/user/change-password/$userName': typeof AuthUserChangePasswordUserNameIndexRoute
'/user/edit/$userName': typeof AuthUserEditUserNameIndexRoute
'/user/role/$roleId': typeof AuthUserRoleRoleIdIndexRoute
}
export interface FileRoutesById {
@ -242,7 +233,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
@ -252,7 +243,6 @@ export interface FileRoutesById {
'/_auth/rooms/$roomName/connect/': typeof AuthRoomsRoomNameConnectIndexRoute
'/_auth/rooms/$roomName/folder-status/': typeof AuthRoomsRoomNameFolderStatusIndexRoute
'/_auth/user/change-password/$userName/': typeof AuthUserChangePasswordUserNameIndexRoute
'/_auth/user/edit/$userName/': typeof AuthUserEditUserNameIndexRoute
'/_auth/user/role/$roleId/': typeof AuthUserRoleRoleIdIndexRoute
}
export interface FileRouteTypes {
@ -271,7 +261,7 @@ export interface FileRouteTypes {
| '/role'
| '/rooms'
| '/user'
| '/oauth/callback'
| '/sso/callback'
| '/profile/$userName'
| '/profile/change-password'
| '/role/create'
@ -281,7 +271,6 @@ export interface FileRouteTypes {
| '/rooms/$roomName/connect'
| '/rooms/$roomName/folder-status'
| '/user/change-password/$userName'
| '/user/edit/$userName'
| '/user/role/$roleId'
fileRoutesByTo: FileRoutesByTo
to:
@ -298,7 +287,7 @@ export interface FileRouteTypes {
| '/role'
| '/rooms'
| '/user'
| '/oauth/callback'
| '/sso/callback'
| '/profile/$userName'
| '/profile/change-password'
| '/role/create'
@ -308,7 +297,6 @@ export interface FileRouteTypes {
| '/rooms/$roomName/connect'
| '/rooms/$roomName/folder-status'
| '/user/change-password/$userName'
| '/user/edit/$userName'
| '/user/role/$roleId'
id:
| '__root__'
@ -326,7 +314,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/'
@ -336,7 +324,6 @@ export interface FileRouteTypes {
| '/_auth/rooms/$roomName/connect/'
| '/_auth/rooms/$roomName/folder-status/'
| '/_auth/user/change-password/$userName/'
| '/_auth/user/edit/$userName/'
| '/_auth/user/role/$roleId/'
fileRoutesById: FileRoutesById
}
@ -344,7 +331,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 +469,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/': {
@ -496,13 +483,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthUserRoleRoleIdIndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/user/edit/$userName/': {
id: '/_auth/user/edit/$userName/'
path: '/user/edit/$userName'
fullPath: '/user/edit/$userName'
preLoaderRoute: typeof AuthUserEditUserNameIndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/user/change-password/$userName/': {
id: '/_auth/user/change-password/$userName/'
path: '/user/change-password/$userName'
@ -555,7 +535,6 @@ interface AuthRouteChildren {
AuthRoomsRoomNameConnectIndexRoute: typeof AuthRoomsRoomNameConnectIndexRoute
AuthRoomsRoomNameFolderStatusIndexRoute: typeof AuthRoomsRoomNameFolderStatusIndexRoute
AuthUserChangePasswordUserNameIndexRoute: typeof AuthUserChangePasswordUserNameIndexRoute
AuthUserEditUserNameIndexRoute: typeof AuthUserEditUserNameIndexRoute
AuthUserRoleRoleIdIndexRoute: typeof AuthUserRoleRoleIdIndexRoute
}
@ -582,7 +561,6 @@ const AuthRouteChildren: AuthRouteChildren = {
AuthRoomsRoomNameFolderStatusIndexRoute,
AuthUserChangePasswordUserNameIndexRoute:
AuthUserChangePasswordUserNameIndexRoute,
AuthUserEditUserNameIndexRoute: AuthUserEditUserNameIndexRoute,
AuthUserRoleRoleIdIndexRoute: AuthUserRoleRoleIdIndexRoute,
}
@ -592,7 +570,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

@ -137,10 +137,11 @@ function AppsComponent() {
return;
}
const MsiFileIds = selectedRows.map((row: any) => row.original.id);
try {
await deleteMutation.mutateAsync({ MsiFileIds });
for (const row of selectedRows) {
const { id } = row.original;
await deleteMutation.mutateAsync(id);
}
toast.success("Xóa phần mềm thành công!");
} catch (e) {
toast.error("Xóa phần mềm thất bại!");
@ -174,10 +175,12 @@ function AppsComponent() {
if (!table) return;
const selectedRows = table.getSelectedRowModel().rows;
const MsiFileIds = selectedRows.map((row: any) => row.original.id);
try {
await deleteMutation.mutateAsync({ MsiFileIds });
for (const row of selectedRows) {
const { id } = row.original;
await deleteMutation.mutateAsync(id);
}
toast.success("Xóa phần mềm từ server thành công!");
if (table) {
table.setRowSelection({});

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

@ -9,9 +9,6 @@ import { LoaderCircle } from "lucide-react";
import { useState } from "react";
export const Route = createFileRoute("/_auth/user/change-password/$userName/")({
head: () => ({
meta: [{ title: "Thay đổi mật khẩu" }],
}),
component: AdminChangePasswordComponent,
loader: async ({ context, params }) => {
context.breadcrumbs = [

View File

@ -22,9 +22,6 @@ import { UserPlus, ArrowLeft, Save, Loader2 } from "lucide-react";
import { toast } from "sonner";
export const Route = createFileRoute("/_auth/user/create/")({
head: () => ({
meta: [{ title: "Tạo người dùng mới" }],
}),
component: CreateUserComponent,
loader: async ({ context }) => {
context.breadcrumbs = [
@ -62,8 +59,7 @@ function CreateUserComponent() {
if (!formData.userName) {
newErrors.userName = "Tên đăng nhập không được để trống";
} else if (!validateUserName(formData.userName)) {
newErrors.userName =
"Tên đăng nhập chỉ cho phép chữ cái, số, dấu chấm và gạch dưới (3-20 ký tự)";
newErrors.userName = "Tên đăng nhập chỉ cho phép chữ cái, số, dấu chấm và gạch dưới (3-20 ký tự)";
}
// Validate name
@ -110,8 +106,7 @@ function CreateUserComponent() {
toast.success("Tạo tài khoản thành công!");
navigate({ to: "/dashboard" }); // TODO: Navigate to user list page when it exists
} catch (error: any) {
const errorMessage =
error.response?.data?.message || "Tạo tài khoản thất bại!";
const errorMessage = error.response?.data?.message || "Tạo tài khoản thất bại!";
toast.error(errorMessage);
}
};
@ -133,14 +128,15 @@ function CreateUserComponent() {
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">
Tạo người dùng mới
</h1>
<h1 className="text-3xl font-bold tracking-tight">Tạo người dùng mới</h1>
<p className="text-muted-foreground mt-1">
Thêm tài khoản người dùng mới vào hệ thống
</p>
</div>
<Button variant="outline" onClick={() => navigate({ to: "/user" })}>
<Button
variant="outline"
onClick={() => navigate({ to: "/user" })}
>
<ArrowLeft className="h-4 w-4 mr-2" />
Quay lại
</Button>
@ -168,9 +164,7 @@ function CreateUserComponent() {
<Input
id="userName"
value={formData.userName}
onChange={(e) =>
handleInputChange("userName", e.target.value)
}
onChange={(e) => handleInputChange("userName", e.target.value)}
placeholder="Nhập tên đăng nhập (3-20 ký tự, chỉ chữ, số, . và _)"
disabled={createMutation.isPending}
className="h-10"
@ -208,9 +202,7 @@ function CreateUserComponent() {
id="password"
type="password"
value={formData.password}
onChange={(e) =>
handleInputChange("password", e.target.value)
}
onChange={(e) => handleInputChange("password", e.target.value)}
placeholder="Nhập mật khẩu (tối thiểu 6 ký tự)"
disabled={createMutation.isPending}
className="h-10"
@ -228,17 +220,13 @@ function CreateUserComponent() {
id="confirmPassword"
type="password"
value={formData.confirmPassword}
onChange={(e) =>
handleInputChange("confirmPassword", e.target.value)
}
onChange={(e) => handleInputChange("confirmPassword", e.target.value)}
placeholder="Nhập lại mật khẩu"
disabled={createMutation.isPending}
className="h-10"
/>
{errors.confirmPassword && (
<p className="text-sm text-destructive">
{errors.confirmPassword}
</p>
<p className="text-sm text-destructive">{errors.confirmPassword}</p>
)}
</div>
</div>
@ -300,8 +288,8 @@ function CreateUserComponent() {
>
Hủy
</Button>
<Button
type="submit"
<Button
type="submit"
disabled={createMutation.isPending}
className="min-w-[140px]"
>

View File

@ -1,361 +0,0 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useEffect, useMemo, useState } from "react";
import {
useGetRoleList,
useGetRoomList,
useGetUsersInfo,
useUpdateUserInfo,
useUpdateUserRole,
} from "@/hooks/queries";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { SelectDialog } from "@/components/dialogs/select-dialog";
import { ArrowLeft, Save } from "lucide-react";
import { toast } from "sonner";
import type { UserProfile } from "@/types/user-profile";
export const Route = createFileRoute("/_auth/user/edit/$userName/")({
head: () => ({
meta: [{ title: "Chỉnh sửa người dùng" }],
}),
component: EditUserComponent,
loader: async ({ context, params }) => {
context.breadcrumbs = [
{ title: "Quản lý người dùng", path: "/user" },
{
title: `Chỉnh sửa thông tin người dùng ${params.userName}`,
path: `/user/edit/${params.userName}`,
},
];
},
});
function EditUserComponent() {
const { userName } = Route.useParams();
const navigate = useNavigate();
const { data: users = [], isLoading } = useGetUsersInfo();
const { data: roomData = [], isLoading: roomsLoading } = useGetRoomList();
const { data: roles = [], isLoading: rolesLoading } = useGetRoleList();
const updateUserInfoMutation = useUpdateUserInfo();
const updateUserRoleMutation = useUpdateUserRole();
const user = useMemo(() => {
return users.find((u) => u.userName === userName) as
| UserProfile
| undefined;
}, [users, userName]);
const [editForm, setEditForm] = useState({
userName: "",
name: "",
});
const [selectedRoleId, setSelectedRoleId] = useState<string>("");
const [selectedRoomValues, setSelectedRoomValues] = useState<string[]>([]);
const [isRoomDialogOpen, setIsRoomDialogOpen] = useState(false);
const roomOptions = useMemo(() => {
const list = Array.isArray(roomData) ? roomData : [];
return list
.map((room: any) => {
const rawValue =
room.id ??
room.roomId ??
room.roomID ??
room.Id ??
room.ID ??
room.RoomId ??
room.RoomID ??
room.name ??
room.roomName ??
room.RoomName ??
"";
const label =
room.name ?? room.roomName ?? room.RoomName ?? (rawValue ? String(rawValue) : "");
if (!rawValue || !label) return null;
return { label: String(label), value: String(rawValue) };
})
.filter((item): item is { label: string; value: string } => !!item);
}, [roomData]);
const roomLabelMap = useMemo(() => {
return new Map(roomOptions.map((room) => [room.value, room.label]));
}, [roomOptions]);
useEffect(() => {
if (!user) return;
setEditForm({
userName: user.userName ?? "",
name: user.name ?? "",
});
setSelectedRoleId(user.roleId ? String(user.roleId) : "");
setSelectedRoomValues(
Array.isArray(user.accessRooms)
? user.accessRooms.map((roomId) => String(roomId))
: []
);
}, [user]);
const handleUpdateUserInfo = async () => {
if (!user?.userId) {
toast.error("Không tìm thấy userId để cập nhật.");
return;
}
const nextUserName = editForm.userName.trim();
const nextName = editForm.name.trim();
if (!nextUserName || !nextName) {
toast.error("Vui lòng nhập đầy đủ tên đăng nhập và họ tên.");
return;
}
try {
const accessRooms = selectedRoomValues
.map((value) => Number(value))
.filter((value) => Number.isFinite(value));
if (
selectedRoomValues.length > 0 &&
accessRooms.length !== selectedRoomValues.length
) {
toast.error("Danh sách phòng không hợp lệ, vui lòng chọn lại.");
return;
}
await updateUserInfoMutation.mutateAsync({
id: user.userId,
data: {
userName: nextUserName,
name: nextName,
accessRooms,
},
});
toast.success("Cập nhật thông tin người dùng thành công!");
} catch (error: any) {
const message = error?.response?.data?.message || "Cập nhật thất bại!";
toast.error(message);
}
};
const handleUpdateUserRole = async () => {
if (!user?.userId) {
toast.error("Không tìm thấy userId để cập nhật role.");
return;
}
if (!selectedRoleId) {
toast.error("Vui lòng chọn vai trò.");
return;
}
try {
await updateUserRoleMutation.mutateAsync({
id: user.userId,
data: { roleId: Number(selectedRoleId) },
});
toast.success("Cập nhật vai trò thành công!");
} catch (error: any) {
const message =
error?.response?.data?.message || "Cập nhật vai trò thất bại!";
toast.error(message);
}
};
if (isLoading) {
return (
<div className="w-full px-6 py-8">
<div className="flex items-center justify-center min-h-[320px]">
<div className="text-muted-foreground">
Đang tải thông tin người dùng...
</div>
</div>
</div>
);
}
if (!user) {
return (
<div className="w-full px-6 py-8 space-y-4">
<Button variant="outline" onClick={() => navigate({ to: "/user" })}>
<ArrowLeft className="h-4 w-4 mr-2" />
Quay lại
</Button>
<div className="text-muted-foreground">
Không tìm thấy người dùng cần chỉnh sửa.
</div>
</div>
);
}
return (
<div className="w-full px-6 py-6 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">
Chỉnh sửa người dùng
</h1>
<p className="text-muted-foreground mt-1">
Tài khoản: {user.userName}
</p>
</div>
<Button variant="outline" onClick={() => navigate({ to: "/user" })}>
<ArrowLeft className="h-4 w-4 mr-2" />
Quay lại
</Button>
</div>
<Card className="shadow-sm">
<CardHeader>
<CardTitle>Thông tin người dùng</CardTitle>
<CardDescription>
Cập nhật họ tên, username danh sách phòng truy cập.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="edit-userName">Tên đăng nhập</Label>
<Input
id="edit-userName"
value={editForm.userName}
onChange={(e) =>
setEditForm((prev) => ({ ...prev, userName: e.target.value }))
}
disabled={updateUserInfoMutation.isPending}
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-name">Họ tên</Label>
<Input
id="edit-name"
value={editForm.name}
onChange={(e) =>
setEditForm((prev) => ({ ...prev, name: e.target.value }))
}
disabled={updateUserInfoMutation.isPending}
/>
</div>
</div>
<div className="space-y-2">
<Label>Các phòng phụ trách</Label>
<div className="flex flex-wrap gap-2">
{selectedRoomValues.length > 0 ? (
selectedRoomValues.map((value) => (
<Badge key={value} variant="secondary">
{roomLabelMap.get(value) ?? value}
</Badge>
))
) : (
<span className="text-xs text-muted-foreground">
Chưa chọn phòng.
</span>
)}
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
onClick={() => setIsRoomDialogOpen(true)}
disabled={roomsLoading || updateUserInfoMutation.isPending}
>
Chọn phòng
</Button>
{roomsLoading && (
<span className="text-xs text-muted-foreground">
Đang tải danh sách phòng...
</span>
)}
</div>
</div>
<div className="flex justify-end">
<Button
type="button"
onClick={handleUpdateUserInfo}
disabled={updateUserInfoMutation.isPending}
className="gap-2"
>
<Save className="h-4 w-4" />
{updateUserInfoMutation.isPending
? "Đang lưu..."
: "Lưu thông tin"}
</Button>
</div>
</CardContent>
</Card>
<Card className="shadow-sm">
<CardHeader>
<CardTitle>Vai trò</CardTitle>
<CardDescription>Cập nhật vai trò của người dùng.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2 max-w-md">
<Label>Vai trò</Label>
<Select
value={selectedRoleId}
onValueChange={setSelectedRoleId}
disabled={rolesLoading || updateUserRoleMutation.isPending}
>
<SelectTrigger className="w-full">
<SelectValue
placeholder={
rolesLoading ? "Đang tải vai trò..." : "Chọn vai trò"
}
/>
</SelectTrigger>
<SelectContent>
{roles.map((role) => (
<SelectItem key={role.id} value={String(role.id)}>
{role.roleName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex justify-end">
<Button
type="button"
onClick={handleUpdateUserRole}
disabled={updateUserRoleMutation.isPending || rolesLoading}
className="gap-2"
>
<Save className="h-4 w-4" />
{updateUserRoleMutation.isPending ? "Đang lưu..." : "Lưu vai trò"}
</Button>
</div>
</CardContent>
</Card>
<SelectDialog
open={isRoomDialogOpen}
onClose={() => setIsRoomDialogOpen(false)}
title="Chọn phòng phụ trách"
description="Chọn một hoặc nhiều phòng để gán quyền truy cập."
items={roomOptions}
selectedValues={selectedRoomValues}
onConfirm={(values) => setSelectedRoomValues(values)}
/>
</div>
);
}

View File

@ -10,13 +10,10 @@ import {
} from "@/components/ui/tooltip";
import type { ColumnDef } from "@tanstack/react-table";
import { VersionTable } from "@/components/tables/version-table";
import { Edit2, Settings, Shield, Trash2 } from "lucide-react";
import { Edit2, Trash2, Shield } from "lucide-react";
import { toast } from "sonner";
export const Route = createFileRoute("/_auth/user/")({
head: () => ({
meta: [{ title: "Danh sách người dùng" }],
}),
component: RouteComponent,
loader: async ({ context }) => {
context.breadcrumbs = [
@ -68,6 +65,21 @@ function RouteComponent() {
<div className="flex justify-center">{Array.isArray(getValue()) ? (getValue() as number[]).join(", ") : "-"}</div>
),
},
{
id: "select",
header: () => <div className="text-center whitespace-normal max-w-xs">Chọn</div>,
cell: ({ row }) => (
<div className="flex justify-center">
<input
type="checkbox"
checked={row.getIsSelected?.() ?? false}
onChange={row.getToggleSelectedHandler?.()}
/>
</div>
),
enableSorting: false,
enableHiding: false,
},
{
id: "actions",
header: () => (
@ -75,78 +87,42 @@ function RouteComponent() {
),
cell: ({ row }) => (
<div className="flex gap-2 justify-center items-center">
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
navigate({
to: "/user/edit/$userName",
params: { userName: row.original.userName },
} as any);
}}
>
<Settings className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">Đi thông tin</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
navigate({
to: "/user/change-password/$userName",
params: { userName: row.original.userName },
} as any);
}}
>
<Edit2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">Đi mật khẩu</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
navigate({
to: "/user/role/$roleId",
params: { roleId: String(row.original.roleId) },
} as any);
}}
>
<Shield className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">Xem quyền</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
onClick={async (e) => {
e.stopPropagation();
if (!confirm("Bạn có chắc muốn xóa người dùng này?")) return;
// Placeholder delete - implement API call as needed
toast.success("Xóa người dùng (chưa thực thi API)");
if (table) table.setRowSelection({});
}}
>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">Xóa người dùng</TooltipContent>
</Tooltip>
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
navigate({
to: "/user/change-password/$userName",
params: { userName: row.original.userName },
} as any);
}}
>
<Edit2 className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
navigate({ to: "/user/role/$roleId", params: { roleId: String(row.original.roleId) } } as any);
}}
>
<Shield className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={async (e) => {
e.stopPropagation();
if (!confirm("Bạn có chắc muốn xóa người dùng này?")) return;
// Placeholder delete - implement API call as needed
toast.success("Xóa người dùng (chưa thực thi API)");
if (table) table.setRowSelection({});
}}
>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
</div>
),
enableSorting: false,

View File

@ -14,7 +14,7 @@ import type { PermissionOnRole } from "@/types/permission";
export const Route = createFileRoute("/_auth/user/role/$roleId/")({
head: () => ({
meta: [{ title: "Quyền của người dùng" }]
meta: [{ title: "Quyền của người dùng | AccessControl" }]
}),
component: ViewRolePermissionsComponent,
loader: async ({ context, params }) => {

View File

@ -120,12 +120,9 @@ export async function deleteRequiredFile(data: { MsiFileIds: number[] }): Promis
/**
* Xóa file từ server
* @param data - DownloadMsiRequest { MsiFileIds: number[] }
* @param fileId - ID file
*/
export async function deleteFile(data: { MsiFileIds: number[] }): Promise<{ message: string }> {
const response = await axios.delete(
API_ENDPOINTS.APP_VERSION.DELETE_FILES,
{ data }
);
export async function deleteFile(fileId: number): Promise<{ message: string }> {
const response = await axios.delete(API_ENDPOINTS.APP_VERSION.DELETE_FILES(fileId));
return response.data;
}

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

@ -1,20 +1,6 @@
import axios from "@/config/axios";
import { API_ENDPOINTS } from "@/config/api";
import type {
UserProfile,
UpdateUserInfoRequest,
UpdateUserRoleRequest,
UpdateUserInfoResponse,
UpdateUserRoleResponse,
} from "@/types/user-profile";
// Helper to extract data from wrapped or unwrapped response
function extractData<T>(responseData: any): T {
if (responseData && typeof responseData === "object" && "success" in responseData && "data" in responseData) {
return responseData.data as T;
}
return responseData as T;
}
import type { UserProfile } from "@/types/user-profile";
/**
* Lấy danh sách thông tin người dùng chuyển sang camelCase keys
@ -25,7 +11,6 @@ export async function getUsersInfo(): Promise<UserProfile[]> {
const list = Array.isArray(response.data) ? response.data : [];
return list.map((u: any) => ({
userId: u.id ?? u.Id ?? u.userId ?? u.UserId ?? undefined,
userName: u.userName ?? u.UserName ?? "",
name: u.name ?? u.Name ?? "",
role: u.role ?? u.Role ?? "",
@ -46,32 +31,4 @@ export async function getUsersInfo(): Promise<UserProfile[]> {
}
}
/**
* Cập nhật thông tin người dùng
*/
export async function updateUserInfo(
userId: number,
data: UpdateUserInfoRequest
): Promise<UpdateUserInfoResponse> {
const response = await axios.put(
API_ENDPOINTS.USER.UPDATE_INFO(userId),
data
);
return extractData<UpdateUserInfoResponse>(response.data);
}
/**
* Cập nhật role người dùng
*/
export async function updateUserRole(
userId: number,
data: UpdateUserRoleRequest
): Promise<UpdateUserRoleResponse> {
const response = await axios.put(
API_ENDPOINTS.USER.UPDATE_ROLE(userId),
data
);
return extractData<UpdateUserRoleResponse>(response.data);
}
export default { getUsersInfo, updateUserInfo, updateUserRole };
export default { getUsersInfo };

View File

@ -168,7 +168,7 @@ export function DashboardTemplate({
variant={usageRange === "weekly" ? "default" : "outline"}
onClick={() => setUsageRange("weekly")}
>
7 ngày
7 ngay
</Button>
<Button
type="button"
@ -176,7 +176,7 @@ export function DashboardTemplate({
variant={usageRange === "monthly" ? "default" : "outline"}
onClick={() => setUsageRange("monthly")}
>
30 ngày
30 ngay
</Button>
</div>
</CardAction>

View File

@ -27,7 +27,7 @@ export const appSidebarSection = {
code: AppSidebarSectionCode.DASHBOARD,
icon: Home,
permissions: [PermissionEnum.ALLOW_ALL],
}
},
],
},
{
@ -40,13 +40,6 @@ export const appSidebarSection = {
icon: Building,
permissions: [PermissionEnum.VIEW_ROOM],
},
{
title: "Điều khiển trực tiếp",
url: "/remote-control",
code: AppSidebarSectionCode.REMOTE_LIVE_CONTROL,
icon: Monitor,
permissions: [PermissionEnum.VIEW_REMOTE_CONTROL],
}
],
},
{
@ -102,6 +95,18 @@ export const appSidebarSection = {
}
]
},
{
title: "Điều khiển từ xa",
items: [
{
title: "Điều khiển trực tiếp",
url: "/remote-control",
code: AppSidebarSectionCode.REMOTE_LIVE_CONTROL,
icon: Monitor,
permissions: [PermissionEnum.ALLOW_ALL],
}
]
},
{
title: "Audits",
items: [

View File

@ -101,11 +101,6 @@ export enum PermissionEnum {
AUDIT_OPERATION = 190,
VIEW_AUDIT_LOGS = 191,
//REMOTE CONTROL
REMOTE_CONTROL_OPERATION = 200,
VIEW_REMOTE_CONTROL = 201,
CONTROL_REMOTE = 202,
//Undefined
UNDEFINED = 9999,

View File

@ -1,41 +1,11 @@
export type UserProfile = {
userId?: number;
userName: string;
name: string;
role: string;
roleId: number;
accessRooms: string[];
accessRooms: number[];
createdAt?: string | null;
createdBy?: string | null;
updatedAt?: string | null;
updatedBy?: string | null;
};
export type UpdateUserInfoRequest = {
name: string;
userName: string;
accessRooms?: number[];
};
export type UpdateUserRoleRequest = {
roleId: number;
};
export type UpdateUserInfoResponse = {
userId: number;
userName: string;
name: string;
roleId: number;
accessRooms: number[];
updatedAt?: string | null;
updatedBy?: string | null;
};
export type UpdateUserRoleResponse = {
userId: number;
userName: string;
roleId: number;
roleName?: string | null;
updatedAt?: string | null;
updatedBy?: string | null;
};

View File

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