Compare commits

..

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

43 changed files with 452 additions and 2915 deletions

View File

@ -1,177 +0,0 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS

View File

@ -1,42 +0,0 @@
---
name: frontend-design
description: Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, artifacts, posters, or applications (examples include websites, landing pages, dashboards, React components, HTML/CSS layouts, or when styling/beautifying any web UI). Generates creative, polished code and UI design that avoids generic AI aesthetics.
license: Complete terms in LICENSE.txt
---
This skill guides creation of distinctive, production-grade frontend interfaces that avoid generic "AI slop" aesthetics. Implement real working code with exceptional attention to aesthetic details and creative choices.
The user provides frontend requirements: a component, page, application, or interface to build. They may include context about the purpose, audience, or technical constraints.
## Design Thinking
Before coding, understand the context and commit to a BOLD aesthetic direction:
- **Purpose**: What problem does this interface solve? Who uses it?
- **Tone**: Pick an extreme: brutally minimal, maximalist chaos, retro-futuristic, organic/natural, luxury/refined, playful/toy-like, editorial/magazine, brutalist/raw, art deco/geometric, soft/pastel, industrial/utilitarian, etc. There are so many flavors to choose from. Use these for inspiration but design one that is true to the aesthetic direction.
- **Constraints**: Technical requirements (framework, performance, accessibility).
- **Differentiation**: What makes this UNFORGETTABLE? What's the one thing someone will remember?
**CRITICAL**: Choose a clear conceptual direction and execute it with precision. Bold maximalism and refined minimalism both work - the key is intentionality, not intensity.
Then implement working code (HTML/CSS/JS, React, Vue, etc.) that is:
- Production-grade and functional
- Visually striking and memorable
- Cohesive with a clear aesthetic point-of-view
- Meticulously refined in every detail
## Frontend Aesthetics Guidelines
Focus on:
- **Typography**: Choose fonts that are beautiful, unique, and interesting. Avoid generic fonts like Arial and Inter; opt instead for distinctive choices that elevate the frontend's aesthetics; unexpected, characterful font choices. Pair a distinctive display font with a refined body font.
- **Color & Theme**: Commit to a cohesive aesthetic. Use CSS variables for consistency. Dominant colors with sharp accents outperform timid, evenly-distributed palettes.
- **Motion**: Use animations for effects and micro-interactions. Prioritize CSS-only solutions for HTML. Use Motion library for React when available. Focus on high-impact moments: one well-orchestrated page load with staggered reveals (animation-delay) creates more delight than scattered micro-interactions. Use scroll-triggering and hover states that surprise.
- **Spatial Composition**: Unexpected layouts. Asymmetry. Overlap. Diagonal flow. Grid-breaking elements. Generous negative space OR controlled density.
- **Backgrounds & Visual Details**: Create atmosphere and depth rather than defaulting to solid colors. Add contextual effects and textures that match the overall aesthetic. Apply creative forms like gradient meshes, noise textures, geometric patterns, layered transparencies, dramatic shadows, decorative borders, custom cursors, and grain overlays.
NEVER use generic AI-generated aesthetics like overused font families (Inter, Roboto, Arial, system fonts), cliched color schemes (particularly purple gradients on white backgrounds), predictable layouts and component patterns, and cookie-cutter design that lacks context-specific character.
Interpret creatively and make unexpected choices that feel genuinely designed for the context. No design should be the same. Vary between light and dark themes, different fonts, different aesthetics. NEVER converge on common choices (Space Grotesk, for example) across generations.
**IMPORTANT**: Match implementation complexity to the aesthetic vision. Maximalist designs need elaborate code with extensive animations and effects. Minimalist or refined designs need restraint, precision, and careful attention to spacing, typography, and subtle details. Elegance comes from executing the vision well.
Remember: Claude is capable of extraordinary creative work. Don't hold back, show what can truly be created when thinking outside the box and committing fully to a distinctive vision.

View File

@ -1,28 +0,0 @@
name: CI
on:
push:
pull_request:
jobs:
build-test:
runs-on: docker
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22.14.0
cache: npm
- name: Install deps
run: npm ci
- name: Test
run: npm run test -- --passWithNoTests
- name: Build
run: npm run build

View File

@ -1,45 +0,0 @@
name: Deploy Staging (Docker)
on:
push:
branches: [main]
jobs:
deploy:
runs-on: docker
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Generate build env
env:
VITE_API_MESH: ${{ secrets.VITE_API_MESH }}
VITE_ENABLE_MESH_DIRECT_AFTER_PROXY: ${{ secrets.VITE_ENABLE_MESH_DIRECT_AFTER_PROXY }}
run: |
cat > .env.production <<EOF
VITE_API_MESH=$VITE_API_MESH
VITE_ENABLE_MESH_DIRECT_AFTER_PROXY=$VITE_ENABLE_MESH_DIRECT_AFTER_PROXY
EOF
- name: Build Image
run: |
IMAGE="${{ secrets.IMAGE_NAME }}"
if [ -z "$IMAGE" ]; then IMAGE="ttmt-frontend"; fi
IMAGE=$(echo "$IMAGE" | tr '[:upper:]' '[:lower:]')
docker build --build-arg NODE_VERSION=22.14.0 -t $IMAGE:staging-${{ github.sha }} .
- name: Deploy local (docker compose)
run: |
IMAGE="${{ secrets.IMAGE_NAME }}"
if [ -z "$IMAGE" ]; then IMAGE="ttmt-frontend"; fi
IMAGE=$(echo "$IMAGE" | tr '[:upper:]' '[:lower:]')
COMPOSE_FILE=/home/compmanage/docker-compose.yml
OVERRIDE_FILE=/tmp/ttmt-frontend.override.yml
cat > $OVERRIDE_FILE <<EOF
services:
frontend:
image: $IMAGE:staging-${{ github.sha }}
EOF
docker rm -f ttmt-frontend || true
docker compose -f $COMPOSE_FILE -f $OVERRIDE_FILE up -d --no-deps --force-recreate frontend
rm -f $OVERRIDE_FILE

1
.gitignore vendored
View File

@ -8,4 +8,3 @@ count.txt
.nitro
.tanstack
.vscode/
plans/

View File

@ -1,93 +0,0 @@
# Frontend Guide - Microsoft SSO (Entra ID)
## 1) Muc tieu
Tai lieu nay danh cho frontend de tich hop dang nhap Microsoft SSO voi backend TTMT.CompManageWeb.
## 2) Redirect URI da dang ky
Redirect URI cho Microsoft app:
https://comp.soict.io/api/auth/sso/callback
Luu y:
- Redirect URI trong Azure App Registration phai giong 100% (scheme, domain, path).
- Sai ky tu hoac sai slash cuoi co the gay loi redirect_uri mismatch.
## 3) Endpoint frontend can goi
Backend route SSO Microsoft (legacy route):
- GET /api/auth/sso/login?returnUrl={FRONTEND_RETURN_URL}
- POST /api/auth/sso/exchange
Route alias cung ho tro:
- GET /api/sso/login?returnUrl={FRONTEND_RETURN_URL}
- POST /api/sso/exchange
## 4) Login flow cho frontend
1. User bam nut "Login with Microsoft".
2. Frontend redirect browser den:
/api/auth/sso/login?returnUrl={FRONTEND_RETURN_URL}
3. Backend redirect sang Microsoft login page.
4. Sau khi user xac thuc thanh cong, Microsoft goi ve redirect URI:
https://comp.soict.io/api/auth/sso/callback
5. Backend tao one-time code va redirect user ve FRONTEND_RETURN_URL kem query:
?code={ONE_TIME_CODE}
6. Frontend doc code trong URL, goi API exchange de doi code lay JWT noi bo.
7. Frontend luu token va thong tin user, dieu huong vao app.
## 5) API exchange chi tiet
Method: POST
Path: /api/auth/sso/exchange
Content-Type: application/json
Request body:
{
"code": "<one-time-code>"
}
Success response (HTTP 200):
{
"token": "<jwt>",
"name": "Nguyen Van A",
"username": "user@hust.edu.vn",
"access": [1, 2, 3],
"role": {
"roleName": "Pending",
"priority": 99
}
}
## 6) Error handling goi y cho frontend
- 400 BadRequest: code thieu hoac token khong hop le.
- 401 Unauthorized: code het han / da dung / khong hop le.
- 404 NotFound: user khong ton tai sau khi exchange.
One-time code co han su dung ngan (khoang 2 phut) va chi dung 1 lan.
Neu exchange that bai, can yeu cau user login lai.
## 7) Mau frontend pseudo-code
const returnUrl = window.location.origin + "/sso/complete";
window.location.href = `/api/auth/sso/login?returnUrl=${encodeURIComponent(returnUrl)}`;
// tai /sso/complete
const code = new URLSearchParams(window.location.search).get("code");
if (code) {
const res = await fetch("/api/auth/sso/exchange", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code })
});
if (!res.ok) {
// hien thi thong bao loi va cho user thu lai
} else {
const data = await res.json();
// save data.token, data.username, data.role, data.access
}
}
## 8) Checklist truoc khi UAT
- Redirect URI trong Azure dung: https://comp.soict.io/api/auth/sso/callback
- Frontend returnUrl la URL FE hop le (vi du: https://comp.soict.io/sso/complete)
- Frontend parse duoc query code
- Frontend goi exchange ngay sau khi nhan code
- Frontend xu ly day du HTTP 400/401/404

View File

@ -1,343 +0,0 @@
# Google OAuth (OIDC) + AzureAD Shared SSO Service
Tai lieu nay tong hop toan bo flow dang nhap OAuth/OIDC theo muc coding cho codebase hien tai.
Pham vi:
- Giu nguyen login username/password cu.
- Dung chung 1 service cho AzureAD SSO va Google OAuth OIDC.
- Van tao/cap nhat user noi bo trong DB.
- User moi vao role Pending (khong co permission), doi admin cap quyen.
----------------------------------------
## 1) Trang thai implementation hien tai
Da hoan tat trong code:
- Shared service theo provider key: SsoService.
- Controller da provider: OAuthController.
- Controller AzureAD cu (SsoController) da chay tren shared service.
- Config da provider qua OAuthProviders trong appsettings.
File chinh:
- TTMT.CompManageWeb/Program.cs
- TTMT.CompManageWeb/Interfaces/ISsoService.cs
- TTMT.CompManageWeb/Services/SsoSerivce.cs
- TTMT.CompManageWeb/Controllers/OAuthController.cs
- TTMT.CompManageWeb/Controllers/SsoController.cs
- TTMT.CompManageWeb/Dtos/Auth/OAuthProviderOptions.cs
- TTMT.CompManageWeb/Dtos/Auth/OAuthProvidersOptions.cs
- TTMT.CompManageWeb/appsettings.json
----------------------------------------
## 2) Kien truc luong dang nhap
### 2.1 Luong OAuth Google
1. FE redirect user den:
- GET /api/auth/oauth/google/login?returnUrl=<frontend-url>
2. Backend build authorize URL va redirect qua Google.
3. Google callback ve backend:
- GET /api/auth/oauth/google/callback?code=...&state=...
4. Backend:
- Exchange code -> token endpoint.
- Validate id_token.
- Lay email/name.
- Kiem tra domain.
- Upsert user noi bo.
- Issue JWT noi bo.
- Tao one-time code (2 phut).
- Redirect ve returnUrl?code=<one-time-code>.
5. FE goi API exchange:
- POST /api/auth/oauth/exchange
- Body: { "code": "..." }
6. Backend consume one-time code va tra payload login (token + role + permission).
### 2.2 Luong AzureAD cu
Van giu endpoint cu:
- GET /api/auth/sso/login
- GET /api/auth/sso/callback
- POST /api/auth/sso/exchange
Nhung ben trong da dung chung service theo provider azuread.
----------------------------------------
## 3) Cau hinh bat buoc
### 3.1 appsettings.json
```json
{
"OAuthProviders": {
"DefaultProvider": "azuread",
"Providers": {
"google": {
"Authority": "https://accounts.google.com",
"AuthorizationEndpoint": "https://accounts.google.com/o/oauth2/v2/auth",
"TokenEndpoint": "https://oauth2.googleapis.com/token",
"ClientId": "",
"ClientSecret": "",
"CallbackPath": "/api/auth/oauth/google/callback",
"AllowedDomain": "",
"PendingRoleName": "Pending",
"Scopes": "openid profile email",
"Issuer": "https://accounts.google.com",
"EmailClaim": "email",
"NameClaim": "name",
"HostedDomainClaimName": "hd",
"EnforceHostedDomainClaim": true
}
}
}
}
```
Giai thich nhanh:
- AllowedDomain:
- Rong: cho phep moi domain.
- Co gia tri (vd hust.edu.vn): chi cho email domain nay.
- EnforceHostedDomainClaim=true:
- Neu co claim hd trong token thi bat buoc phai khop AllowedDomain.
- Neu token khong co hd, he thong fallback check theo email domain.
- PendingRoleName: role duoc gan khi user moi dang nhap lan dau.
### 3.2 Google Cloud Console
1. Tao OAuth client (Web application).
2. Authorized redirect URIs:
- https://<your-domain>/api/auth/oauth/google/callback
- (dev) https://localhost:<port>/api/auth/oauth/google/callback
3. Lay ClientId, ClientSecret va dien vao appsettings (hoac secrets).
----------------------------------------
## 4) Dang ky DI va options
Trong Program.cs can co:
```csharp
builder.Services.Configure<AzureAdOptions>(
builder.Configuration.GetSection(AzureAdOptions.SectionName));
builder.Services.Configure<OAuthProvidersOptions>(
builder.Configuration.GetSection(OAuthProvidersOptions.SectionName));
builder.Services.AddScoped<ISsoService, SsoService>();
```
OAuthProvidersOptions dung dictionary provider theo key (vd google, azuread, okta).
----------------------------------------
## 5) Thiet ke interface va service chung
### 5.1 Interface ISsoService
Interface da duoc mo rong thanh provider-aware:
- BuildAuthorizeUrl(provider, redirectUri, state)
- ExchangeCodeAsync(provider, code, redirectUri, ct)
- ValidateIdTokenAsync(provider, idToken, ct)
- IsAllowedDomain(provider, email, principal)
- UpsertUserAsync(provider, email, name, ct)
Va van giu overload cu de backward compatibility.
### 5.2 SsoService - logic tong
Core y tuong:
- Resolve config theo provider key.
- Provider nao khong co endpoint explicit thi suy ra tu metadata/authority.
- AzureAD duoc fallback tu AzureAdOptions de khong vo luong cu.
Phan quan trong:
1) ResolveProvider(...)
- Doc provider trong OAuthProviders.
- Neu key = azuread ma khong co trong OAuthProviders, fallback AzureAdOptions.
2) BuildAuthorizeUrl(...)
- Build URL authorize theo endpoint cua provider.
- Them scope, state, redirect_uri.
- Neu bat hosted-domain check thi them hd=<AllowedDomain>.
3) ExchangeCodeAsync(...)
- Goi token endpoint voi client_id, client_secret, code, redirect_uri.
- Parse OidcTokenResponse.
- Bat buoc co id_token (flow hien tai dang OIDC-centric).
4) ValidateIdTokenAsync(...)
- Lay metadata OIDC (.well-known/openid-configuration).
- Validate issuer, audience, signing key, lifetime.
5) IsAllowedDomain(...)
- Neu AllowedDomain rong -> cho phep.
- Neu khong rong -> check duoi email.
- Neu EnforceHostedDomainClaim bat va token co claim hd -> bat buoc hd trung AllowedDomain.
6) UpsertUserAsync(...)
- Tim user theo email (UserName).
- Neu ton tai: cap nhat ten + metadata update.
- Neu chua ton tai: tao user moi, Password = null, gan role PendingRoleName.
7) One-time code
- CreateOneTimeCodeAsync(...): tao code, luu bang SsoOneTimeCodes, het han sau 2 phut.
- ExchangeOneTimeCodeForLoginAsync(...): consume code, tra payload login final.
----------------------------------------
## 6) Controller va contract API
### 6.1 OAuthController (da provider)
Route base: api/auth/oauth
1) Login
- GET /api/auth/oauth/{provider}/login?returnUrl=...
- Redirect sang provider authorize URL.
2) Callback
- GET /api/auth/oauth/{provider}/callback?code=...&state=...
- Xu ly token + domain + upsert + issue token + one-time code.
- Redirect ve FE voi query code.
3) Exchange
- POST /api/auth/oauth/exchange
Request:
```json
{
"code": "one-time-code"
}
```
Response thanh cong:
```json
{
"token": "<jwt-noi-bo>",
"name": "...",
"username": "email@domain",
"access": [1, 2, 3],
"role": {
"roleName": "Pending",
"priority": 99
}
}
```
### 6.2 SsoController (AzureAD legacy)
Van giu route cu, nhung da goi service chung voi provider azuread.
----------------------------------------
## 7) Rule user va role Pending
Rule bat buoc:
- User OAuth lan dau phai duoc tao trong bang UserAccounts.
- User moi phai vao role Pending.
- Role Pending khong co permission nao (PermissionRoles.IsChecked = 0 hoac khong co row).
- Admin se cap role/permission sau.
Model lien quan:
- UserAccounts
- Roles
- PermissionRoles
- SsoOneTimeCodes
Kiem tra nhanh trong DB:
```sql
-- 1) Kiem tra role Pending co ton tai
SELECT Id, RoleName, Priority
FROM "Roles"
WHERE "RoleName" = 'Pending';
-- 2) Kiem tra Pending role khong co permission active
SELECT pr.*
FROM "PermissionRoles" pr
JOIN "Roles" r ON r."Id" = pr."RoleId"
WHERE r."RoleName" = 'Pending'
AND pr."IsChecked" = 1;
-- 3) Kiem tra user tao boi OAuth
SELECT "Id", "UserName", "Password", "RoleId", "CreatedBy", "UpdatedBy"
FROM "UserAccounts"
WHERE "UserName" = '<email-user>';
```
Ky vong:
- Query #2 tra ve 0 rows.
- User moi co Password = null, CreatedBy = 'SSO'.
----------------------------------------
## 8) Frontend integration (chi tiet)
1. Nguoi dung bam nut Google Login:
- Window.location -> GET /api/auth/oauth/google/login?returnUrl=<FE_CALLBACK_URL>
2. Sau callback backend, FE nhan code tu query string.
3. FE goi:
```http
POST /api/auth/oauth/exchange
Content-Type: application/json
{ "code": "<code-tu-query>" }
```
4. FE luu token va xu ly permission/role giong login cu.
----------------------------------------
## 9) Luu y bao mat va hardening
Nen lam tiep:
- Validate returnUrl theo allowlist de tranh open redirect.
- Luu va verify state/nonce server-side (cache/redis).
- Dung IHttpClientFactory thay new HttpClient() de kiem soat timeout/retry.
- Dua ClientSecret sang secret manager/env var.
- Bat HTTPS va secure cookie policy day du.
----------------------------------------
## 10) Troubleshooting
1) Loi IdToken not found in token response
- Provider dang khong tra OIDC token.
- Kiem tra scope co openid chua.
2) Loi Email not found in token
- Kiem tra claim trong Google token (email, preferred_username).
3) Loi Email domain is not allowed
- Check AllowedDomain va claim hd.
4) Loi Role 'Pending' not found
- Tao role Pending trong bang Roles.
5) Loi exchange code het han
- One-time code chi song 2 phut va chi dung 1 lan.
----------------------------------------
## 11) Checklist test E2E truoc khi merge
- Login Google thanh cong voi account hop le.
- User moi duoc tao trong DB va role = Pending.
- Pending role khong co permission active.
- User cu login lai thi khong tao duplicate user.
- One-time code chi dung 1 lan.
- AllowedDomain rong cho phep tat ca domain.
- AllowedDomain co gia tri thi chan dung domain.
----------------------------------------
## 12) Ghi chu pham vi hien tai
Hien tai flow moi dang OIDC-centric (bat buoc id_token).
Neu can support OAuth thuan (khong co id_token), can bo sung nhanh:
- UserInfo endpoint call.
- Mapping email/name tu userinfo response.
- Branch logic trong callback de fallback userinfo.

