Compare commits
No commits in common. "main" and "httpsImplement" have entirely different histories.
main
...
httpsImple
|
|
@ -1,93 +0,0 @@
|
|||
# Frontend Guide - Microsoft SSO (Entra ID)
|
||||
|
||||
## 1) Muc tieu
|
||||
Tai lieu nay danh cho frontend de tich hop dang nhap Microsoft SSO voi backend TTMT.CompManageWeb.
|
||||
|
||||
## 2) Redirect URI da dang ky
|
||||
Redirect URI cho Microsoft app:
|
||||
|
||||
https://comp.soict.io/api/auth/sso/callback
|
||||
|
||||
Luu y:
|
||||
- Redirect URI trong Azure App Registration phai giong 100% (scheme, domain, path).
|
||||
- Sai ky tu hoac sai slash cuoi co the gay loi redirect_uri mismatch.
|
||||
|
||||
## 3) Endpoint frontend can goi
|
||||
Backend route SSO Microsoft (legacy route):
|
||||
|
||||
- GET /api/auth/sso/login?returnUrl={FRONTEND_RETURN_URL}
|
||||
- POST /api/auth/sso/exchange
|
||||
|
||||
Route alias cung ho tro:
|
||||
- GET /api/sso/login?returnUrl={FRONTEND_RETURN_URL}
|
||||
- POST /api/sso/exchange
|
||||
|
||||
## 4) Login flow cho frontend
|
||||
1. User bam nut "Login with Microsoft".
|
||||
2. Frontend redirect browser den:
|
||||
/api/auth/sso/login?returnUrl={FRONTEND_RETURN_URL}
|
||||
3. Backend redirect sang Microsoft login page.
|
||||
4. Sau khi user xac thuc thanh cong, Microsoft goi ve redirect URI:
|
||||
https://comp.soict.io/api/auth/sso/callback
|
||||
5. Backend tao one-time code va redirect user ve FRONTEND_RETURN_URL kem query:
|
||||
?code={ONE_TIME_CODE}
|
||||
6. Frontend doc code trong URL, goi API exchange de doi code lay JWT noi bo.
|
||||
7. Frontend luu token va thong tin user, dieu huong vao app.
|
||||
|
||||
## 5) API exchange chi tiet
|
||||
Method: POST
|
||||
Path: /api/auth/sso/exchange
|
||||
Content-Type: application/json
|
||||
|
||||
Request body:
|
||||
{
|
||||
"code": "<one-time-code>"
|
||||
}
|
||||
|
||||
Success response (HTTP 200):
|
||||
{
|
||||
"token": "<jwt>",
|
||||
"name": "Nguyen Van A",
|
||||
"username": "user@hust.edu.vn",
|
||||
"access": [1, 2, 3],
|
||||
"role": {
|
||||
"roleName": "Pending",
|
||||
"priority": 99
|
||||
}
|
||||
}
|
||||
|
||||
## 6) Error handling goi y cho frontend
|
||||
- 400 BadRequest: code thieu hoac token khong hop le.
|
||||
- 401 Unauthorized: code het han / da dung / khong hop le.
|
||||
- 404 NotFound: user khong ton tai sau khi exchange.
|
||||
|
||||
One-time code co han su dung ngan (khoang 2 phut) va chi dung 1 lan.
|
||||
Neu exchange that bai, can yeu cau user login lai.
|
||||
|
||||
## 7) Mau frontend pseudo-code
|
||||
const returnUrl = window.location.origin + "/sso/complete";
|
||||
window.location.href = `/api/auth/sso/login?returnUrl=${encodeURIComponent(returnUrl)}`;
|
||||
|
||||
// tai /sso/complete
|
||||
const code = new URLSearchParams(window.location.search).get("code");
|
||||
if (code) {
|
||||
const res = await fetch("/api/auth/sso/exchange", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ code })
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
// hien thi thong bao loi va cho user thu lai
|
||||
} else {
|
||||
const data = await res.json();
|
||||
// save data.token, data.username, data.role, data.access
|
||||
}
|
||||
}
|
||||
|
||||
## 8) Checklist truoc khi UAT
|
||||
- Redirect URI trong Azure dung: https://comp.soict.io/api/auth/sso/callback
|
||||
- Frontend returnUrl la URL FE hop le (vi du: https://comp.soict.io/sso/complete)
|
||||
- Frontend parse duoc query code
|
||||
- Frontend goi exchange ngay sau khi nhan code
|
||||
- Frontend xu ly day du HTTP 400/401/404
|
||||
|
|
@ -1,343 +0,0 @@
|
|||
# Google OAuth (OIDC) + AzureAD Shared SSO Service
|
||||
|
||||
Tai lieu nay tong hop toan bo flow dang nhap OAuth/OIDC theo muc coding cho codebase hien tai.
|
||||
|
||||
Pham vi:
|
||||
- Giu nguyen login username/password cu.
|
||||
- Dung chung 1 service cho AzureAD SSO va Google OAuth OIDC.
|
||||
- Van tao/cap nhat user noi bo trong DB.
|
||||
- User moi vao role Pending (khong co permission), doi admin cap quyen.
|
||||
|
||||
----------------------------------------
|
||||
|
||||
## 1) Trang thai implementation hien tai
|
||||
|
||||
Da hoan tat trong code:
|
||||
- Shared service theo provider key: SsoService.
|
||||
- Controller da provider: OAuthController.
|
||||
- Controller AzureAD cu (SsoController) da chay tren shared service.
|
||||
- Config da provider qua OAuthProviders trong appsettings.
|
||||
|
||||
File chinh:
|
||||
- TTMT.CompManageWeb/Program.cs
|
||||
- TTMT.CompManageWeb/Interfaces/ISsoService.cs
|
||||
- TTMT.CompManageWeb/Services/SsoSerivce.cs
|
||||
- TTMT.CompManageWeb/Controllers/OAuthController.cs
|
||||
- TTMT.CompManageWeb/Controllers/SsoController.cs
|
||||
- TTMT.CompManageWeb/Dtos/Auth/OAuthProviderOptions.cs
|
||||
- TTMT.CompManageWeb/Dtos/Auth/OAuthProvidersOptions.cs
|
||||
- TTMT.CompManageWeb/appsettings.json
|
||||
|
||||
----------------------------------------
|
||||
|
||||
## 2) Kien truc luong dang nhap
|
||||
|
||||
### 2.1 Luong OAuth Google
|
||||
|
||||
1. FE redirect user den:
|
||||
- GET /api/auth/oauth/google/login?returnUrl=<frontend-url>
|
||||
2. Backend build authorize URL va redirect qua Google.
|
||||
3. Google callback ve backend:
|
||||
- GET /api/auth/oauth/google/callback?code=...&state=...
|
||||
4. Backend:
|
||||
- Exchange code -> token endpoint.
|
||||
- Validate id_token.
|
||||
- Lay email/name.
|
||||
- Kiem tra domain.
|
||||
- Upsert user noi bo.
|
||||
- Issue JWT noi bo.
|
||||
- Tao one-time code (2 phut).
|
||||
- Redirect ve returnUrl?code=<one-time-code>.
|
||||
5. FE goi API exchange:
|
||||
- POST /api/auth/oauth/exchange
|
||||
- Body: { "code": "..." }
|
||||
6. Backend consume one-time code va tra payload login (token + role + permission).
|
||||
|
||||
### 2.2 Luong AzureAD cu
|
||||
|
||||
Van giu endpoint cu:
|
||||
- GET /api/auth/sso/login
|
||||
- GET /api/auth/sso/callback
|
||||
- POST /api/auth/sso/exchange
|
||||
|
||||
Nhung ben trong da dung chung service theo provider azuread.
|
||||
|
||||
----------------------------------------
|
||||
|
||||
## 3) Cau hinh bat buoc
|
||||
|
||||
### 3.1 appsettings.json
|
||||
|
||||
```json
|
||||
{
|
||||
"OAuthProviders": {
|
||||
"DefaultProvider": "azuread",
|
||||
"Providers": {
|
||||
"google": {
|
||||
"Authority": "https://accounts.google.com",
|
||||
"AuthorizationEndpoint": "https://accounts.google.com/o/oauth2/v2/auth",
|
||||
"TokenEndpoint": "https://oauth2.googleapis.com/token",
|
||||
"ClientId": "",
|
||||
"ClientSecret": "",
|
||||
"CallbackPath": "/api/auth/oauth/google/callback",
|
||||
"AllowedDomain": "",
|
||||
"PendingRoleName": "Pending",
|
||||
"Scopes": "openid profile email",
|
||||
"Issuer": "https://accounts.google.com",
|
||||
"EmailClaim": "email",
|
||||
"NameClaim": "name",
|
||||
"HostedDomainClaimName": "hd",
|
||||
"EnforceHostedDomainClaim": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Giai thich nhanh:
|
||||
- AllowedDomain:
|
||||
- Rong: cho phep moi domain.
|
||||
- Co gia tri (vd hust.edu.vn): chi cho email domain nay.
|
||||
- EnforceHostedDomainClaim=true:
|
||||
- Neu co claim hd trong token thi bat buoc phai khop AllowedDomain.
|
||||
- Neu token khong co hd, he thong fallback check theo email domain.
|
||||
- PendingRoleName: role duoc gan khi user moi dang nhap lan dau.
|
||||
|
||||
### 3.2 Google Cloud Console
|
||||
|
||||
1. Tao OAuth client (Web application).
|
||||
2. Authorized redirect URIs:
|
||||
- https://<your-domain>/api/auth/oauth/google/callback
|
||||
- (dev) https://localhost:<port>/api/auth/oauth/google/callback
|
||||
3. Lay ClientId, ClientSecret va dien vao appsettings (hoac secrets).
|
||||
|
||||
----------------------------------------
|
||||
|
||||
## 4) Dang ky DI va options
|
||||
|
||||
Trong Program.cs can co:
|
||||
|
||||
```csharp
|
||||
builder.Services.Configure<AzureAdOptions>(
|
||||
builder.Configuration.GetSection(AzureAdOptions.SectionName));
|
||||
|
||||
builder.Services.Configure<OAuthProvidersOptions>(
|
||||
builder.Configuration.GetSection(OAuthProvidersOptions.SectionName));
|
||||
|
||||
builder.Services.AddScoped<ISsoService, SsoService>();
|
||||
```
|
||||
|
||||
OAuthProvidersOptions dung dictionary provider theo key (vd google, azuread, okta).
|
||||
|
||||
----------------------------------------
|
||||
|
||||
## 5) Thiet ke interface va service chung
|
||||
|
||||
### 5.1 Interface ISsoService
|
||||
|
||||
Interface da duoc mo rong thanh provider-aware:
|
||||
- BuildAuthorizeUrl(provider, redirectUri, state)
|
||||
- ExchangeCodeAsync(provider, code, redirectUri, ct)
|
||||
- ValidateIdTokenAsync(provider, idToken, ct)
|
||||
- IsAllowedDomain(provider, email, principal)
|
||||
- UpsertUserAsync(provider, email, name, ct)
|
||||
|
||||
Va van giu overload cu de backward compatibility.
|
||||
|
||||
### 5.2 SsoService - logic tong
|
||||
|
||||
Core y tuong:
|
||||
- Resolve config theo provider key.
|
||||
- Provider nao khong co endpoint explicit thi suy ra tu metadata/authority.
|
||||
- AzureAD duoc fallback tu AzureAdOptions de khong vo luong cu.
|
||||
|
||||
Phan quan trong:
|
||||
|
||||
1) ResolveProvider(...)
|
||||
- Doc provider trong OAuthProviders.
|
||||
- Neu key = azuread ma khong co trong OAuthProviders, fallback AzureAdOptions.
|
||||
|
||||
2) BuildAuthorizeUrl(...)
|
||||
- Build URL authorize theo endpoint cua provider.
|
||||
- Them scope, state, redirect_uri.
|
||||
- Neu bat hosted-domain check thi them hd=<AllowedDomain>.
|
||||
|
||||
3) ExchangeCodeAsync(...)
|
||||
- Goi token endpoint voi client_id, client_secret, code, redirect_uri.
|
||||
- Parse OidcTokenResponse.
|
||||
- Bat buoc co id_token (flow hien tai dang OIDC-centric).
|
||||
|
||||
4) ValidateIdTokenAsync(...)
|
||||
- Lay metadata OIDC (.well-known/openid-configuration).
|
||||
- Validate issuer, audience, signing key, lifetime.
|
||||
|
||||
5) IsAllowedDomain(...)
|
||||
- Neu AllowedDomain rong -> cho phep.
|
||||
- Neu khong rong -> check duoi email.
|
||||
- Neu EnforceHostedDomainClaim bat va token co claim hd -> bat buoc hd trung AllowedDomain.
|
||||
|
||||
6) UpsertUserAsync(...)
|
||||
- Tim user theo email (UserName).
|
||||
- Neu ton tai: cap nhat ten + metadata update.
|
||||
- Neu chua ton tai: tao user moi, Password = null, gan role PendingRoleName.
|
||||
|
||||
7) One-time code
|
||||
- CreateOneTimeCodeAsync(...): tao code, luu bang SsoOneTimeCodes, het han sau 2 phut.
|
||||
- ExchangeOneTimeCodeForLoginAsync(...): consume code, tra payload login final.
|
||||
|
||||
----------------------------------------
|
||||
|
||||
## 6) Controller va contract API
|
||||
|
||||
### 6.1 OAuthController (da provider)
|
||||
|
||||
Route base: api/auth/oauth
|
||||
|
||||
1) Login
|
||||
- GET /api/auth/oauth/{provider}/login?returnUrl=...
|
||||
- Redirect sang provider authorize URL.
|
||||
|
||||
2) Callback
|
||||
- GET /api/auth/oauth/{provider}/callback?code=...&state=...
|
||||
- Xu ly token + domain + upsert + issue token + one-time code.
|
||||
- Redirect ve FE voi query code.
|
||||
|
||||
3) Exchange
|
||||
- POST /api/auth/oauth/exchange
|
||||
|
||||
Request:
|
||||
```json
|
||||
{
|
||||
"code": "one-time-code"
|
||||
}
|
||||
```
|
||||
|
||||
Response thanh cong:
|
||||
```json
|
||||
{
|
||||
"token": "<jwt-noi-bo>",
|
||||
"name": "...",
|
||||
"username": "email@domain",
|
||||
"access": [1, 2, 3],
|
||||
"role": {
|
||||
"roleName": "Pending",
|
||||
"priority": 99
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 SsoController (AzureAD legacy)
|
||||
|
||||
Van giu route cu, nhung da goi service chung voi provider azuread.
|
||||
|
||||
----------------------------------------
|
||||
|
||||
## 7) Rule user va role Pending
|
||||
|
||||
Rule bat buoc:
|
||||
- User OAuth lan dau phai duoc tao trong bang UserAccounts.
|
||||
- User moi phai vao role Pending.
|
||||
- Role Pending khong co permission nao (PermissionRoles.IsChecked = 0 hoac khong co row).
|
||||
- Admin se cap role/permission sau.
|
||||
|
||||
Model lien quan:
|
||||
- UserAccounts
|
||||
- Roles
|
||||
- PermissionRoles
|
||||
- SsoOneTimeCodes
|
||||
|
||||
Kiem tra nhanh trong DB:
|
||||
|
||||
```sql
|
||||
-- 1) Kiem tra role Pending co ton tai
|
||||
SELECT Id, RoleName, Priority
|
||||
FROM "Roles"
|
||||
WHERE "RoleName" = 'Pending';
|
||||
|
||||
-- 2) Kiem tra Pending role khong co permission active
|
||||
SELECT pr.*
|
||||
FROM "PermissionRoles" pr
|
||||
JOIN "Roles" r ON r."Id" = pr."RoleId"
|
||||
WHERE r."RoleName" = 'Pending'
|
||||
AND pr."IsChecked" = 1;
|
||||
|
||||
-- 3) Kiem tra user tao boi OAuth
|
||||
SELECT "Id", "UserName", "Password", "RoleId", "CreatedBy", "UpdatedBy"
|
||||
FROM "UserAccounts"
|
||||
WHERE "UserName" = '<email-user>';
|
||||
```
|
||||
|
||||
Ky vong:
|
||||
- Query #2 tra ve 0 rows.
|
||||
- User moi co Password = null, CreatedBy = 'SSO'.
|
||||
|
||||
----------------------------------------
|
||||
|
||||
## 8) Frontend integration (chi tiet)
|
||||
|
||||
1. Nguoi dung bam nut Google Login:
|
||||
- Window.location -> GET /api/auth/oauth/google/login?returnUrl=<FE_CALLBACK_URL>
|
||||
2. Sau callback backend, FE nhan code tu query string.
|
||||
3. FE goi:
|
||||
|
||||
```http
|
||||
POST /api/auth/oauth/exchange
|
||||
Content-Type: application/json
|
||||
|
||||
{ "code": "<code-tu-query>" }
|
||||
```
|
||||
|
||||
4. FE luu token va xu ly permission/role giong login cu.
|
||||
|
||||
----------------------------------------
|
||||
|
||||
## 9) Luu y bao mat va hardening
|
||||
|
||||
Nen lam tiep:
|
||||
- Validate returnUrl theo allowlist de tranh open redirect.
|
||||
- Luu va verify state/nonce server-side (cache/redis).
|
||||
- Dung IHttpClientFactory thay new HttpClient() de kiem soat timeout/retry.
|
||||
- Dua ClientSecret sang secret manager/env var.
|
||||
- Bat HTTPS va secure cookie policy day du.
|
||||
|
||||
----------------------------------------
|
||||
|
||||
## 10) Troubleshooting
|
||||
|
||||
1) Loi IdToken not found in token response
|
||||
- Provider dang khong tra OIDC token.
|
||||
- Kiem tra scope co openid chua.
|
||||
|
||||
2) Loi Email not found in token
|
||||
- Kiem tra claim trong Google token (email, preferred_username).
|
||||
|
||||
3) Loi Email domain is not allowed
|
||||
- Check AllowedDomain va claim hd.
|
||||
|
||||
4) Loi Role 'Pending' not found
|
||||
- Tao role Pending trong bang Roles.
|
||||
|
||||
5) Loi exchange code het han
|
||||
- One-time code chi song 2 phut va chi dung 1 lan.
|
||||
|
||||
----------------------------------------
|
||||
|
||||
## 11) Checklist test E2E truoc khi merge
|
||||
|
||||
- Login Google thanh cong voi account hop le.
|
||||
- User moi duoc tao trong DB va role = Pending.
|
||||
- Pending role khong co permission active.
|
||||
- User cu login lai thi khong tao duplicate user.
|
||||
- One-time code chi dung 1 lan.
|
||||
- AllowedDomain rong cho phep tat ca domain.
|
||||
- AllowedDomain co gia tri thi chan dung domain.
|
||||
|
||||
----------------------------------------
|
||||
|
||||
## 12) Ghi chu pham vi hien tai
|
||||
|
||||
Hien tai flow moi dang OIDC-centric (bat buoc id_token).
|
||||
Neu can support OAuth thuan (khong co id_token), can bo sung nhanh:
|
||||
- UserInfo endpoint call.
|
||||
- Mapping email/name tu userinfo response.
|
||||
- Branch logic trong callback de fallback userinfo.
|
||||
74
Users-API.md
74
Users-API.md
|
|
@ -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.
|
||||
|
||||
----------------------------------------
|
||||
183
nginx/nginx.conf
183
nginx/nginx.conf
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ export function RoomManagementCard({
|
|||
<Card className="bg-white/80 backdrop-blur border-muted/60 shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle>Quản lý phòng</CardTitle>
|
||||
<CardDescription>Thông tin tổng quan và các phòng đang không sử dụng</CardDescription>
|
||||
<CardDescription>Thông tin tổng quan và 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) => (
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { Input } from "@/components/ui/input";
|
|||
import { Label } from "@/components/ui/label";
|
||||
import type { LoginResquest } from "@/types/auth";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { buildGoogleOAuthLoginUrl, buildMicrosoftSsoLoginUrl, login } from "@/services/auth.service";
|
||||
import { buildSsoLoginUrl, login } from "@/services/auth.service";
|
||||
import { useState } from "react";
|
||||
import { useNavigate, useRouter } from "@tanstack/react-router";
|
||||
import { Route } from "@/routes/(auth)/login";
|
||||
|
|
@ -44,22 +44,12 @@ export function LoginForm({ className }: React.ComponentProps<"form">) {
|
|||
}
|
||||
});
|
||||
|
||||
const handleGoogleLogin = () => {
|
||||
const returnUrl = new URL("/oauth/callback", window.location.origin);
|
||||
returnUrl.searchParams.set("provider", "google");
|
||||
const handleSsoLogin = () => {
|
||||
const returnUrl = new URL("/sso/callback", window.location.origin);
|
||||
if (search.redirect) {
|
||||
returnUrl.searchParams.set("redirect", search.redirect);
|
||||
}
|
||||
window.location.assign(buildGoogleOAuthLoginUrl(returnUrl.toString()));
|
||||
};
|
||||
|
||||
const handleMicrosoftLogin = () => {
|
||||
const returnUrl = new URL("/oauth/callback", window.location.origin);
|
||||
returnUrl.searchParams.set("provider", "azuread");
|
||||
if (search.redirect) {
|
||||
returnUrl.searchParams.set("redirect", search.redirect);
|
||||
}
|
||||
window.location.assign(buildMicrosoftSsoLoginUrl(returnUrl.toString()));
|
||||
window.location.assign(buildSsoLoginUrl(returnUrl.toString()));
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
|
|
@ -122,33 +112,12 @@ export function LoginForm({ className }: React.ComponentProps<"form">) {
|
|||
</Button>
|
||||
)}
|
||||
<div className="text-center text-sm text-muted-foreground">Hoặc</div>
|
||||
<Button type="button" variant="outline" className="w-full gap-2" onClick={handleGoogleLogin}>
|
||||
<Button type="button" variant="outline" className="w-full gap-2" onClick={handleSsoLogin}>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" className="h-4 w-4">
|
||||
<path
|
||||
fill="#4285F4"
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.02 5.02 0 0 1-2.18 3.29v2.74h3.52c2.05-1.89 3.3-4.67 3.3-8.04Z"
|
||||
/>
|
||||
<path
|
||||
fill="#34A853"
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.52-2.74c-.98.66-2.23 1.06-3.76 1.06-2.89 0-5.33-1.95-6.2-4.56H2.18v2.84A11 11 0 0 0 12 23Z"
|
||||
/>
|
||||
<path
|
||||
fill="#FBBC05"
|
||||
d="M5.8 14.1A6.62 6.62 0 0 1 5.45 12c0-.73.13-1.44.35-2.1V7.06H2.18A11 11 0 0 0 1 12c0 1.77.42 3.44 1.18 4.94L5.8 14.1Z"
|
||||
/>
|
||||
<path
|
||||
fill="#EA4335"
|
||||
d="M12 5.34c1.61 0 3.05.56 4.18 1.64l3.14-3.14C17.45 2.09 14.97 1 12 1a11 11 0 0 0-9.82 6.06L5.8 9.9C6.67 7.29 9.11 5.34 12 5.34Z"
|
||||
/>
|
||||
</svg>
|
||||
Đăng nhập với Google
|
||||
</Button>
|
||||
<Button type="button" variant="outline" className="w-full gap-2" onClick={handleMicrosoftLogin}>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" className="h-4 w-4">
|
||||
<rect x="2" y="2" width="9" height="9" fill="#F35325" />
|
||||
<rect x="13" y="2" width="9" height="9" fill="#81BC06" />
|
||||
<rect x="2" y="13" width="9" height="9" fill="#05A6F0" />
|
||||
<rect x="13" y="13" width="9" height="9" fill="#FFBA08" />
|
||||
<rect x="1" y="1" width="10" height="10" fill="#F25022" />
|
||||
<rect x="13" y="1" width="10" height="10" fill="#7FBA00" />
|
||||
<rect x="1" y="13" width="10" height="10" fill="#00A4EF" />
|
||||
<rect x="13" y="13" width="10" height="10" fill="#FFB900" />
|
||||
</svg>
|
||||
Đăng nhập với Microsoft
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ export function UploadVersionForm({ onSubmit, closeDialog }: UploadVersionFormPr
|
|||
const [isDone, setIsDone] = useState(false);
|
||||
|
||||
// Match server allowed extensions
|
||||
const ALLOWED_EXTENSIONS = [".exe", ".apk", ".conf", ".json", ".xml", ".setting", ".lnk", ".url", ".seb", ".ps1", ".zip"];
|
||||
const ALLOWED_EXTENSIONS = [".exe", ".apk", ".conf", ".json", ".xml", ".setting", ".lnk", ".url", ".seb", ".ps1"];
|
||||
const isFileValid = (file: File) => {
|
||||
const fileName = file.name.toLowerCase();
|
||||
return ALLOWED_EXTENSIONS.some((ext) => fileName.endsWith(ext));
|
||||
|
|
|
|||
|
|
@ -1,46 +1,16 @@
|
|||
const isDev = import.meta.env.MODE === "development";
|
||||
|
||||
const trimTrailingSlash = (value: string) => value.replace(/\/+$/, "");
|
||||
|
||||
export const BASE_URL = isDev
|
||||
? import.meta.env.VITE_API_URL_DEV
|
||||
: "/api";
|
||||
|
||||
export const BASE_MESH_URL = isDev
|
||||
? (import.meta.env.VITE_API_MESH || import.meta.env.VITE_API_MESH_DEV || "")
|
||||
: (import.meta.env.VITE_API_MESH || "");
|
||||
|
||||
export const buildMeshProxyUrl = (meshPathAndQuery: string) => {
|
||||
const cleanPath = meshPathAndQuery.startsWith("/")
|
||||
? meshPathAndQuery.substring(1)
|
||||
: meshPathAndQuery;
|
||||
const proxyPath = `/api/meshcentral/proxy/${cleanPath}`;
|
||||
|
||||
// If an explicit mesh host is configured, always use it.
|
||||
// This allows forcing proxy URLs to https://<IP>:<port>/api/meshcentral/proxy/...
|
||||
if (BASE_MESH_URL && BASE_MESH_URL.startsWith("http")) {
|
||||
return `${trimTrailingSlash(BASE_MESH_URL)}${proxyPath}`;
|
||||
}
|
||||
|
||||
// In development, BASE_URL is usually absolute (e.g. http://localhost:5218/api).
|
||||
// Build an absolute proxy URL to backend so iframe requests do not hit Vite dev server.
|
||||
if (BASE_URL.startsWith("http")) {
|
||||
const apiBase = trimTrailingSlash(BASE_URL);
|
||||
const backendOrigin = apiBase.endsWith("/api")
|
||||
? apiBase.slice(0, -4)
|
||||
: apiBase;
|
||||
return `${backendOrigin}${proxyPath}`;
|
||||
}
|
||||
|
||||
return proxyPath;
|
||||
};
|
||||
? import.meta.env.VITE_API_MESH_DEV
|
||||
: "/meshapi";
|
||||
|
||||
export const API_ENDPOINTS = {
|
||||
AUTH: {
|
||||
LOGIN: `${BASE_URL}/login`,
|
||||
OAUTH_LOGIN: (provider: string) =>
|
||||
`${BASE_URL}/auth/oauth/${encodeURIComponent(provider)}/login`,
|
||||
OAUTH_EXCHANGE: `${BASE_URL}/auth/oauth/exchange`,
|
||||
SSO_LOGIN: `${BASE_URL}/auth/sso/login`,
|
||||
SSO_EXCHANGE: `${BASE_URL}/auth/sso/exchange`,
|
||||
LOGOUT: `${BASE_URL}/logout`,
|
||||
|
|
@ -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}`,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -1,63 +1,36 @@
|
|||
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useExchangeSsoCode } from "@/hooks/queries";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LoaderCircle } from "lucide-react";
|
||||
import axios from "axios";
|
||||
import { exchangeCodeByProvider } from "@/services/auth.service";
|
||||
import type { LoginResponse } from "@/types/auth";
|
||||
|
||||
const inFlightExchanges = new Map<string, Promise<LoginResponse>>();
|
||||
const consumedCodes = new Set<string>();
|
||||
|
||||
export const Route = createFileRoute("/(auth)/oauth/callback/")({
|
||||
component: OAuthCallbackPage,
|
||||
export const Route = createFileRoute("/(auth)/sso/callback/")({
|
||||
component: SsoCallbackPage,
|
||||
});
|
||||
|
||||
function OAuthCallbackPage() {
|
||||
function SsoCallbackPage() {
|
||||
const auth = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const search = Route.useSearch() as { code?: string; redirect?: string; provider?: string };
|
||||
const exchangeMutation = useExchangeSsoCode();
|
||||
const search = Route.useSearch() as { code?: string; redirect?: string };
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [isExchanging, setIsExchanging] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const code = search.code;
|
||||
const provider = (search.provider || "").toLowerCase();
|
||||
if (!code) {
|
||||
setErrorMessage("OAuth code is missing.");
|
||||
return;
|
||||
}
|
||||
|
||||
const exchangeId = `${provider || "auto"}:${code}`;
|
||||
|
||||
if (consumedCodes.has(exchangeId)) {
|
||||
setErrorMessage("Mã đăng nhập đã được sử dụng. Vui lòng đăng nhập lại.");
|
||||
if (!search.code) {
|
||||
setErrorMessage("SSO code is missing.");
|
||||
return;
|
||||
}
|
||||
|
||||
setErrorMessage(null);
|
||||
setIsExchanging(true);
|
||||
|
||||
let cancelled = false;
|
||||
let exchangePromise = inFlightExchanges.get(exchangeId);
|
||||
if (!exchangePromise) {
|
||||
exchangePromise = exchangeCodeByProvider(code, provider);
|
||||
inFlightExchanges.set(exchangeId, exchangePromise);
|
||||
}
|
||||
|
||||
exchangePromise
|
||||
.then(async (data) => {
|
||||
if (cancelled) return;
|
||||
|
||||
exchangeMutation.mutate(search.code, {
|
||||
onSuccess: async (data) => {
|
||||
if (!data.token) {
|
||||
setErrorMessage("OAuth response missing token.");
|
||||
setErrorMessage("SSO response missing token.");
|
||||
return;
|
||||
}
|
||||
|
||||
consumedCodes.add(exchangeId);
|
||||
|
||||
localStorage.setItem("token", data.token);
|
||||
localStorage.setItem("username", data.username || "");
|
||||
localStorage.setItem("name", data.name || "");
|
||||
|
|
@ -72,38 +45,22 @@ function OAuthCallbackPage() {
|
|||
auth.login(data.username || "");
|
||||
|
||||
await navigate({ to: search.redirect || "/dashboard" });
|
||||
})
|
||||
.catch((error) => {
|
||||
if (cancelled) return;
|
||||
|
||||
if (axios.isAxiosError(error) && error.response?.status === 401) {
|
||||
consumedCodes.add(exchangeId);
|
||||
setErrorMessage("Mã đăng nhập đã hết hạn hoặc đã được sử dụng. Vui lòng đăng nhập lại.");
|
||||
return;
|
||||
}
|
||||
setErrorMessage("OAuth exchange failed.");
|
||||
})
|
||||
.finally(() => {
|
||||
inFlightExchanges.delete(exchangeId);
|
||||
if (!cancelled) {
|
||||
setIsExchanging(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [auth, navigate, search.code, search.provider, search.redirect]);
|
||||
},
|
||||
onError: () => {
|
||||
setErrorMessage("SSO exchange failed.");
|
||||
},
|
||||
});
|
||||
}, [auth, exchangeMutation, navigate, search.code, search.redirect]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-gradient-to-br from-background to-muted/20">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-xl">Đang xác thực OAuth</CardTitle>
|
||||
<CardTitle className="text-xl">Đang xác thực SSO</CardTitle>
|
||||
<CardDescription>Vui lòng đợi trong giây lát.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col items-center gap-4">
|
||||
{isExchanging && (
|
||||
{exchangeMutation.isPending && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<LoaderCircle className="w-4 h-4 animate-spin" />
|
||||
Đang trao đổi mã đăng nhập
|
||||
|
|
@ -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({});
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { getRemoteDesktopUrl } from "@/services/remote-control.service";
|
||||
import { buildMeshProxyUrl } from "@/config/api";
|
||||
import { BASE_URL } from "@/config/api";
|
||||
|
||||
|
||||
export const Route = createFileRoute("/_auth/remote-control/")({
|
||||
|
|
@ -38,7 +38,9 @@ function RemoteControlPage() {
|
|||
// Chuyển URL MeshCentral thành proxy URL
|
||||
const originalUrl = new URL(data.url);
|
||||
const pathAndQuery = originalUrl.pathname + originalUrl.search;
|
||||
const proxyUrlFull = buildMeshProxyUrl(pathAndQuery);
|
||||
const cleanPath = pathAndQuery.startsWith('/') ? pathAndQuery.substring(1) : pathAndQuery;
|
||||
const baseWithoutApi = BASE_URL.replace('/api', '');
|
||||
const proxyUrlFull = `${baseWithoutApi}/api/meshcentral/proxy/${cleanPath}`;
|
||||
|
||||
console.log("[RemoteControl] Proxy URL:", proxyUrlFull);
|
||||
setProxyUrl(proxyUrlFull);
|
||||
|
|
|
|||
|
|
@ -36,25 +36,6 @@ function RouteComponent() {
|
|||
roomName as string,
|
||||
);
|
||||
|
||||
const sortedFolderStatusList = useMemo(() => {
|
||||
return [...(folderStatusList ?? [])].sort((a, b) => {
|
||||
const aRoom = (a as ClientFolderStatus & { roomName?: string }).roomName;
|
||||
const bRoom = (b as ClientFolderStatus & { roomName?: string }).roomName;
|
||||
|
||||
if (aRoom || bRoom) {
|
||||
return (aRoom ?? "").localeCompare(bRoom ?? "", "vi", {
|
||||
numeric: true,
|
||||
sensitivity: "base",
|
||||
});
|
||||
}
|
||||
|
||||
return (a.deviceId ?? "").localeCompare(b.deviceId ?? "", "vi", {
|
||||
numeric: true,
|
||||
sensitivity: "base",
|
||||
});
|
||||
});
|
||||
}, [folderStatusList]);
|
||||
|
||||
const columnHelper = createColumnHelper<ClientFolderStatus>();
|
||||
|
||||
const columns = useMemo(
|
||||
|
|
@ -99,7 +80,7 @@ function RouteComponent() {
|
|||
);
|
||||
|
||||
const table = useReactTable({
|
||||
data: sortedFolderStatusList,
|
||||
data: folderStatusList ?? [],
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
|
|
@ -107,7 +88,7 @@ function RouteComponent() {
|
|||
return (
|
||||
<FolderStatusTemplate
|
||||
roomName={roomName as string}
|
||||
data={sortedFolderStatusList}
|
||||
data={folderStatusList}
|
||||
isLoading={isLoading}
|
||||
onBack={() =>
|
||||
navigate({ to: "/rooms/$roomName/", params: { roomName } } as any)
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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]"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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 và 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ọ và 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 }) => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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 và 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 };
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -4,7 +4,6 @@
|
|||
"outDir": "./dist",
|
||||
"composite": true,
|
||||
"noEmit": false,
|
||||
"ignoreDeprecations": "6.0",
|
||||
"types": []
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user