View File

@ -3,7 +3,6 @@
# server 127.0.0.1:8080;
# server 172.18.10.8:8080;
# }
server {
listen 80;
server_name comp.soict.io;
@ -13,198 +12,82 @@ server {
}
location / {
# root /usr/share/nginx/html;
# index index.html index.htm;
return 301 https://$host$request_uri;
}
}
server {
server{
listen 443 ssl;
server_name comp.soict.io;
ssl_certificate /etc/letsencrypt/live/comp.soict.io/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/comp.soict.io/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
# MeshCentral proxied flow can set sizable auth cookies.
client_header_buffer_size 16k;
large_client_header_buffers 8 32k;
# Required when proxy_pass uses variables.
# In Docker, 127.0.0.11 is the embedded DNS resolver.
resolver 127.0.0.11 valid=30s ipv6=off;
resolver_timeout 5s;
set $backend_server ttmt-web:8080;
# Internal MeshCentral hop to avoid upstream TLS handshake instability.
set $meshserver meshcentral:8082;
# Public host MeshCentral expects in Host header.
set $meshhost soict-overleaf.tailc51e09.ts.net:8443;
set $backend_server 172.18.10.8:8080;
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;
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 via Tailscale HTTPS so MeshCentral receives
# requests over TLS and generates correct absolute URLs based on $meshhost.
proxy_pass https://soict-overleaf.tailc51e09.ts.net:8443/$1$is_args$args;
proxy_ssl_verify off;
proxy_ssl_server_name on;
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 https;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port 443;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_buffering off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
}

154
package-lock.json generated
View File

@ -26,7 +26,6 @@
"@tanstack/react-router": "^1.121.2",
"@tanstack/react-router-devtools": "^1.121.2",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.26",
"@tanstack/router-plugin": "^1.121.2",
"@tanstack/zod-form-adapter": "^0.42.1",
"axios": "^1.11.0",
@ -1120,9 +1119,9 @@
"license": "MIT"
},
"node_modules/@hono/node-server": {
"version": "1.19.14",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
"integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==",
"version": "1.19.10",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.10.tgz",
"integrity": "sha512-hZ7nOssGqRgyV3FVVQdfi+U4q02uB23bpnYpdvNXkYTRRyWx84b7yf1ans+dnJ/7h41sGL3CeQTfO+ZGxuO+Iw==",
"engines": {
"node": ">=18.14.1"
},
@ -3621,22 +3620,6 @@
"react-dom": ">=16.8"
}
},
"node_modules/@tanstack/react-virtual": {
"version": "3.13.26",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.26.tgz",
"integrity": "sha512-DosdgjOxCLahkn0o+ilmZYwEjo1glfMGuRT/j3PQ18yr5XqA8N/BCaL9IJ3B5TRl+nnzyK2IOFgAILwzN3a9xQ==",
"dependencies": {
"@tanstack/virtual-core": "3.16.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tanstack/router-core": {
"version": "1.129.8",
"resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.129.8.tgz",
@ -3801,15 +3784,6 @@
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/virtual-core": {
"version": "3.16.0",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.16.0.tgz",
"integrity": "sha512-Er2N7q3WOiH6y2JLxsxNX+u2/sLqSsL0bxFgDjuiPiA7vKhZRm+IzcS17vRee3GNXr64UsesA5CAp9yTiIYw9A==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/virtual-file-routes": {
"version": "1.129.7",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-file-routes/-/virtual-file-routes-1.129.7.tgz",
@ -4434,37 +4408,13 @@
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
},
"node_modules/axios": {
"version": "1.16.1",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz",
"integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==",
"version": "1.13.6",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz",
"integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==",
"dependencies": {
"follow-redirects": "^1.16.0",
"follow-redirects": "^1.15.11",
"form-data": "^4.0.5",
"https-proxy-agent": "^5.0.1",
"proxy-from-env": "^2.1.0"
}
},
"node_modules/axios/node_modules/agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
"dependencies": {
"debug": "4"
},
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/axios/node_modules/https-proxy-agent": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
"dependencies": {
"agent-base": "6",
"debug": "4"
},
"engines": {
"node": ">= 6"
"proxy-from-env": "^1.1.0"
}
},
"node_modules/babel-dead-code-elimination": {
@ -5284,9 +5234,9 @@
"license": "MIT"
},
"node_modules/devalue": {
"version": "5.8.1",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz",
"integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw=="
"version": "5.6.4",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.4.tgz",
"integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA=="
},
"node_modules/diff": {
"version": "8.0.3",
@ -5611,11 +5561,11 @@
}
},
"node_modules/express-rate-limit": {
"version": "8.5.2",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz",
"integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==",
"version": "8.3.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.1.tgz",
"integrity": "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==",
"dependencies": {
"ip-address": "^10.2.0"
"ip-address": "10.1.0"
},
"engines": {
"node": ">= 16"
@ -5648,9 +5598,9 @@
}
},
"node_modules/fast-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
"integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==",
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
"integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
"funding": [
{
"type": "github",
@ -5724,9 +5674,9 @@
}
},
"node_modules/follow-redirects": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
@ -6025,9 +5975,9 @@
"integrity": "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ=="
},
"node_modules/hono": {
"version": "4.12.23",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.23.tgz",
"integrity": "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==",
"version": "4.12.9",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.9.tgz",
"integrity": "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA==",
"engines": {
"node": ">=16.9.0"
}
@ -6170,9 +6120,9 @@
}
},
"node_modules/ip-address": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
"engines": {
"node": ">= 12"
}
@ -6924,15 +6874,16 @@
}
},
"node_modules/nanoid": {
"version": "3.3.12",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
@ -7246,9 +7197,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.15",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
"funding": [
{
"type": "opencollective",
@ -7263,8 +7214,9 @@
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.12",
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
@ -7347,12 +7299,9 @@
}
},
"node_modules/proxy-from-env": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
"engines": {
"node": ">=10"
}
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
},
"node_modules/psl": {
"version": "1.15.0",
@ -7375,9 +7324,9 @@
}
},
"node_modules/qs": {
"version": "6.15.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz",
"integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==",
"dependencies": {
"side-channel": "^1.1.0"
},
@ -8857,9 +8806,9 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
},
"node_modules/uuid": {
"version": "13.0.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.2.tgz",
"integrity": "sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw==",
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
@ -8898,9 +8847,9 @@
}
},
"node_modules/vite": {
"version": "6.4.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
"version": "6.4.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
@ -9252,10 +9201,11 @@
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
},
"node_modules/ws": {
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
"version": "8.18.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},

View File

@ -30,7 +30,6 @@
"@tanstack/react-router": "^1.121.2",
"@tanstack/react-router-devtools": "^1.121.2",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.26",
"@tanstack/router-plugin": "^1.121.2",
"@tanstack/zod-form-adapter": "^0.42.1",
"axios": "^1.11.0",

View File

@ -1,11 +0,0 @@
{
"version": 1,
"skills": {
"frontend-design": {
"source": "anthropics/skills",
"sourceType": "github",
"skillPath": "skills/frontend-design/SKILL.md",
"computedHash": "516bd2154eb843a8240e43d5b285229129853114ad7075a5e141e1c08e408c84"
}
}
}

View File

@ -1,226 +0,0 @@
import { useMemo, useState } from "react";
import { AlertTriangle, CheckCircle2, Power, PowerOff, RotateCcw, ShieldBan, XCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useMachineNumber } from "@/hooks/useMachineNumber";
import { useExecuteSensitiveCommand, useGetSensitiveCommands } from "@/hooks/queries/useCommandQueries";
import { CommandType } from "@/types/command-registry";
import { toast } from "sonner";
interface DeviceActionBarProps {
roomName: string;
selectedDevices: any[];
onClearSelection: () => void;
}
const ACTIONS = [
{
type: CommandType.RESTART,
label: "Khởi động lại",
icon: Power,
variant: "outline" as const,
},
{
type: CommandType.SHUTDOWN,
label: "Tắt máy",
icon: PowerOff,
variant: "destructive" as const,
},
{
type: CommandType.TASKKILL,
label: "Kết thúc tác vụ",
icon: XCircle,
variant: "outline" as const,
},
{
type: CommandType.BLOCK,
label: "Chặn",
icon: ShieldBan,
variant: "outline" as const,
},
{
type: CommandType.RESET,
label: "Reset",
icon: RotateCcw,
variant: "destructive" as const,
},
];
const DANGER_TYPES = new Set<CommandType>([CommandType.SHUTDOWN, CommandType.RESET]);
export function DeviceActionBar({
roomName,
selectedDevices,
onClearSelection,
}: DeviceActionBarProps) {
const [confirmOpen, setConfirmOpen] = useState(false);
const [activeType, setActiveType] = useState<CommandType | null>(null);
const [password, setPassword] = useState("");
const [isExecuting, setIsExecuting] = useState(false);
const getMachineNumber = useMachineNumber();
const { data: sensitiveCommands = [] } = useGetSensitiveCommands();
const executeSensitiveMutation = useExecuteSensitiveCommand();
const commandsByType = useMemo(() => {
return (Object.values(CommandType) as Array<number | string>)
.filter((value) => typeof value === "number")
.reduce((acc: Record<number, any[]>, type) => {
acc[type as number] = (sensitiveCommands || []).filter(
(command: any) => Number(command.command) === Number(type)
);
return acc;
}, {} as Record<number, any[]>);
}, [sensitiveCommands]);
const selectedCount = selectedDevices.length;
const activeCommand = activeType ? commandsByType[activeType]?.[0] : null;
const buildDeviceLabel = (device: any) => {
const number = getMachineNumber(device?.id || "");
const ipAddress = device?.networkInfos?.[0]?.ipAddress;
if (number > 0) {
return `#${number}${ipAddress ? ` (${ipAddress})` : ""}`;
}
return `${device?.id ?? ""}${ipAddress ? ` (${ipAddress})` : ""}`;
};
const openConfirm = (type: CommandType) => {
if (!commandsByType[type]?.length) {
toast.error("Chưa có lệnh phù hợp cho thao tác này.");
return;
}
setActiveType(type);
setConfirmOpen(true);
};
const handleClose = () => {
if (isExecuting) return;
setConfirmOpen(false);
setActiveType(null);
setPassword("");
};
const handleConfirm = async () => {
if (!activeCommand || !activeType) return;
if (!password.trim()) {
toast.error("Vui lòng nhập mật khẩu xác nhận.");
return;
}
setIsExecuting(true);
try {
await executeSensitiveMutation.mutateAsync({
roomName,
command: activeCommand.commandName,
password,
});
toast.success(`Đã gửi lệnh: ${activeCommand.commandName}`);
handleClose();
onClearSelection();
} catch (error) {
console.error("Execute command error:", error);
toast.error("Lỗi khi gửi lệnh!");
} finally {
setIsExecuting(false);
}
};
if (selectedCount === 0) return null;
return (
<>
<div className="sticky bottom-4 z-30">
<div className="flex flex-col gap-3 rounded-xl border bg-background/95 px-4 py-3 shadow-lg backdrop-blur sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-2 text-sm font-semibold">
<CheckCircle2 className="h-4 w-4 text-emerald-600" />
Đã chọn {selectedCount} thiết bị
</div>
<div className="flex flex-wrap items-center gap-2">
{ACTIONS.map((action) => {
const Icon = action.icon;
const isDisabled = !commandsByType[action.type]?.length;
return (
<Button
key={action.type}
variant={action.variant}
size="sm"
disabled={isDisabled}
onClick={() => openConfirm(action.type)}
>
<Icon className="h-4 w-4" />
{action.label}
</Button>
);
})}
<Button variant="ghost" size="sm" onClick={onClearSelection}>
Bỏ chọn
</Button>
</div>
</div>
</div>
<Dialog open={confirmOpen} onOpenChange={handleClose}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-orange-600" />
Xác nhận thực thi lệnh
</DialogTitle>
<DialogDescription className="text-left space-y-3">
<p>
Bạn chắc chắn muốn thực thi lệnh{" "}
<strong>{activeCommand?.commandName ?? ""}</strong>?
</p>
{DANGER_TYPES.has(activeType ?? CommandType.RESTART) && (
<p className="text-sm text-destructive">
Hành đng này không thể hoàn tác.
</p>
)}
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="text-sm font-medium">Thiết bị đưc chọn</div>
<ScrollArea className="max-h-40 rounded-lg border p-2">
<div className="space-y-1 text-sm">
{selectedDevices.map((device) => (
<div key={device.id} className="text-muted-foreground">
{buildDeviceLabel(device)}
</div>
))}
</div>
</ScrollArea>
<div>
<label className="mb-1 block text-sm font-medium">Mật khẩu</label>
<Input
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="Nhập mật khẩu để xác nhận"
/>
</div>
</div>
<DialogFooter className="flex gap-2 sm:gap-3">
<Button variant="outline" onClick={handleClose} disabled={isExecuting}>
Hủy
</Button>
<Button
variant={DANGER_TYPES.has(activeType ?? CommandType.RESTART) ? "destructive" : "default"}
onClick={handleConfirm}
disabled={isExecuting}
>
{isExecuting ? "Đang gửi..." : "Xác nhận"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@ -205,7 +205,7 @@ export function CommandActionButtons({ roomName, selectedDevices = [] }: Command
return (
<>
<div className="flex gap-2 flex-wrap items-center">
<div className="flex gap-2 flex-nowrap overflow-x-auto items-center whitespace-nowrap">
{Object.values(CommandType)
.filter((value) => typeof value === "number")
.map((commandType) => renderCommandButton(commandType as CommandType))}

View File

@ -1,46 +1,30 @@
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 { useState, type MouseEvent } from "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,
folderStatus,
isCheckingFolder,
isSelected,
onSelect,
}: {
device: any | undefined;
position: number;
folderStatus?: ClientFolderStatus;
isCheckingFolder?: boolean;
isSelected?: boolean;
onSelect?: (event: MouseEvent<HTMLElement>) => void;
}) {
const [isConnecting, setIsConnecting] = useState(false);
const [showRemote, setShowRemote] = useState(false);
const [proxyUrl, setProxyUrl] = useState<string | null>(null);
if (!device) {
return (
<div className="flex flex-col items-stretch rounded-lg border border-dashed border-muted-foreground/20 overflow-hidden w-[88px]">
<div className="flex items-center justify-between px-1.5 py-1 bg-muted/30">
<span className="text-[11px] font-bold text-muted-foreground/50 leading-none">
{position}
</span>
</div>
<div className="flex flex-col items-center justify-center py-2 gap-0.5">
<Monitor className="h-5 w-5 text-muted-foreground/20" />
<span className="text-[10px] text-muted-foreground/40">Trống</span>
<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">
<div className="absolute -top-2 -left-2 w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold bg-muted text-muted-foreground">
{position}
</div>
<Monitor className="h-8 w-8 mb-1 text-muted-foreground/40" />
<span className="text-xs text-muted-foreground">Trống</span>
</div>
);
}
@ -49,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;
@ -179,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 />
@ -209,7 +137,7 @@ export function ComputerCard({
<Badge
variant={isOffline ? "destructive" : "default"}
className={`flex items-center gap-1 w-fit ${
isOffline ? "bg-slate-100 text-slate-600" : "bg-teal-50 text-teal-700"
isOffline ? "bg-red-100 text-red-700" : "bg-green-100 text-green-700"
}`}
>
{isOffline ? <WifiOff className="h-3 w-3" /> : <Wifi className="h-3 w-3" />}
@ -220,117 +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
onClick={onSelect}
className={cn(
"flex flex-col items-stretch w-[88px] rounded-lg border-2 overflow-hidden transition-all hover:shadow-md hover:-translate-y-0.5 cursor-pointer select-none",
isOffline
? "border-slate-400 bg-white hover:border-slate-500"
: "border-teal-400 bg-white hover:border-teal-500",
isSelected && "ring-2 ring-primary ring-offset-1 ring-offset-background"
"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"
)}
>
{/* Top bar: position + folder status */}
<div
className={cn(
"flex items-center justify-between px-1.5 py-1",
isOffline ? "bg-slate-500" : "bg-teal-600"
)}
>
<span
className="text-[11px] font-bold text-white leading-none"
style={{ textShadow: "0 1px 2px rgba(0,0,0,0.5)" }}
>
{position}
</span>
{!isOffline && (
<div
onClick={(e) => e.stopPropagation()}
className="[&_button]:p-0 [&_button]:rounded [&_button]:hover:bg-teal-500 [&_svg]:h-3.5 [&_svg]:w-3.5 [&_svg]:text-white"
>
<FolderStatusPopover
deviceId={device.networkInfos?.[0]?.macAddress || device.id}
status={folderStatus}
isLoading={isCheckingFolder}
/>
</div>
)}
</div>
{position}
</div>
{/* Body */}
<div className="flex flex-col items-center justify-center gap-0.5 py-2 px-1">
<Monitor
className={cn(
"h-5 w-5",
isOffline ? "text-slate-300" : "text-teal-500"
)}
{/* Folder Status Icon */}
{device && !isOffline && (
<div className="absolute -top-2 -right-2">
<FolderStatusPopover
deviceId={device.networkInfos?.[0]?.macAddress || device.id}
status={folderStatus}
isLoading={isCheckingFolder}
/>
{firstNetworkInfo?.ipAddress && (
<div className="text-[9px] font-mono text-center leading-tight w-full truncate text-muted-foreground px-0.5">
{firstNetworkInfo.ipAddress}
</div>
)}
</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-[9px] font-mono text-center text-muted-foreground/60 leading-tight">
<div className="text-[10px] font-mono text-center mb-1 px-1 truncate w-full">
v{agentVersion}
</div>
)}
<div
className={cn(
"text-[10px] font-semibold leading-none mt-0.5",
isOffline ? "text-slate-500" : "text-teal-600"
)}
>
{isOffline ? "Off" : "On"}
</div>
</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"
/>
)}
<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>
);
}

View File

@ -8,13 +8,6 @@ export function createAppsColumns(isPending: boolean): ColumnDef<Version>[] {
return [
{ accessorKey: "version", header: "Phiên bản" },
{ accessorKey: "fileName", header: "Tên file" },
{
accessorKey: "syncFolder",
header: "Thư mục đồng bộ",
cell: ({ getValue }) => (
<code className="text-xs">{(getValue() as string) || "C:/Setup"}</code>
),
},
{
accessorKey: "updatedAt",
header: () => (

View File

@ -32,7 +32,6 @@ export interface CommandRegistryFormData {
commandContent: string;
qos: 0 | 1 | 2;
isRetained: boolean;
ttlMinutes: number;
}
// Zod validation schema
@ -54,7 +53,6 @@ const commandRegistrySchema = z.object({
.trim(),
qos: z.union([z.literal(0), z.literal(1), z.literal(2)]),
isRetained: z.boolean(),
ttlMinutes: z.number().int().min(-1, "TTL tối thiểu là -1 (vô hạn)"),
});
const QoSLevels = [
@ -100,7 +98,6 @@ export function CommandRegistryForm({
commandContent: initialData?.commandContent || "",
qos: (initialData?.qos || 0) as 0 | 1 | 2,
isRetained: initialData?.isRetained || false,
ttlMinutes: initialData?.ttlMinutes ?? -1,
},
onSubmit: async ({ value }) => {
try {
@ -186,7 +183,6 @@ export function CommandRegistryForm({
<option value={CommandType.SHUTDOWN}>SHUTDOWN - Tắt máy</option>
<option value={CommandType.TASKKILL}>TASKKILL - Kết thúc tác vụ</option>
<option value={CommandType.BLOCK}>BLOCK - Chặn</option>
<option value={CommandType.RESET}>RESET - Đt lại</option>
</select>
{field.state.meta.errors?.length > 0 && (
<p className="text-sm text-red-500">
@ -391,31 +387,6 @@ export function CommandRegistryForm({
)}
</form.Field>
{/* TTL Minutes */}
<form.Field name="ttlMinutes">
{(field: any) => (
<div className="space-y-2">
<Label>TTL (Phút)</Label>
<Input
type="number"
placeholder="-1"
value={field.state.value}
onChange={(e) => field.handleChange(Number(e.target.value))}
onBlur={field.handleBlur}
disabled={isSubmitting}
/>
{field.state.meta.errors?.length > 0 && (
<p className="text-sm text-red-500">
{String(field.state.meta.errors[0])}
</p>
)}
<p className="text-sm text-muted-foreground">
Thời gian tồn tại của lệnh retained (phút). -1 = hạn.
</p>
</div>
)}
</form.Field>
{/* Submit Button */}
<div className="flex gap-3 pt-4">
<Button

View File

@ -4,7 +4,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import type { LoginResquest } from "@/types/auth";
import { useMutation } from "@tanstack/react-query";
import { buildGoogleOAuthLoginUrl, buildMicrosoftSsoLoginUrl, login } from "@/services/auth.service";
import { buildSsoLoginUrl, login } from "@/services/auth.service";
import { useState } from "react";
import { useNavigate, useRouter } from "@tanstack/react-router";
import { Route } from "@/routes/(auth)/login";
@ -44,22 +44,12 @@ export function LoginForm({ className }: React.ComponentProps<"form">) {
}
});
const handleGoogleLogin = () => {
const returnUrl = new URL("/oauth/callback", window.location.origin);
returnUrl.searchParams.set("provider", "google");
const handleSsoLogin = () => {
const returnUrl = new URL("/sso/callback", window.location.origin);
if (search.redirect) {
returnUrl.searchParams.set("redirect", search.redirect);
}
window.location.assign(buildGoogleOAuthLoginUrl(returnUrl.toString()));
};
const handleMicrosoftLogin = () => {
const returnUrl = new URL("/oauth/callback", window.location.origin);
returnUrl.searchParams.set("provider", "azuread");
if (search.redirect) {
returnUrl.searchParams.set("redirect", search.redirect);
}
window.location.assign(buildMicrosoftSsoLoginUrl(returnUrl.toString()));
window.location.assign(buildSsoLoginUrl(returnUrl.toString()));
};
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
@ -122,33 +112,12 @@ export function LoginForm({ className }: React.ComponentProps<"form">) {
</Button>
)}
<div className="text-center text-sm text-muted-foreground">Hoặc</div>
<Button type="button" variant="outline" className="w-full gap-2" onClick={handleGoogleLogin}>
<Button type="button" variant="outline" className="w-full gap-2" onClick={handleSsoLogin}>
<svg viewBox="0 0 24 24" aria-hidden="true" className="h-4 w-4">
<path
fill="#4285F4"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.02 5.02 0 0 1-2.18 3.29v2.74h3.52c2.05-1.89 3.3-4.67 3.3-8.04Z"
/>
<path
fill="#34A853"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.52-2.74c-.98.66-2.23 1.06-3.76 1.06-2.89 0-5.33-1.95-6.2-4.56H2.18v2.84A11 11 0 0 0 12 23Z"
/>
<path
fill="#FBBC05"
d="M5.8 14.1A6.62 6.62 0 0 1 5.45 12c0-.73.13-1.44.35-2.1V7.06H2.18A11 11 0 0 0 1 12c0 1.77.42 3.44 1.18 4.94L5.8 14.1Z"
/>
<path
fill="#EA4335"
d="M12 5.34c1.61 0 3.05.56 4.18 1.64l3.14-3.14C17.45 2.09 14.97 1 12 1a11 11 0 0 0-9.82 6.06L5.8 9.9C6.67 7.29 9.11 5.34 12 5.34Z"
/>
</svg>
Đăng nhập với Google
</Button>
<Button type="button" variant="outline" className="w-full gap-2" onClick={handleMicrosoftLogin}>
<svg viewBox="0 0 24 24" aria-hidden="true" className="h-4 w-4">
<rect x="2" y="2" width="9" height="9" fill="#F35325" />
<rect x="13" y="2" width="9" height="9" fill="#81BC06" />
<rect x="2" y="13" width="9" height="9" fill="#05A6F0" />
<rect x="13" y="13" width="9" height="9" fill="#FFBA08" />
<rect x="1" y="1" width="10" height="10" fill="#F25022" />
<rect x="13" y="1" width="10" height="10" fill="#7FBA00" />
<rect x="1" y="13" width="10" height="10" fill="#00A4EF" />
<rect x="13" y="13" width="10" height="10" fill="#FFB900" />
</svg>
Đăng nhập với Microsoft
</Button>

View File

@ -18,14 +18,14 @@ 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));
};
const form = useForm({
defaultValues: { files: new DataTransfer().files, newVersion: "", syncFolder: "" },
defaultValues: { files: new DataTransfer().files, newVersion: "" },
onSubmit: async ({ value }) => {
if (!value.newVersion || value.files.length === 0) {
toast.error("Vui lòng điền đầy đủ thông tin");
@ -49,7 +49,6 @@ export function UploadVersionForm({ onSubmit, closeDialog }: UploadVersionFormPr
const fd = new FormData();
Array.from(value.files).forEach((f) => fd.append("FileInput", f));
fd.append("Version", value.newVersion);
if (value.syncFolder) fd.append("SyncFolder", value.syncFolder);
await onSubmit(fd, {
onUploadProgress: (e: AxiosProgressEvent) => {
@ -92,23 +91,6 @@ export function UploadVersionForm({ onSubmit, closeDialog }: UploadVersionFormPr
)}
</form.Field>
<form.Field name="syncFolder">
{(field) => (
<div>
<Label>Thư mục đng bộ</Label>
<Input
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
placeholder="C:/Setup"
disabled={isUploading || isDone}
/>
<p className="text-xs text-muted-foreground mt-1">
Đ trống = mặc đnh <code>C:/Setup</code>
</p>
</div>
)}
</form.Field>
<form.Field name="files">
{(field) => (
<div>

View File

@ -1,122 +0,0 @@
import { useMemo, type MouseEvent } from "react";
import { cn } from "@/lib/utils";
import { useMachineNumber } from "@/hooks/useMachineNumber";
interface DeviceGridCompactProps {
devices: any[];
selectedIds?: string[];
onSelectDevice?: (
deviceId: string,
index: number,
event: MouseEvent<HTMLElement>
) => void;
}
export function DeviceGridCompact({
devices,
selectedIds = [],
onSelectDevice,
}: DeviceGridCompactProps) {
const getMachineNumber = useMachineNumber();
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]);
const items = useMemo(() => {
return [...devices]
.map((device, index) => ({
device,
index,
number: getMachineNumber(device?.id || ""),
}))
.sort((a, b) => {
const aNumber = a.number > 0 ? a.number : Number.MAX_SAFE_INTEGER;
const bNumber = b.number > 0 ? b.number : Number.MAX_SAFE_INTEGER;
if (aNumber !== bNumber) return aNumber - bNumber;
return a.index - b.index;
});
}, [devices, getMachineNumber]);
return (
<div className="grid grid-cols-[repeat(auto-fill,minmax(76px,1fr))] gap-2">
{items.map((item, index) => {
const device = item.device;
const position = item.number > 0 ? item.number : item.index + 1;
const ipAddress = device?.networkInfos?.[0]?.ipAddress;
const version = device?.version;
const titleParts = [`#${position}`];
if (ipAddress) titleParts.push(`IP: ${ipAddress}`);
if (version) titleParts.push(`v${version}`);
const isOffline = device?.isOffline;
const isSelected = selectedSet.has(device?.id);
// last 2 octets of IP for compact display
const shortIp = ipAddress
? ipAddress.split(".").slice(-2).join(".")
: null;
return (
<button
key={device?.id || `device-${item.index}`}
type="button"
title={titleParts.join(" | ")}
onClick={(event) => {
if (!device?.id) return;
onSelectDevice?.(device.id, index, event);
}}
className={cn(
"flex flex-col items-stretch rounded-lg border-2 overflow-hidden transition-all hover:shadow-md hover:-translate-y-0.5 cursor-pointer",
isOffline
? "border-slate-400 bg-white hover:border-slate-500"
: "border-teal-400 bg-white hover:border-teal-500",
isSelected &&
"ring-2 ring-primary ring-offset-1 ring-offset-background"
)}
>
{/* Top color bar with position number */}
<div
className={cn(
"flex items-center justify-between px-1.5 py-1",
isOffline ? "bg-slate-500" : "bg-teal-600"
)}
>
<span
className="text-[11px] font-bold text-white leading-none"
style={{ textShadow: "0 1px 2px rgba(0,0,0,0.5)" }}
>
{position}
</span>
<span
className={cn(
"h-1.5 w-1.5 rounded-full border border-white/40",
isOffline ? "bg-slate-200" : "bg-teal-200"
)}
/>
</div>
{/* Body */}
<div className="flex flex-col items-center justify-center gap-0.5 py-1.5 px-1">
{shortIp ? (
<span
className="font-mono text-muted-foreground truncate w-full text-center leading-tight"
style={{ fontSize: "9px" }}
>
{shortIp}
</span>
) : (
<span className="text-[9px] text-muted-foreground/50"></span>
)}
<span
className={cn(
"text-[10px] font-semibold leading-none",
isOffline ? "text-slate-500" : "text-teal-600"
)}
>
{isOffline ? "Off" : "On"}
</span>
</div>
</button>
);
})}
</div>
);
}

View File

@ -1,4 +1,3 @@
import { useMemo, type MouseEvent } from "react";
import { Monitor, DoorOpen } from "lucide-react";
import { ComputerCard } from "../cards/computer-card";
import { useMachineNumber } from "../../hooks/useMachineNumber";
@ -8,146 +7,68 @@ export function DeviceGrid({
devices,
folderStatuses,
isCheckingFolder,
totalSeats,
selectedIds = [],
onSelectDevice,
}: {
devices: any[];
folderStatuses?: Map<string, ClientFolderStatus>;
isCheckingFolder?: boolean;
totalSeats?: number;
selectedIds?: string[];
onSelectDevice?: (
deviceId: string,
index: number,
event: MouseEvent<HTMLElement>
) => void;
}) {
const getMachineNumber = useMachineNumber();
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]);
const parsedDevices = devices
.map((device, index) => ({
device,
index,
number: getMachineNumber(device.id || ""),
}))
.sort((a, b) => {
const aNumber = a.number > 0 ? a.number : Number.MAX_SAFE_INTEGER;
const bNumber = b.number > 0 ? b.number : Number.MAX_SAFE_INTEGER;
const deviceMap = new Map<number, any>();
if (aNumber !== bNumber) {
return aNumber - bNumber;
}
devices.forEach((device) => {
const number = getMachineNumber(device.id || "");
if (number > 0 && number <= 40) deviceMap.set(number, device);
});
return a.index - b.index;
});
const orderedDevices = parsedDevices.map((item, orderIndex) => ({
...item,
orderIndex,
}));
const seatCount =
typeof totalSeats === "number" && totalSeats > 0 ? totalSeats : orderedDevices.length;
const rightCapacity = Math.ceil(seatCount / 2);
const inRangeCount = orderedDevices.filter(
(item) => item.number > 0 && item.number <= seatCount
).length;
const useThresholdSplit =
seatCount > 0 && inRangeCount >= Math.ceil(orderedDevices.length * 0.6);
let rightDevices = orderedDevices;
let leftDevices: typeof orderedDevices = [];
if (useThresholdSplit) {
rightDevices = orderedDevices.filter(
(item) => item.number > 0 && item.number <= rightCapacity
);
leftDevices = orderedDevices.filter((item) => item.number > rightCapacity);
const unassigned = orderedDevices.filter(
(item) => item.number <= 0 || item.number > seatCount
);
leftDevices = [...leftDevices, ...unassigned];
} else {
const splitIndex = Math.ceil(orderedDevices.length / 2);
rightDevices = orderedDevices.slice(0, splitIndex);
leftDevices = orderedDevices.slice(splitIndex);
}
const renderDevice = (item: (typeof orderedDevices)[number]) => {
const device = item.device;
const position = item.number > 0 ? item.number : item.index + 1;
const macAddress = device?.networkInfos?.[0]?.macAddress || device?.id;
const folderStatus = folderStatuses?.get(macAddress);
const isSelected = device?.id ? selectedSet.has(device.id) : false;
return (
<ComputerCard
key={device?.id || `device-${item.index}`}
device={device}
position={position}
folderStatus={folderStatus}
isCheckingFolder={isCheckingFolder}
isSelected={isSelected}
onSelect={(event) => {
if (!device?.id) return;
onSelectDevice?.(device.id, item.orderIndex, event);
}}
/>
);
};
const columnsPerSide = 4;
const chunkRows = <T,>(items: T[], size: number) => {
const rows: T[][] = [];
for (let i = 0; i < items.length; i += size) {
rows.push(items.slice(i, i + size));
}
return rows;
};
const leftRows = chunkRows(leftDevices, columnsPerSide);
const rightRows = chunkRows(rightDevices, columnsPerSide);
const totalRows = Math.max(leftRows.length, rightRows.length);
const renderPlaceholder = (key: string) => (
<div key={key} className="w-24 h-24 shrink-0" aria-hidden="true" />
);
const totalRows = 5;
const renderRow = (rowIndex: number) => {
const leftRow = leftRows[rowIndex] ?? [];
const rightRow = rightRows[rowIndex] ?? [];
const leftFill = Math.max(0, columnsPerSide - leftRow.length);
const rightFill = Math.max(0, columnsPerSide - rightRow.length);
// Cả 2 panel đều mirror: số nhỏ nhất sát divider, tăng ra ngoài
// Right: [8,7,6,5,4,3,2,1 | divider] Left: [divider | 9,10,11,12,13,14,15,16]
// Nhìn từ bàn GV (phải) sang trái: 1,2,3,4,... liên tục
const rightRowReversed = [...rightRow].reverse();
const leftRowReversed = [...leftRow].reverse();
// Đảo ngược: 21-40 sang trái, 1-20 sang phải
const leftStart = 21 + (totalRows - 1 - rowIndex) * 4;
const rightStart = (totalRows - 1 - rowIndex) * 4 + 1;
return (
<div key={`row-${rowIndex}`} className="flex items-center justify-center gap-3">
{/* Left panel: số lớn sát divider, giảm ra ngoài trái */}
<div className="flex items-center gap-3">
{Array.from({ length: leftFill }).map((_, i) =>
renderPlaceholder(`left-pad-${rowIndex}-${i}`)
)}
{leftRowReversed.map(renderDevice)}
<div key={rowIndex} className="flex items-center justify-center gap-3">
{/* Bên trái (2140) */}
{Array.from({ length: 4 }).map((_, i) => {
const pos = leftStart + (3 - i);
const device = deviceMap.get(pos);
const macAddress = device?.networkInfos?.[0]?.macAddress || device?.id;
const folderStatus = folderStatuses?.get(macAddress);
return (
<ComputerCard
key={pos}
device={device}
position={pos}
folderStatus={folderStatus}
isCheckingFolder={isCheckingFolder}
/>
);
})}
{/* Đường chia giữa */}
<div className="w-32 flex items-center justify-center">
<div className="h-px w-full bg-border border-t-2 border-dashed" />
</div>
<div className="w-10 flex items-center justify-center">
<div className="h-10 w-px bg-border border-l-2 border-dashed" />
</div>
{/* Bên phải (120) */}
{Array.from({ length: 4 }).map((_, i) => {
const pos = rightStart + (3 - i);
const device = deviceMap.get(pos);
const macAddress = device?.networkInfos?.[0]?.macAddress || device?.id;
const folderStatus = folderStatuses?.get(macAddress);
{/* Right panel: số 1 sát divider, tăng ra ngoài phải */}
<div className="flex items-center gap-3">
{rightRowReversed.map(renderDevice)}
{Array.from({ length: rightFill }).map((_, i) =>
renderPlaceholder(`right-pad-${rowIndex}-${i}`)
)}
</div>
return (
<ComputerCard
key={pos}
device={device}
position={pos}
folderStatus={folderStatus}
isCheckingFolder={isCheckingFolder}
/>
);
})}
</div>
);
};
@ -155,7 +76,7 @@ export function DeviceGrid({
return (
<div className="px-0.5 py-8 space-y-6">
<div className="space-y-4">
{Array.from({ length: totalRows }).map((_, i) => renderRow(totalRows - 1 - i))}
{Array.from({ length: totalRows }).map((_, i) => renderRow(i))}
</div>
<div className="flex items-center justify-between px-4 pb-6 border-b-2 border-dashed">
<div className="flex items-center gap-3 px-6 py-4 bg-muted rounded-lg border-2 border-border">

View File

@ -1,89 +0,0 @@
import { useMemo } from "react";
import type { Room } from "@/types/room";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
interface RoomListPanelProps {
rooms: Room[];
activeRoomName?: string;
onSelectRoom: (roomName: string) => void;
}
const formatDeviceCount = (value: number) => {
if (!Number.isFinite(value)) return "0";
if (value < 1000) return String(value);
const k = value / 1000;
const needsDecimal = value < 10000 && value % 1000 !== 0;
const formatted = k.toFixed(needsDecimal ? 1 : 0).replace(/\.0$/, "");
return `${formatted}k`;
};
export function RoomListPanel({
rooms,
activeRoomName,
onSelectRoom,
}: RoomListPanelProps) {
const sortedRooms = useMemo(() => {
return [...rooms].sort((a, b) => {
const nameA = String(a?.name ?? "");
const nameB = String(b?.name ?? "");
return nameA.localeCompare(nameB, "vi", {
numeric: true,
sensitivity: "base",
});
});
}, [rooms]);
return (
<div className="w-[200px] shrink-0 overflow-hidden rounded-xl border bg-background shadow-sm">
<div className="flex items-center justify-between border-b bg-muted/30 px-3 py-2">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Danh sách phòng
</span>
<Badge variant="secondary" className="text-[10px]">
{rooms.length}
</Badge>
</div>
<ScrollArea className="h-[calc(100vh-240px)] p-2">
<div className="space-y-1.5">
{sortedRooms.length === 0 && (
<div className="px-2 py-6 text-center text-xs text-muted-foreground">
Chưa phòng
</div>
)}
{sortedRooms.map((room) => {
const isActive = room.name === activeRoomName;
const hasOffline = room.numberOfOfflineDevices > 0;
return (
<button
key={room.name}
type="button"
onClick={() => onSelectRoom(room.name)}
className={cn(
"w-full flex items-center gap-2 rounded-lg border px-2.5 py-2 text-left text-sm transition-colors",
isActive
? "border-border/60 bg-background text-foreground shadow-sm"
: "border-transparent text-muted-foreground hover:bg-muted/40"
)}
>
<span
className={cn(
"h-2 w-2 rounded-full",
hasOffline ? "bg-red-500" : "bg-green-500"
)}
/>
<span className="flex-1 truncate font-medium text-foreground">
{room.name}
</span>
<Badge variant="secondary" className="text-[10px]">
{formatDeviceCount(room.numberOfDevices)}
</Badge>
</button>
);
})}
</div>
</ScrollArea>
</div>
);
}

View File

@ -5,8 +5,6 @@ import {
useReactTable,
type ColumnDef,
} from "@tanstack/react-table";
import { useMemo, useRef, type MouseEvent } from "react";
import { useVirtualizer, type VirtualItem } from "@tanstack/react-virtual";
import {
Table,
TableBody,
@ -18,7 +16,6 @@ import {
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Wifi, WifiOff, Clock, MapPin, Monitor, ChevronLeft, ChevronRight } from "lucide-react";
import { useMachineNumber } from "@/hooks/useMachineNumber";
import { FolderStatusPopover } from "../folder-status-popover";
@ -26,13 +23,6 @@ import { FolderStatusPopover } from "../folder-status-popover";
interface DeviceTableProps {
devices: any[];
isCheckingFolder?: boolean;
selectedIds?: string[];
onToggleDevice?: (
deviceId: string,
index: number,
event: MouseEvent<HTMLElement>
) => void;
onToggleAll?: (checked: boolean) => void;
}
/**
@ -41,46 +31,10 @@ interface DeviceTableProps {
export function DeviceTable({
devices,
isCheckingFolder,
selectedIds = [],
onToggleDevice,
onToggleAll,
}: DeviceTableProps) {
const getMachineNumber = useMachineNumber();
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]);
const allSelected = devices.length > 0 && devices.every((d) => selectedSet.has(d.id));
const someSelected = devices.some((d) => selectedSet.has(d.id));
const selectionEnabled = Boolean(onToggleDevice || onToggleAll);
const selectionColumn: ColumnDef<any> = {
id: "select",
header: () => (
<div className="flex items-center justify-center">
<Checkbox
checked={allSelected ? true : someSelected ? "indeterminate" : false}
onCheckedChange={(value) => onToggleAll?.(value === true)}
aria-label="Chọn tất cả"
/>
</div>
),
cell: ({ row }) => {
const device = row.original;
return (
<div className="flex items-center justify-center">
<Checkbox
checked={selectedSet.has(device.id)}
onClick={(event) => {
event.stopPropagation();
onToggleDevice?.(device.id, row.index, event);
}}
aria-label="Chọn thiết bị"
/>
</div>
);
},
};
const columns: ColumnDef<any>[] = [
...(selectionEnabled ? [selectionColumn] : []),
{
header: "STT",
cell: ({ row }) => {
@ -154,11 +108,11 @@ export function DeviceTable({
key={idx}
className="flex items-center gap-2 text-sm font-mono px-2 py-1 rounded bg-muted/30"
>
<span className="text-primary">*</span>
<span className="text-primary"></span>
<code className="bg-background px-2 py-0.5 rounded">
{info.macAddress ?? "-"}
</code>
<span className="text-muted-foreground">-&gt;</span>
<span className="text-muted-foreground"></span>
<code className="bg-background px-2 py-0.5 rounded">
{info.ipAddress ?? "-"}
</code>
@ -217,24 +171,8 @@ export function DeviceTable({
initialState: { pagination: { pageSize: 16 } },
});
const parentRef = useRef<HTMLDivElement>(null);
const rows = table.getRowModel().rows;
const rowVirtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 72,
overscan: 8,
});
const virtualRows = rowVirtualizer.getVirtualItems() as VirtualItem[];
const paddingTop = virtualRows.length > 0 ? virtualRows[0].start : 0;
const paddingBottom =
virtualRows.length > 0
? rowVirtualizer.getTotalSize() - virtualRows[virtualRows.length - 1].end
: 0;
const columnCount = table.getVisibleLeafColumns().length;
return (
<div ref={parentRef} className="max-h-[600px] overflow-y-auto">
<div className="max-h-[600px] overflow-y-auto">
<Table>
<TableHeader className="sticky top-0 bg-background z-10">
{table.getHeaderGroups().map((headerGroup) => (
@ -248,40 +186,15 @@ export function DeviceTable({
))}
</TableHeader>
<TableBody>
{paddingTop > 0 && (
<TableRow>
<TableCell
colSpan={columnCount}
className="p-0"
style={{ height: `${paddingTop}px` }}
/>
{table.getRowModel().rows.map((row) => (
<TableRow key={row.id} className="hover:bg-muted/50 transition-colors">
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id} className="py-4">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
)}
{virtualRows.map((virtualRow) => {
const row = rows[virtualRow.index];
return (
<TableRow
key={row.id}
className="hover:bg-muted/50 transition-colors"
style={{ height: `${virtualRow.size}px` }}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id} className="py-4">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
);
})}
{paddingBottom > 0 && (
<TableRow>
<TableCell
colSpan={columnCount}
className="p-0"
style={{ height: `${paddingBottom}px` }}
/>
</TableRow>
)}
))}
</TableBody>
</Table>

View File

@ -1,4 +1,5 @@
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
@ -6,23 +7,50 @@ function ScrollArea({
className,
children,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<div
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative overflow-auto", className)}
className={cn("relative", className)}
{...props}
>
{children}
</div>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return <div className={className} {...props} />
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }

View File

@ -1,46 +1,16 @@
const isDev = import.meta.env.MODE === "development";
const trimTrailingSlash = (value: string) => value.replace(/\/+$/, "");
export const BASE_URL = isDev
? import.meta.env.VITE_API_URL_DEV
: "/api";
export const BASE_MESH_URL = isDev
? (import.meta.env.VITE_API_MESH || import.meta.env.VITE_API_MESH_DEV || "")
: (import.meta.env.VITE_API_MESH || "");
export const buildMeshProxyUrl = (meshPathAndQuery: string) => {
const cleanPath = meshPathAndQuery.startsWith("/")
? meshPathAndQuery.substring(1)
: meshPathAndQuery;
const proxyPath = `/api/meshcentral/proxy/${cleanPath}`;
// If an explicit mesh host is configured, always use it.
// This allows forcing proxy URLs to https://<IP>:<port>/api/meshcentral/proxy/...
if (BASE_MESH_URL && BASE_MESH_URL.startsWith("http")) {
return `${trimTrailingSlash(BASE_MESH_URL)}${proxyPath}`;
}
// In development, BASE_URL is usually absolute (e.g. http://localhost:5218/api).
// Build an absolute proxy URL to backend so iframe requests do not hit Vite dev server.
if (BASE_URL.startsWith("http")) {
const apiBase = trimTrailingSlash(BASE_URL);
const backendOrigin = apiBase.endsWith("/api")
? apiBase.slice(0, -4)
: apiBase;
return `${backendOrigin}${proxyPath}`;
}
return proxyPath;
};
? import.meta.env.VITE_API_MESH_DEV
: "/meshapi";
export const API_ENDPOINTS = {
AUTH: {
LOGIN: `${BASE_URL}/login`,
OAUTH_LOGIN: (provider: string) =>
`${BASE_URL}/auth/oauth/${encodeURIComponent(provider)}/login`,
OAUTH_EXCHANGE: `${BASE_URL}/auth/oauth/exchange`,
SSO_LOGIN: `${BASE_URL}/auth/sso/login`,
SSO_EXCHANGE: `${BASE_URL}/auth/sso/exchange`,
LOGOUT: `${BASE_URL}/logout`,
@ -74,9 +44,6 @@ export const API_ENDPOINTS = {
DELETE_REQUIRED_FILE: `${BASE_URL}/AppVersion/requirefile/delete`,
DELETE_FILES: `${BASE_URL}/AppVersion/delete`,
},
MANIFEST: {
SEND_ALL: `${BASE_URL}/Manifest/sendall`,
},
DEVICE_COMM: {
DOWNLOAD_FILES: (roomName: string) => `${BASE_URL}/DeviceComm/downloadfile/${roomName}`,
INSTALL_MSI: (roomName: string) => `${BASE_URL}/DeviceComm/installmsi/${roomName}`,

View File

@ -184,12 +184,3 @@ export function useDeleteFile() {
},
});
}
/**
* Hook đ gửi manifest
*/
export function useSendManifest() {
return useMutation({
mutationFn: () => appVersionService.sendManifest(),
});
}

View File

@ -115,17 +115,10 @@ export function useCreateAccount() {
}
/**
* Hook đ đi one-time OAuth code lấy payload đăng nhập
*/
export function useExchangeOAuthCode() {
return useMutation<LoginResponse, any, string>({
mutationFn: (code) => authService.exchangeOAuthCode(code),
});
}
/**
* Legacy alias for backward compatibility.
* Hook đ đi one-time code SSO lấy payload đăng nhập
*/
export function useExchangeSsoCode() {
return useExchangeOAuthCode();
return useMutation<LoginResponse, any, string>({
mutationFn: (code) => authService.exchangeSsoCode(code),
});
}

View File

@ -2,7 +2,6 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import * as deviceCommService from "@/services/device-comm.service";
import type { DeviceHealthCheck } from "@/types/device";
import type { ClientFolderStatus } from "@/types/folder";
import type { Room } from "@/types/room";
const DEVICE_COMM_QUERY_KEYS = {
all: ["device-comm"] as const,
@ -30,7 +29,7 @@ export function useGetAllDevices(enabled = true) {
* Hook đ lấy danh sách phòng
*/
export function useGetRoomList(enabled = true) {
return useQuery<Room[]>({
return useQuery({
queryKey: DEVICE_COMM_QUERY_KEYS.roomList(),
queryFn: () => deviceCommService.getRoomList(),
enabled,

View File

@ -28,7 +28,7 @@ import { Route as AuthRoomsRoomNameIndexRouteImport } from './routes/_auth/rooms
import { Route as AuthRoleCreateIndexRouteImport } from './routes/_auth/role/create/index'
import { Route as AuthProfileChangePasswordIndexRouteImport } from './routes/_auth/profile/change-password/index'
import { Route as AuthProfileUserNameIndexRouteImport } from './routes/_auth/profile/$userName/index'
import { Route as authOauthCallbackIndexRouteImport } from './routes/(auth)/oauth/callback/index'
import { Route as authSsoCallbackIndexRouteImport } from './routes/(auth)/sso/callback/index'
import { Route as AuthUserRoleRoleIdIndexRouteImport } from './routes/_auth/user/role/$roleId/index'
import { Route as AuthUserEditUserNameIndexRouteImport } from './routes/_auth/user/edit/$userName/index'
import { Route as AuthUserChangePasswordUserNameIndexRouteImport } from './routes/_auth/user/change-password/$userName/index'
@ -132,9 +132,9 @@ const AuthProfileUserNameIndexRoute =
path: '/profile/$userName/',
getParentRoute: () => AuthRoute,
} as any)
const authOauthCallbackIndexRoute = authOauthCallbackIndexRouteImport.update({
id: '/(auth)/oauth/callback/',
path: '/oauth/callback/',
const authSsoCallbackIndexRoute = authSsoCallbackIndexRouteImport.update({
id: '/(auth)/sso/callback/',
path: '/sso/callback/',
getParentRoute: () => rootRouteImport,
} as any)
const AuthUserRoleRoleIdIndexRoute = AuthUserRoleRoleIdIndexRouteImport.update({
@ -186,7 +186,7 @@ export interface FileRoutesByFullPath {
'/role': typeof AuthRoleIndexRoute
'/rooms': typeof AuthRoomsIndexRoute
'/user': typeof AuthUserIndexRoute
'/oauth/callback': typeof authOauthCallbackIndexRoute
'/sso/callback': typeof authSsoCallbackIndexRoute
'/profile/$userName': typeof AuthProfileUserNameIndexRoute
'/profile/change-password': typeof AuthProfileChangePasswordIndexRoute
'/role/create': typeof AuthRoleCreateIndexRoute
@ -213,7 +213,7 @@ export interface FileRoutesByTo {
'/role': typeof AuthRoleIndexRoute
'/rooms': typeof AuthRoomsIndexRoute
'/user': typeof AuthUserIndexRoute
'/oauth/callback': typeof authOauthCallbackIndexRoute
'/sso/callback': typeof authSsoCallbackIndexRoute
'/profile/$userName': typeof AuthProfileUserNameIndexRoute
'/profile/change-password': typeof AuthProfileChangePasswordIndexRoute
'/role/create': typeof AuthRoleCreateIndexRoute
@ -242,7 +242,7 @@ export interface FileRoutesById {
'/_auth/role/': typeof AuthRoleIndexRoute
'/_auth/rooms/': typeof AuthRoomsIndexRoute
'/_auth/user/': typeof AuthUserIndexRoute
'/(auth)/oauth/callback/': typeof authOauthCallbackIndexRoute
'/(auth)/sso/callback/': typeof authSsoCallbackIndexRoute
'/_auth/profile/$userName/': typeof AuthProfileUserNameIndexRoute
'/_auth/profile/change-password/': typeof AuthProfileChangePasswordIndexRoute
'/_auth/role/create/': typeof AuthRoleCreateIndexRoute
@ -271,7 +271,7 @@ export interface FileRouteTypes {
| '/role'
| '/rooms'
| '/user'
| '/oauth/callback'
| '/sso/callback'
| '/profile/$userName'
| '/profile/change-password'
| '/role/create'
@ -298,7 +298,7 @@ export interface FileRouteTypes {
| '/role'
| '/rooms'
| '/user'
| '/oauth/callback'
| '/sso/callback'
| '/profile/$userName'
| '/profile/change-password'
| '/role/create'
@ -326,7 +326,7 @@ export interface FileRouteTypes {
| '/_auth/role/'
| '/_auth/rooms/'
| '/_auth/user/'
| '/(auth)/oauth/callback/'
| '/(auth)/sso/callback/'
| '/_auth/profile/$userName/'
| '/_auth/profile/change-password/'
| '/_auth/role/create/'
@ -344,7 +344,7 @@ export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
AuthRoute: typeof AuthRouteWithChildren
authLoginIndexRoute: typeof authLoginIndexRoute
authOauthCallbackIndexRoute: typeof authOauthCallbackIndexRoute
authSsoCallbackIndexRoute: typeof authSsoCallbackIndexRoute
}
declare module '@tanstack/react-router' {
@ -482,11 +482,11 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthProfileUserNameIndexRouteImport
parentRoute: typeof AuthRoute
}
'/(auth)/oauth/callback/': {
id: '/(auth)/oauth/callback/'
path: '/oauth/callback'
fullPath: '/oauth/callback'
preLoaderRoute: typeof authOauthCallbackIndexRouteImport
'/(auth)/sso/callback/': {
id: '/(auth)/sso/callback/'
path: '/sso/callback'
fullPath: '/sso/callback'
preLoaderRoute: typeof authSsoCallbackIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/_auth/user/role/$roleId/': {
@ -592,7 +592,7 @@ const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
AuthRoute: AuthRouteWithChildren,
authLoginIndexRoute: authLoginIndexRoute,
authOauthCallbackIndexRoute: authOauthCallbackIndexRoute,
authSsoCallbackIndexRoute: authSsoCallbackIndexRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)

View File

@ -1,63 +1,36 @@
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
import { useEffect, useState } from "react";
import { useExchangeSsoCode } from "@/hooks/queries";
import { useAuth } from "@/hooks/useAuth";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { LoaderCircle } from "lucide-react";
import axios from "axios";
import { exchangeCodeByProvider } from "@/services/auth.service";
import type { LoginResponse } from "@/types/auth";
const inFlightExchanges = new Map<string, Promise<LoginResponse>>();
const consumedCodes = new Set<string>();
export const Route = createFileRoute("/(auth)/oauth/callback/")({
component: OAuthCallbackPage,
export const Route = createFileRoute("/(auth)/sso/callback/")({
component: SsoCallbackPage,
});
function OAuthCallbackPage() {
function SsoCallbackPage() {
const auth = useAuth();
const navigate = useNavigate();
const search = Route.useSearch() as { code?: string; redirect?: string; provider?: string };
const exchangeMutation = useExchangeSsoCode();
const search = Route.useSearch() as { code?: string; redirect?: string };
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [isExchanging, setIsExchanging] = useState(false);
useEffect(() => {
const code = search.code;
const provider = (search.provider || "").toLowerCase();
if (!code) {
setErrorMessage("OAuth code is missing.");
return;
}
const exchangeId = `${provider || "auto"}:${code}`;
if (consumedCodes.has(exchangeId)) {
setErrorMessage("Mã đăng nhập đã được sử dụng. Vui lòng đăng nhập lại.");
if (!search.code) {
setErrorMessage("SSO code is missing.");
return;
}
setErrorMessage(null);
setIsExchanging(true);
let cancelled = false;
let exchangePromise = inFlightExchanges.get(exchangeId);
if (!exchangePromise) {
exchangePromise = exchangeCodeByProvider(code, provider);
inFlightExchanges.set(exchangeId, exchangePromise);
}
exchangePromise
.then(async (data) => {
if (cancelled) return;
exchangeMutation.mutate(search.code, {
onSuccess: async (data) => {
if (!data.token) {
setErrorMessage("OAuth response missing token.");
setErrorMessage("SSO response missing token.");
return;
}
consumedCodes.add(exchangeId);
localStorage.setItem("token", data.token);
localStorage.setItem("username", data.username || "");
localStorage.setItem("name", data.name || "");
@ -72,38 +45,22 @@ function OAuthCallbackPage() {
auth.login(data.username || "");
await navigate({ to: search.redirect || "/dashboard" });
})
.catch((error) => {
if (cancelled) return;
if (axios.isAxiosError(error) && error.response?.status === 401) {
consumedCodes.add(exchangeId);
setErrorMessage("Mã đăng nhập đã hết hạn hoặc đã được sử dụng. Vui lòng đăng nhập lại.");
return;
}
setErrorMessage("OAuth exchange failed.");
})
.finally(() => {
inFlightExchanges.delete(exchangeId);
if (!cancelled) {
setIsExchanging(false);
}
});
return () => {
cancelled = true;
};
}, [auth, navigate, search.code, search.provider, search.redirect]);
},
onError: () => {
setErrorMessage("SSO exchange failed.");
},
});
}, [auth, exchangeMutation, navigate, search.code, search.redirect]);
return (
<div className="flex items-center justify-center min-h-screen bg-gradient-to-br from-background to-muted/20">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<CardTitle className="text-xl">Đang xác thực OAuth</CardTitle>
<CardTitle className="text-xl">Đang xác thực SSO</CardTitle>
<CardDescription>Vui lòng đi trong giây lát.</CardDescription>
</CardHeader>
<CardContent className="flex flex-col items-center gap-4">
{isExchanging && (
{exchangeMutation.isPending && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<LoaderCircle className="w-4 h-4 animate-spin" />
Đang trao đi đăng nhập

View File

@ -9,7 +9,6 @@ import {
useDeleteRequiredFile,
useInstallMsi,
useDownloadFiles,
useSendManifest,
} from "@/hooks/queries";
import { toast } from "sonner";
import type { AxiosProgressEvent } from "axios";
@ -50,8 +49,6 @@ function AppsComponent() {
const deleteRequiredFileMutation = useDeleteRequiredFile();
const sendManifestMutation = useSendManifest();
const columns = useMemo(
() => createAppsColumns(installMutation.isPending),
[installMutation.isPending]
@ -191,21 +188,6 @@ function AppsComponent() {
}
};
const handleSendManifest = async (targets: string[]) => {
// targets ignored for now — API sendall broadcasts to all devices via MQTT
// TODO: use targets when per-room/per-device manifest API is available
try {
await sendManifestMutation.mutateAsync();
toast.success(
targets.length > 0
? `Đã gửi manifest cho ${targets.length} mục!`
: "Đã gửi manifest đến tất cả thiết bị!"
);
} catch (e) {
toast.error("Gửi manifest thất bại!");
}
};
const handleAddToRequired = async () => {
if (!table) {
toast.error("Không thể lấy thông tin bảng!");
@ -250,8 +232,6 @@ function AppsComponent() {
onDeleteFromServer={handleDeleteFromServer}
onDeleteFromRequired={handleDeleteFromRequiredList}
onAddToRequired={handleAddToRequired}
onSendManifest={handleSendManifest}
sendManifestLoading={sendManifestMutation.isPending}
updateLoading={installMutation.isPending}
downloadLoading={downloadMutation.isPending}
deleteLoading={deleteMutation.isPending || deleteRequiredFileMutation.isPending}

View File

@ -1,9 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import { useState } from "react";
import {
CommandSubmitTemplate,
type SendCommandOptions,
} from "@/template/command-submit-template";
import { CommandSubmitTemplate } from "@/template/command-submit-template";
import { CommandRegistryForm, type CommandRegistryFormData } from "@/components/forms/command-registry-form";
import {
useGetCommandList,
@ -70,18 +67,6 @@ function CommandPage() {
const deleteCommandMutation = useDeleteCommand();
const sendCommandMutation = useSendCommand();
const formInitialData = selectedCommand
? {
commandName: selectedCommand.commandName,
commandType: selectedCommand.commandType,
description: selectedCommand.description,
commandContent: selectedCommand.commandContent,
qos: selectedCommand.qoS,
isRetained: selectedCommand.isRetained,
ttlMinutes: selectedCommand.ttlMinutes ?? -1,
}
: undefined;
// Columns for command table
const columns: ColumnDef<CommandRegistry>[] = [
{
@ -107,7 +92,6 @@ function CommandPage() {
2: "SHUTDOWN",
3: "TASKKILL",
4: "BLOCK",
5: "RESET",
};
return <span>{typeMap[type] || "UNKNOWN"}</span>;
},
@ -241,10 +225,7 @@ function CommandPage() {
};
// Handle execute commands from list
const handleExecuteSelected = async (
targets: string[],
options?: SendCommandOptions
) => {
const handleExecuteSelected = async (targets: string[]) => {
if (!table) {
toast.error("Không thể lấy thông tin bảng!");
return;
@ -264,8 +245,6 @@ function CommandPage() {
Command: row.original.commandContent,
QoS: row.original.qoS,
IsRetained: row.original.isRetained,
TtlMinutes: options?.ttlMinutes,
SendTime: options?.sendTime,
};
await sendCommandMutation.mutateAsync({
@ -284,11 +263,7 @@ function CommandPage() {
};
// Handle execute custom command
const handleExecuteCustom = async (
targets: string[],
commandData: ShellCommandData,
options?: SendCommandOptions
) => {
const handleExecuteCustom = async (targets: string[], commandData: ShellCommandData) => {
try {
for (const target of targets) {
// API expects PascalCase directly
@ -296,8 +271,6 @@ function CommandPage() {
Command: commandData.command,
QoS: commandData.qos,
IsRetained: commandData.isRetained,
TtlMinutes: options?.ttlMinutes,
SendTime: options?.sendTime,
};
await sendCommandMutation.mutateAsync({
roomName: target,
@ -330,7 +303,7 @@ function CommandPage() {
<CommandRegistryForm
onSubmit={handleFormSubmit}
closeDialog={() => setIsDialogOpen(false)}
initialData={formInitialData}
initialData={selectedCommand || undefined}
title={selectedCommand ? "Sửa Lệnh" : "Thêm Lệnh Mới"}
/>
}

View File

@ -6,6 +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 { BASE_URL } from "@/config/api";
export const Route = createFileRoute("/_auth/remote-control/")({
@ -34,8 +35,15 @@ function RemoteControlPage() {
onSuccess: (data) => {
setErrorMessage(null);
console.log("[RemoteControl] URL:", data.url);
setProxyUrl(data.url);
// Chuyển URL MeshCentral thành proxy URL
const originalUrl = new URL(data.url);
const pathAndQuery = originalUrl.pathname + originalUrl.search;
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);
setShowRemote(true);
},
onError: (error: any) => {

View File

@ -5,7 +5,7 @@ import {
} from "@tanstack/react-router";
import { useMemo } from "react";
import { useGetClientFolderStatus } from "@/hooks/queries";
import type { ClientFolderStatus, ExtraFile, MissingFile } from "@/types/folder";
import type { ClientFolderStatus } from "@/types/folder";
import FolderStatusTemplate from "@/template/folder-status-template";
import {
createColumnHelper,
@ -36,46 +36,8 @@ 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 renderFileList = (files?: MissingFile[] | ExtraFile[]) => {
if (!files || files.length === 0) {
return <span className="text-muted-foreground">-</span>;
}
return (
<div className="max-h-32 overflow-auto space-y-1">
{files.map((file) => (
<div key={`${file.folderPath}/${file.fileName}`} className="text-xs">
<div className="font-mono break-all">{file.fileName}</div>
<div className="text-muted-foreground break-all">
{file.folderPath}
</div>
</div>
))}
</div>
);
};
const columns = useMemo(
() => [
columnHelper.accessor("deviceId", {
@ -84,13 +46,14 @@ function RouteComponent() {
}),
columnHelper.display({
id: "missing",
header: "File thiếu",
cell: (info) => renderFileList(info.row.original.missingFiles),
header: "Số lượng file thiếu",
cell: (info) =>
(info.row.original.missingFiles?.length ?? 0).toString(),
}),
columnHelper.display({
id: "extra",
header: "File thừa",
cell: (info) => renderFileList(info.row.original.extraFiles),
header: "Số lượng file thừa",
cell: (info) => (info.row.original.extraFiles?.length ?? 0).toString(),
}),
columnHelper.display({
id: "current",
@ -117,7 +80,7 @@ function RouteComponent() {
);
const table = useReactTable({
data: sortedFolderStatusList,
data: folderStatusList ?? [],
columns,
getCoreRowModel: getCoreRowModel(),
});
@ -125,7 +88,7 @@ function RouteComponent() {
return (
<FolderStatusTemplate
roomName={roomName as string}
data={sortedFolderStatusList}
data={folderStatusList}
isLoading={isLoading}
onBack={() =>
navigate({ to: "/rooms/$roomName/", params: { roomName } } as any)

View File

@ -1,23 +1,14 @@
import { createFileRoute, useParams, useNavigate } from "@tanstack/react-router";
import { useEffect, useMemo, useState, type MouseEvent } from "react";
import { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { LayoutGrid, TableIcon, Monitor, FolderCheck } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useGetDeviceFromRoom, useGetRoomList } from "@/hooks/queries";
import { useGetDeviceFromRoom } from "@/hooks/queries";
import { useDeviceEvents } from "@/hooks/useDeviceEvents";
import { DeviceGrid } from "@/components/grids/device-grid";
import { DeviceGridCompact } from "@/components/grids/device-grid-compact";
import { DeviceTable } from "@/components/tables/device-table";
import { useMachineNumber } from "@/hooks/useMachineNumber";
import { CommandActionButtons } from "@/components/buttons/command-action-buttons";
import { DeviceActionBar } from "@/components/bars/device-action-bar";
export const Route = createFileRoute("/_auth/rooms/$roomName/")({
head: ({ params }) => ({
@ -34,174 +25,69 @@ export const Route = createFileRoute("/_auth/rooms/$roomName/")({
function RoomDetailPage() {
const { roomName } = useParams({ from: "/_auth/rooms/$roomName/" });
const [viewMode, setViewMode] = useState<"grid" | "table" | "map">("map");
const [statusFilter, setStatusFilter] = useState<"all" | "on" | "off">("all");
const [searchInput, setSearchInput] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [lastSelectedIndex, setLastSelectedIndex] = useState<number | null>(null);
const [viewMode, setViewMode] = useState<"grid" | "table">("grid");
// SSE real-time updates
useDeviceEvents(roomName);
// Folder status from SS
const { data: devices = [] } = useGetDeviceFromRoom(roomName);
const { data: roomData = [] } = useGetRoomList();
const parseMachineNumber = useMachineNumber();
const navigate = useNavigate();
const sortedDevices = useMemo(() => {
return [...devices].sort((a, b) => {
return parseMachineNumber(a.id) - parseMachineNumber(b.id);
});
}, [devices, parseMachineNumber]);
const currentRoom = roomData.find((room) => room.name === roomName);
const totalSeats = currentRoom?.numberOfDevices;
const deviceCount = sortedDevices.length;
const offlineCount = sortedDevices.filter((device) => device.isOffline).length;
const onlineCount = Math.max(0, deviceCount - offlineCount);
const mapDisabled = deviceCount > 200;
const forceTable = deviceCount > 2000;
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedSearch(searchInput.trim());
}, 300);
return () => clearTimeout(handler);
}, [searchInput]);
useEffect(() => {
setSelectedIds([]);
setLastSelectedIndex(null);
}, [roomName]);
useEffect(() => {
if (forceTable && viewMode !== "table") {
setViewMode("table");
}
}, [forceTable, viewMode]);
useEffect(() => {
if (mapDisabled && viewMode === "map") {
setViewMode("grid");
}
}, [mapDisabled, viewMode]);
const filteredDevices = useMemo(() => {
const query = debouncedSearch.toLowerCase();
return sortedDevices.filter((device) => {
if (statusFilter === "on" && device.isOffline) return false;
if (statusFilter === "off" && !device.isOffline) return false;
if (!query) return true;
const ipAddress = device.networkInfos?.[0]?.ipAddress ?? "";
const macAddress = device.networkInfos?.[0]?.macAddress ?? "";
const id = device.id ?? "";
const haystack = `${id} ${ipAddress} ${macAddress}`.toLowerCase();
return haystack.includes(query);
});
}, [sortedDevices, statusFilter, debouncedSearch]);
const filteredDeviceIds = useMemo(
() => filteredDevices.map((device) => device.id),
[filteredDevices]
);
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]);
const selectedDevices = useMemo(
() => devices.filter((device) => selectedSet.has(device.id)),
[devices, selectedSet]
);
useEffect(() => {
setSelectedIds((prev) => prev.filter((id) => devices.some((d) => d.id === id)));
}, [devices]);
useEffect(() => {
if (lastSelectedIndex !== null && lastSelectedIndex >= filteredDeviceIds.length) {
setLastSelectedIndex(null);
}
}, [filteredDeviceIds.length, lastSelectedIndex]);
const handleSelectDevice = (
deviceId: string,
index: number,
event: MouseEvent<HTMLElement>
) => {
const isShift = event.shiftKey;
setSelectedIds((prev) => {
const next = new Set(prev);
if (isShift && lastSelectedIndex !== null) {
const start = Math.min(lastSelectedIndex, index);
const end = Math.max(lastSelectedIndex, index);
const rangeIds = filteredDeviceIds.slice(start, end + 1);
rangeIds.forEach((id) => next.add(id));
return Array.from(next);
}
if (next.has(deviceId)) {
next.delete(deviceId);
} else {
next.add(deviceId);
}
return Array.from(next);
});
setLastSelectedIndex(index);
};
const handleToggleAll = (checked: boolean) => {
setSelectedIds(checked ? filteredDeviceIds : []);
setLastSelectedIndex(null);
};
const handleClearSelection = () => {
setSelectedIds([]);
setLastSelectedIndex(null);
};
const chipOptions = [
{ key: "all" as const, label: `Tất cả (${deviceCount})` },
{ key: "on" as const, label: `On (${onlineCount})` },
{ key: "off" as const, label: `Off (${offlineCount})` },
];
const sortedDevices = [...devices].sort((a, b) => {
return parseMachineNumber(a.id) - parseMachineNumber(b.id);
});
return (
<div className="w-full px-6">
<div className="space-y-6">
<Card className="shadow-sm">
<CardHeader className="bg-muted/50 space-y-3 pb-3">
{/* Row 1: Title + stats */}
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
<div className="flex items-center gap-2 shrink-0">
<Monitor className="h-5 w-5" />
<CardTitle>Phòng {roomName}</CardTitle>
</div>
<div className="w-full px-6 space-y-6">
<Card className="shadow-sm">
<CardHeader className="bg-muted/50 space-y-4">
{/* Hàng 1: Thông tin phòng và controls */}
<div className="flex items-center justify-between w-full gap-4">
<div className="flex items-center gap-2">
<Monitor className="h-5 w-5" />
<CardTitle>Danh sách thiết bị phòng {roomName}</CardTitle>
</div>
<div className="flex items-center gap-1.5">
<Badge variant="outline" className="text-[11px] text-teal-700 border-teal-200 bg-teal-50">
On {onlineCount}
</Badge>
<Badge variant="outline" className="text-[11px] text-slate-600 border-slate-200 bg-slate-50">
Off {offlineCount}
</Badge>
<Badge variant="secondary" className="text-[11px]">
Tổng {deviceCount}
</Badge>
</div>
</div>
<div className="flex items-center gap-2 bg-background rounded-lg p-1 border shrink-0">
<Button
variant={viewMode === "grid" ? "default" : "ghost"}
size="sm"
onClick={() => setViewMode("grid")}
className="flex items-center gap-2"
>
<LayoutGrid className="h-4 w-4" />
đ
</Button>
<Button
variant={viewMode === "table" ? "default" : "ghost"}
size="sm"
onClick={() => setViewMode("table")}
className="flex items-center gap-2"
>
<TableIcon className="h-4 w-4" />
Bảng
</Button>
</div>
</div>
{/* Row 2: Command buttons + folder button cùng hàng */}
{/* Hàng 2: Thực thi lệnh */}
<div className="flex items-center justify-between w-full gap-4">
<div className="flex items-center gap-2 text-sm font-semibold">
Thực thi lệnh
</div>
<div className="flex items-center gap-3 justify-end">
{/* Command Action Buttons */}
{devices.length > 0 && (
<div className="flex flex-wrap items-center gap-2">
<>
<CommandActionButtons roomName={roomName} />
<div className="h-5 w-px bg-border shrink-0" />
<div className="h-8 w-px bg-border" />
<Button
onClick={() =>
navigate({
@ -216,158 +102,33 @@ function RoomDetailPage() {
<FolderCheck className="h-4 w-4" />
Kiểm tra thư mục Setup
</Button>
</div>
</>
)}
</div>
</div>
</CardHeader>
{/* Row 3: View toggle + search + filter */}
<div className="flex flex-wrap items-center gap-2">
{/* View mode */}
<div className="flex items-center gap-1 rounded-lg border bg-background p-0.5">
<Button
variant={viewMode === "grid" ? "default" : "ghost"}
size="sm"
onClick={() => setViewMode("grid")}
className="h-7 gap-1.5 px-2.5 text-xs"
disabled={forceTable}
>
<LayoutGrid className="h-3.5 w-3.5" />
Lưới
</Button>
<CardContent className="p-0">
{devices.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12">
<Monitor className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2">Không thiết bị</h3>
<p className="text-muted-foreground text-center max-w-sm">
Phòng này chưa thiết bị nào đưc kết nối.
</p>
</div>
) : viewMode === "grid" ? (
<DeviceGrid
devices={sortedDevices}
/>
) : (
<DeviceTable
devices={sortedDevices}
/>
)}
</CardContent>
</Card>
<Button
variant={viewMode === "table" ? "default" : "ghost"}
size="sm"
onClick={() => setViewMode("table")}
className="h-7 gap-1.5 px-2.5 text-xs"
>
<TableIcon className="h-3.5 w-3.5" />
Bảng
</Button>
{mapDisabled || forceTable ? (
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
variant="ghost"
size="sm"
className="h-7 gap-1.5 px-2.5 text-xs"
disabled
>
<Monitor className="h-3.5 w-3.5" />
đ
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
đ chỉ hỗ trợ phòng &lt;= 200 thiết bị.
</TooltipContent>
</Tooltip>
) : (
<Button
variant={viewMode === "map" ? "default" : "ghost"}
size="sm"
onClick={() => setViewMode("map")}
className="h-7 gap-1.5 px-2.5 text-xs"
>
<Monitor className="h-3.5 w-3.5" />
đ
</Button>
)}
</div>
{/* Search */}
<Input
value={searchInput}
onChange={(event) => setSearchInput(event.target.value)}
placeholder="Tìm theo số máy, IP hoặc mã thiết bị"
className="h-8 w-56 shrink-0"
/>
{/* Status filter */}
<div className="flex items-center gap-1 ml-auto">
{chipOptions.map((chip) => (
<Button
key={chip.key}
variant={statusFilter === chip.key ? "default" : "outline"}
size="sm"
className="h-8"
onClick={() => setStatusFilter(chip.key)}
>
{chip.label}
</Button>
))}
</div>
</div>
</CardHeader>
<CardContent className="p-0">
{devices.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12">
<Monitor className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2">Không thiết bị</h3>
<p className="text-muted-foreground text-center max-w-sm">
Phòng này chưa thiết bị nào đưc kết nối.
</p>
</div>
) : (
<div className="space-y-4 p-4">
{forceTable && (
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900">
Phòng này {deviceCount} thiết bị. Chỉ hiển thị chế đ
Bảng đ đm bảo hiệu năng.
</div>
)}
{!forceTable && viewMode === "grid" && deviceCount > 500 && (
<div className="rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-700">
Phòng này nhiều thiết bị. Bạn thể chuyển sang chế đ
Bảng đ thao tác nhanh hơn.
</div>
)}
{filteredDevices.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12">
<Monitor className="h-10 w-10 text-muted-foreground mb-3" />
<h3 className="text-base font-semibold mb-1">
Không thiết bị phù hợp
</h3>
<p className="text-muted-foreground text-center max-w-sm">
Hãy thử thay đi từ khóa hoặc bộ lọc trạng thái.
</p>
</div>
) : forceTable || viewMode === "table" ? (
<DeviceTable
devices={filteredDevices}
selectedIds={selectedIds}
onToggleDevice={handleSelectDevice}
onToggleAll={handleToggleAll}
/>
) : viewMode === "map" ? (
<DeviceGrid
devices={filteredDevices}
totalSeats={totalSeats}
selectedIds={selectedIds}
onSelectDevice={handleSelectDevice}
/>
) : (
<DeviceGridCompact
devices={filteredDevices}
selectedIds={selectedIds}
onSelectDevice={handleSelectDevice}
/>
)}
</div>
)}
</CardContent>
</Card>
<DeviceActionBar
roomName={roomName}
selectedDevices={selectedDevices}
onClearSelection={handleClearSelection}
/>
</div>
</div>
);
}

View File

@ -129,11 +129,3 @@ export async function deleteFile(data: { MsiFileIds: number[] }): Promise<{ mess
);
return response.data;
}
/**
* Gửi manifest (build + publish MQTT tới tất cả required files)
*/
export async function sendManifest(): Promise<{ status: string; message: string }> {
const response = await axios.post(API_ENDPOINTS.MANIFEST.SEND_ALL);
return response.data;
}

View File

@ -1,6 +1,5 @@
import axios from "@/config/axios";
import { API_ENDPOINTS } from "@/config/api";
import rawAxios from "axios";
import type { LoginResquest, LoginResponse, CreateAccountRequest } from "@/types/auth";
/**
@ -17,93 +16,25 @@ export async function login(credentials: LoginResquest): Promise<LoginResponse>
}
/**
* Build OAuth login URL by provider
* @param provider - OAuth provider key (e.g. google, azuread)
* Build SSO login URL
* @param returnUrl - FE callback url
*/
export function buildOAuthLoginUrl(provider: string, returnUrl: string): string {
const base = API_ENDPOINTS.AUTH.OAUTH_LOGIN(provider);
const encoded = encodeURIComponent(returnUrl);
return `${base}?returnUrl=${encoded}`;
}
/**
* Build Google OAuth login URL
* @param returnUrl - FE callback url
*/
export function buildGoogleOAuthLoginUrl(returnUrl: string): string {
return buildOAuthLoginUrl("google", returnUrl);
}
/**
* Build Microsoft SSO login URL (legacy endpoint)
* @param returnUrl - FE callback url
*/
export function buildMicrosoftSsoLoginUrl(returnUrl: string): string {
export function buildSsoLoginUrl(returnUrl: string): string {
const base = API_ENDPOINTS.AUTH.SSO_LOGIN;
const encoded = encodeURIComponent(returnUrl);
return `${base}?returnUrl=${encoded}`;
}
/**
* Exchange one-time OAuth code for login payload
* Exchange one-time code for login payload
* @param code - one-time code
*/
export async function exchangeOAuthCode(code: string): Promise<LoginResponse> {
try {
const response = await rawAxios.post<LoginResponse>(
API_ENDPOINTS.AUTH.OAUTH_EXCHANGE,
{ code }
);
return response.data;
} catch (error) {
if (rawAxios.isAxiosError(error)) {
const status = error.response?.status;
if (status === 401 || status === 404 || status === 405) {
const fallbackResponse = await rawAxios.post<LoginResponse>(
API_ENDPOINTS.AUTH.SSO_EXCHANGE,
{ code }
);
return fallbackResponse.data;
}
}
throw error;
}
}
/**
* Legacy AzureAD SSO URL builder kept for backward compatibility.
*/
export function buildSsoLoginUrl(returnUrl: string): string {
return buildOAuthLoginUrl("azuread", returnUrl);
}
/**
* Legacy SSO exchange alias kept for backward compatibility.
*/
export async function exchangeSsoCode(code: string): Promise<LoginResponse> {
return exchangeOAuthCode(code);
}
/**
* Exchange one-time code by provider without breaking existing flows.
* - azuread/microsoft: force legacy SSO exchange endpoint
* - default: use OAuth exchange flow
*/
export async function exchangeCodeByProvider(
code: string,
provider?: string
): Promise<LoginResponse> {
const providerKey = (provider || "").toLowerCase();
if (providerKey === "microsoft" || providerKey === "azuread") {
const response = await rawAxios.post<LoginResponse>(
API_ENDPOINTS.AUTH.SSO_EXCHANGE,
{ code }
);
return response.data;
}
return exchangeOAuthCode(code);
const response = await axios.post<LoginResponse>(
API_ENDPOINTS.AUTH.SSO_EXCHANGE,
{ code }
);
return response.data;
}
/**

View File

@ -1,7 +1,6 @@
import axios from "@/config/axios";
import { API_ENDPOINTS } from "@/config/api";
import type { DeviceHealthCheck } from "@/types/device";
import type { Room } from "@/types/room";
/**
* Lấy tất cả thiết bị trong hệ thống
@ -14,8 +13,8 @@ export async function getAllDevices(): Promise<any[]> {
/**
* Lấy danh sách phòng
*/
export async function getRoomList(): Promise<Room[]> {
const response = await axios.get<Room[]>(API_ENDPOINTS.DEVICE_COMM.GET_ROOM_LIST);
export async function getRoomList(): Promise<any[]> {
const response = await axios.get<any[]>(API_ENDPOINTS.DEVICE_COMM.GET_ROOM_LIST);
return response.data;
}

View File

@ -14,7 +14,7 @@ import { RequestUpdateMenu } from "@/components/menu/request-update-menu";
import { DeleteMenu } from "@/components/menu/delete-menu";
import { Button } from "@/components/ui/button";
import type { AxiosProgressEvent } from "axios";
import { useMemo, useState } from "react";
import { useState } from "react";
import { SelectDialog } from "@/components/dialogs/select-dialog";
import { DeviceSearchDialog } from "@/components/bars/device-searchbar";
import { UploadVersionForm } from "@/components/forms/upload-file-form";
@ -43,8 +43,6 @@ interface AppManagerTemplateProps<TData> {
deleteLoading?: boolean;
onAddToRequired?: () => Promise<void> | void;
addToRequiredLoading?: boolean;
onSendManifest?: (targetNames: string[]) => Promise<void> | void;
sendManifestLoading?: boolean;
onTableInit?: (table: any) => void;
rooms?: Room[];
devices?: string[];
@ -72,8 +70,6 @@ export function AppManagerTemplate<TData>({
deleteLoading,
onAddToRequired,
addToRequiredLoading,
onSendManifest,
sendManifestLoading,
onTableInit,
rooms = [],
devices = [],
@ -82,23 +78,7 @@ export function AppManagerTemplate<TData>({
pageSizeOptions = [5, 10, 15, 20],
}: AppManagerTemplateProps<TData>) {
const [dialogOpen, setDialogOpen] = useState(false);
const [dialogType, setDialogType] = useState<"room" | "device" | "download-room" | "download-device" | "manifest-room" | "manifest-device" | null>(null);
const sortedData = useMemo(() => {
const firstItem = data?.[0] as { fileName?: string } | undefined;
if (!firstItem || typeof firstItem.fileName !== "string") {
return data;
}
return [...data].sort((a, b) => {
const aName = (a as { fileName?: string }).fileName ?? "";
const bName = (b as { fileName?: string }).fileName ?? "";
return aName.localeCompare(bName, "vi", {
numeric: true,
sensitivity: "base",
});
});
}, [data]);
const [dialogType, setDialogType] = useState<"room" | "device" | "download-room" | "download-device" | null>(null);
const openRoomDialog = () => {
if (rooms.length > 0 && onUpdate) {
@ -128,26 +108,6 @@ export function AppManagerTemplate<TData>({
}
};
const openManifestRoomDialog = () => {
if (rooms.length > 0 && onSendManifest) {
setDialogType("manifest-room");
setDialogOpen(true);
}
};
const openManifestDeviceDialog = () => {
if (onSendManifest) {
setDialogType("manifest-device");
setDialogOpen(true);
}
};
const handleManifestAll = async () => {
if (!onSendManifest) return;
await onSendManifest([]);
};
const handleUpdateAll = async () => {
if (!onUpdate) return;
try {
@ -189,7 +149,7 @@ export function AppManagerTemplate<TData>({
<CardContent>
<VersionTable
data={sortedData}
data={data}
isLoading={isLoading}
columns={columns}
onTableInit={onTableInit}
@ -241,18 +201,6 @@ export function AppManagerTemplate<TData>({
{addToRequiredLoading ? "Đang thêm..." : "Thêm vào danh sách"}
</Button>
)}
{onSendManifest && (
<RequestUpdateMenu
onUpdateAll={handleManifestAll}
onUpdateRoom={openManifestRoomDialog}
onUpdateDevice={openManifestDeviceDialog}
loading={sendManifestLoading}
label="Gửi Manifest"
allLabel="Gửi tất cả"
roomLabel="Gửi theo phòng"
deviceLabel="Gửi theo thiết bị"
/>
)}
</div>
{onDeleteFromServer && onDeleteFromRequired && (
<DeleteMenu
@ -351,8 +299,7 @@ export function AppManagerTemplate<TData>({
/>
)}
{/* Dialog tải file - tìm thiết bị */}
{/* Dialog tải file - tìm thiết bị */}
{dialogType === "download-device" && (
<DeviceSearchDialog
open={dialogOpen && dialogType === "download-device"}
@ -381,60 +328,6 @@ export function AppManagerTemplate<TData>({
}}
/>
)}
{/* Dialog gửi manifest - chọn phòng */}
{dialogType === "manifest-room" && (
<SelectDialog
open={dialogOpen}
onClose={() => {
setDialogOpen(false);
setDialogType(null);
}}
title="Chọn phòng"
description="Chọn các phòng để gửi manifest"
icon={<Building2 className="w-6 h-6 text-primary" />}
items={mapRoomsToSelectItems(rooms)}
onConfirm={async (selectedItems) => {
if (!onSendManifest) return;
try {
await onSendManifest(selectedItems);
} catch (e) {
console.error("Send manifest error:", e);
} finally {
setDialogOpen(false);
setDialogType(null);
}
}}
/>
)}
{/* Dialog gửi manifest - chọn thiết bị */}
{dialogType === "manifest-device" && (
<DeviceSearchDialog
open={dialogOpen && dialogType === "manifest-device"}
onClose={() => {
setDialogOpen(false);
setDialogType(null);
}}
rooms={rooms}
fetchDevices={getDeviceFromRoom}
onSelect={async (deviceIds) => {
if (!onSendManifest) {
setDialogOpen(false);
setDialogType(null);
return;
}
try {
await onSendManifest(deviceIds);
} catch (e) {
console.error("Send manifest error:", e);
} finally {
setDialogOpen(false);
setDialogType(null);
}
}}
/>
)}
</div>
);
}

View File

@ -11,13 +11,9 @@ import { Plus, CommandIcon, Zap, Building2 } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import type { ColumnDef } from "@tanstack/react-table";
import { VersionTable } from "@/components/tables/version-table";
import {
@ -32,11 +28,6 @@ import { getDeviceFromRoom } from "@/services/device-comm.service";
import type { Room } from "@/types/room";
import { toast } from "sonner";
export interface SendCommandOptions {
ttlMinutes?: number;
sendTime?: Date;
}
interface CommandSubmitTemplateProps<T extends { id: number }> {
title: string;
description: string;
@ -60,15 +51,8 @@ interface CommandSubmitTemplateProps<T extends { id: number }> {
onTableInit?: (table: any) => void;
// Execute
onExecuteSelected?: (
targets: string[],
options?: SendCommandOptions
) => void | Promise<void>;
onExecuteCustom?: (
targets: string[],
commandData: ShellCommandData,
options?: SendCommandOptions
) => void | Promise<void>;
onExecuteSelected?: (targets: string[]) => void;
onExecuteCustom?: (targets: string[], commandData: ShellCommandData) => void;
isExecuting?: boolean;
// Execution scope
@ -129,158 +113,17 @@ export function CommandSubmitTemplate<T extends { id: number }>({
const [customCommand, setCustomCommand] = useState("");
const [customQoS, setCustomQoS] = useState<0 | 1 | 2>(0);
const [customRetained, setCustomRetained] = useState(false);
const [table, setTable] = useState<any>();
const [dialogOpen2, setDialogOpen2] = useState(false);
const [dialogType, setDialogType] = useState<
"room" | "device" | "room-custom" | "device-custom" | null
>(null);
const [confirmOpen, setConfirmOpen] = useState(false);
const [ttlMinutesInput, setTtlMinutesInput] = useState("");
const [sendTimeInput, setSendTimeInput] = useState("");
const [confirmError, setConfirmError] = useState<string | null>(null);
const [pendingAction, setPendingAction] = useState<
| { type: "selected"; targets: string[] }
| { type: "custom"; targets: string[]; commandData: ShellCommandData }
| null
>(null);
const handleTableInit = (t: any) => {
setTable(t);
onTableInit?.(t);
};
const closeTargetDialog = () => {
setDialogOpen2(false);
setDialogType(null);
};
const resetConfirmState = () => {
setConfirmOpen(false);
setPendingAction(null);
setTtlMinutesInput("");
setSendTimeInput("");
setConfirmError(null);
};
const formatLocalSendTime = (date: Date) => {
const pad2 = (value: number) => String(value).padStart(2, "0");
const hh = pad2(date.getHours());
const mm = pad2(date.getMinutes());
const ss = pad2(date.getSeconds());
const dd = pad2(date.getDate());
const MM = pad2(date.getMonth() + 1);
const yy = pad2(date.getFullYear() % 100);
return `${hh}:${mm}:${ss} ${dd}/${MM}/${yy}`;
};
const openConfirmForSelected = (targets: string[]) => {
setPendingAction({ type: "selected", targets });
setSendTimeInput(formatLocalSendTime(new Date()));
setConfirmOpen(true);
setConfirmError(null);
};
const openConfirmForCustom = (
targets: string[],
commandData: ShellCommandData
) => {
setPendingAction({ type: "custom", targets, commandData });
setSendTimeInput(formatLocalSendTime(new Date()));
setConfirmOpen(true);
setConfirmError(null);
};
const parseSendOptions = (): {
options?: SendCommandOptions;
error?: string;
} => {
const options: SendCommandOptions = {};
const ttlTrimmed = ttlMinutesInput.trim();
if (ttlTrimmed) {
const parsedTtl = Number(ttlTrimmed);
if (!Number.isInteger(parsedTtl) || parsedTtl < 0) {
return { error: "TtlMinutes phải là số nguyên >= 0." };
}
options.ttlMinutes = parsedTtl;
}
const sendTrimmed = sendTimeInput.trim();
if (sendTrimmed) {
const match =
/^(\d{2}):(\d{2}):(\d{2})\s+(\d{2})\/(\d{2})\/(\d{2})$/.exec(
sendTrimmed
);
if (!match) {
return { error: "SendTime không đúng định dạng HH:MM:SS DD/MM/YY." };
}
const [, hh, mm, ss, dd, MM, yy] = match;
const hour = Number(hh);
const minute = Number(mm);
const second = Number(ss);
const day = Number(dd);
const month = Number(MM);
const year = 2000 + Number(yy);
if (
hour > 23 ||
minute > 59 ||
second > 59 ||
month < 1 ||
month > 12 ||
day < 1 ||
day > 31
) {
return { error: "SendTime không hợp lệ." };
}
const date = new Date(year, month - 1, day, hour, minute, second);
if (
date.getFullYear() !== year ||
date.getMonth() !== month - 1 ||
date.getDate() !== day ||
date.getHours() !== hour ||
date.getMinutes() !== minute ||
date.getSeconds() !== second
) {
return { error: "SendTime không hợp lệ." };
}
options.sendTime = date;
}
return { options };
};
const handleConfirmSend = async () => {
if (!pendingAction) return;
const { options, error } = parseSendOptions();
if (error) {
setConfirmError(error);
toast.error(error);
return;
}
try {
if (pendingAction.type === "selected") {
await onExecuteSelected?.(pendingAction.targets, options);
} else {
await onExecuteCustom?.(
pendingAction.targets,
pendingAction.commandData,
options
);
setCustomCommand("");
setCustomQoS(0);
setCustomRetained(false);
}
} catch (e) {
console.error("Confirm send error:", e);
} finally {
resetConfirmState();
setTimeout(() => window.location.reload(), 500);
}
};
const openRoomDialog = () => {
if (rooms.length > 0 && onExecuteSelected) {
setDialogType("room");
@ -295,6 +138,21 @@ export function CommandSubmitTemplate<T extends { id: number }>({
}
};
const handleExecuteSelected = () => {
if (!table) {
toast.error("Không thể lấy thông tin bảng!");
return;
}
const selectedRows = table.getSelectedRowModel().rows;
if (selectedRows.length === 0) {
toast.error("Vui lòng chọn ít nhất một mục để thực thi!");
return;
}
onExecuteSelected?.([]);
};
const handleExecuteAll = () => {
if (!onExecuteSelected) return;
try {
@ -302,7 +160,7 @@ export function CommandSubmitTemplate<T extends { id: number }>({
typeof room === "string" ? room : room.name,
);
const allTargets = [...roomNames, ...devices];
openConfirmForSelected(allTargets);
onExecuteSelected(allTargets);
} catch (e) {
console.error("Execute error:", e);
}
@ -320,7 +178,14 @@ export function CommandSubmitTemplate<T extends { id: number }>({
isRetained: customRetained,
};
openConfirmForCustom(targets, shellCommandData);
try {
await onExecuteCustom?.(targets, shellCommandData);
setCustomCommand("");
setCustomQoS(0);
setCustomRetained(false);
} catch (e) {
console.error("Execute custom command error:", e);
}
};
const handleExecuteCustomAll = () => {
@ -478,7 +343,9 @@ export function CommandSubmitTemplate<T extends { id: number }>({
<SelectDialog
open={dialogOpen2}
onClose={() => {
closeTargetDialog();
setDialogOpen2(false);
setDialogType(null);
setTimeout(() => window.location.reload(), 500);
}}
title="Chọn phòng"
description="Chọn các phòng để thực thi lệnh"
@ -487,11 +354,13 @@ export function CommandSubmitTemplate<T extends { id: number }>({
onConfirm={async (selectedItems) => {
if (!onExecuteSelected) return;
try {
openConfirmForSelected(selectedItems);
await onExecuteSelected(selectedItems);
} catch (e) {
console.error("Execute error:", e);
} finally {
closeTargetDialog();
setDialogOpen2(false);
setDialogType(null);
setTimeout(() => window.location.reload(), 500);
}
}}
/>
@ -502,21 +371,27 @@ export function CommandSubmitTemplate<T extends { id: number }>({
<DeviceSearchDialog
open={dialogOpen2 && dialogType === "device"}
onClose={() => {
closeTargetDialog();
setDialogOpen2(false);
setDialogType(null);
setTimeout(() => window.location.reload(), 500);
}}
rooms={rooms}
fetchDevices={getDeviceFromRoom}
onSelect={async (deviceIds) => {
if (!onExecuteSelected) {
closeTargetDialog();
setDialogOpen2(false);
setDialogType(null);
setTimeout(() => window.location.reload(), 500);
return;
}
try {
openConfirmForSelected(deviceIds);
await onExecuteSelected(deviceIds);
} catch (e) {
console.error("Execute error:", e);
} finally {
closeTargetDialog();
setDialogOpen2(false);
setDialogType(null);
setTimeout(() => window.location.reload(), 500);
}
}}
/>
@ -527,7 +402,9 @@ export function CommandSubmitTemplate<T extends { id: number }>({
<SelectDialog
open={dialogOpen2}
onClose={() => {
closeTargetDialog();
setDialogOpen2(false);
setDialogType(null);
setTimeout(() => window.location.reload(), 500);
}}
title="Chọn phòng"
description="Chọn các phòng để thực thi lệnh tùy chỉnh"
@ -539,7 +416,9 @@ export function CommandSubmitTemplate<T extends { id: number }>({
} catch (e) {
console.error("Execute error:", e);
} finally {
closeTargetDialog();
setDialogOpen2(false);
setDialogType(null);
setTimeout(() => window.location.reload(), 500);
}
}}
/>
@ -550,7 +429,9 @@ export function CommandSubmitTemplate<T extends { id: number }>({
<DeviceSearchDialog
open={dialogOpen2 && dialogType === "device-custom"}
onClose={() => {
closeTargetDialog();
setDialogOpen2(false);
setDialogType(null);
setTimeout(() => window.location.reload(), 500);
}}
rooms={rooms}
fetchDevices={getDeviceFromRoom}
@ -560,67 +441,14 @@ export function CommandSubmitTemplate<T extends { id: number }>({
} catch (e) {
console.error("Execute error:", e);
} finally {
closeTargetDialog();
setDialogOpen2(false);
setDialogType(null);
setTimeout(() => window.location.reload(), 500);
}
}}
/>
)}
{/* Dialog xác nhận gửi lệnh */}
<Dialog
open={confirmOpen}
onOpenChange={(open) => {
if (!open) resetConfirmState();
}}
>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Xác nhận gửi lệnh</DialogTitle>
<DialogDescription>
Vui lòng xác nhận nhập thông tin bổ sung trước khi gửi.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="ttl-minutes">TtlMinutes (phút)</Label>
<Input
id="ttl-minutes"
type="number"
min={0}
placeholder="VD: 60"
value={ttlMinutesInput}
onChange={(e) => setTtlMinutesInput(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="send-time">SendTime</Label>
<Input
id="send-time"
placeholder="HH:MM:SS DD/MM/YY (VD: 14:30:00 25/05/26)"
value={sendTimeInput}
onChange={(e) => setSendTimeInput(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Đ trống nếu muốn gửi ngay.
</p>
</div>
{confirmError && (
<p className="text-sm text-red-600">{confirmError}</p>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={resetConfirmState}>
Hủy
</Button>
<Button onClick={handleConfirmSend}>Xác nhận gửi</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Dialog for add/edit */}
{formContent && (
<Dialog open={dialogOpen} onOpenChange={onDialogOpen}>

View File

@ -6,7 +6,6 @@ export interface CommandRegistry {
commandContent: string;
qoS: 0 | 1 | 2;
isRetained: boolean;
ttlMinutes: number;
createdAt?: string;
updatedAt?: string;
}

View File

@ -3,7 +3,6 @@ export type Version = {
version: string;
fileName: string;
folderPath: string;
syncFolder?: string;
updatedAt?: string;
requestUpdateAt?: string;
isRequired: boolean;

View File

@ -4,7 +4,7 @@ export type UserProfile = {
name: string;
role: string;
roleId: number;
accessRooms: string[];
accessRooms: number[];
createdAt?: string | null;
createdBy?: string | null;
updatedAt?: string | null;

View File

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