Compare commits
No commits in common. "main" and "httpsImplement" have entirely different histories.
main
...
httpsImple
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -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
1
.gitignore
vendored
|
|
@ -8,4 +8,3 @@ count.txt
|
|||
.nitro
|
||||
.tanstack
|
||||
.vscode/
|
||||
plans/
|
||||
|
|
@ -1,93 +0,0 @@
|
|||
# Frontend Guide - Microsoft SSO (Entra ID)
|
||||
|
||||
## 1) Muc tieu
|
||||
Tai lieu nay danh cho frontend de tich hop dang nhap Microsoft SSO voi backend TTMT.CompManageWeb.
|
||||
|
||||
## 2) Redirect URI da dang ky
|
||||
Redirect URI cho Microsoft app:
|
||||
|
||||
https://comp.soict.io/api/auth/sso/callback
|
||||
|
||||
Luu y:
|
||||
- Redirect URI trong Azure App Registration phai giong 100% (scheme, domain, path).
|
||||
- Sai ky tu hoac sai slash cuoi co the gay loi redirect_uri mismatch.
|
||||
|
||||
## 3) Endpoint frontend can goi
|
||||
Backend route SSO Microsoft (legacy route):
|
||||
|
||||
- GET /api/auth/sso/login?returnUrl={FRONTEND_RETURN_URL}
|
||||
- POST /api/auth/sso/exchange
|
||||
|
||||
Route alias cung ho tro:
|
||||
- GET /api/sso/login?returnUrl={FRONTEND_RETURN_URL}
|
||||
- POST /api/sso/exchange
|
||||
|
||||
## 4) Login flow cho frontend
|
||||
1. User bam nut "Login with Microsoft".
|
||||
2. Frontend redirect browser den:
|
||||
/api/auth/sso/login?returnUrl={FRONTEND_RETURN_URL}
|
||||
3. Backend redirect sang Microsoft login page.
|
||||
4. Sau khi user xac thuc thanh cong, Microsoft goi ve redirect URI:
|
||||
https://comp.soict.io/api/auth/sso/callback
|
||||
5. Backend tao one-time code va redirect user ve FRONTEND_RETURN_URL kem query:
|
||||
?code={ONE_TIME_CODE}
|
||||
6. Frontend doc code trong URL, goi API exchange de doi code lay JWT noi bo.
|
||||
7. Frontend luu token va thong tin user, dieu huong vao app.
|
||||
|
||||
## 5) API exchange chi tiet
|
||||
Method: POST
|
||||
Path: /api/auth/sso/exchange
|
||||
Content-Type: application/json
|
||||
|
||||
Request body:
|
||||
{
|
||||
"code": "<one-time-code>"
|
||||
}
|
||||
|
||||
Success response (HTTP 200):
|
||||
{
|
||||
"token": "<jwt>",
|
||||
"name": "Nguyen Van A",
|
||||
"username": "user@hust.edu.vn",
|
||||
"access": [1, 2, 3],
|
||||
"role": {
|
||||
"roleName": "Pending",
|
||||
"priority": 99
|
||||
}
|
||||
}
|
||||
|
||||
## 6) Error handling goi y cho frontend
|
||||
- 400 BadRequest: code thieu hoac token khong hop le.
|
||||
- 401 Unauthorized: code het han / da dung / khong hop le.
|
||||
- 404 NotFound: user khong ton tai sau khi exchange.
|
||||
|
||||
One-time code co han su dung ngan (khoang 2 phut) va chi dung 1 lan.
|
||||
Neu exchange that bai, can yeu cau user login lai.
|
||||
|
||||
## 7) Mau frontend pseudo-code
|
||||
const returnUrl = window.location.origin + "/sso/complete";
|
||||
window.location.href = `/api/auth/sso/login?returnUrl=${encodeURIComponent(returnUrl)}`;
|
||||
|
||||
// tai /sso/complete
|
||||
const code = new URLSearchParams(window.location.search).get("code");
|
||||
if (code) {
|
||||
const res = await fetch("/api/auth/sso/exchange", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ code })
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
// hien thi thong bao loi va cho user thu lai
|
||||
} else {
|
||||
const data = await res.json();
|
||||
// save data.token, data.username, data.role, data.access
|
||||
}
|
||||
}
|
||||
|
||||
## 8) Checklist truoc khi UAT
|
||||
- Redirect URI trong Azure dung: https://comp.soict.io/api/auth/sso/callback
|
||||
- Frontend returnUrl la URL FE hop le (vi du: https://comp.soict.io/sso/complete)
|
||||
- Frontend parse duoc query code
|
||||
- Frontend goi exchange ngay sau khi nhan code
|
||||
- Frontend xu ly day du HTTP 400/401/404
|
||||
|
|
@ -1,343 +0,0 @@
|
|||
# Google OAuth (OIDC) + AzureAD Shared SSO Service
|
||||
|
||||
Tai lieu nay tong hop toan bo flow dang nhap OAuth/OIDC theo muc coding cho codebase hien tai.
|
||||
|
||||
Pham vi:
|
||||
- Giu nguyen login username/password cu.
|
||||
- Dung chung 1 service cho AzureAD SSO va Google OAuth OIDC.
|
||||
- Van tao/cap nhat user noi bo trong DB.
|
||||
- User moi vao role Pending (khong co permission), doi admin cap quyen.
|
||||
|
||||
----------------------------------------
|
||||
|
||||
## 1) Trang thai implementation hien tai
|
||||
|
||||
Da hoan tat trong code:
|
||||
- Shared service theo provider key: SsoService.
|
||||
- Controller da provider: OAuthController.
|
||||
- Controller AzureAD cu (SsoController) da chay tren shared service.
|
||||
- Config da provider qua OAuthProviders trong appsettings.
|
||||
|
||||
File chinh:
|
||||
- TTMT.CompManageWeb/Program.cs
|
||||
- TTMT.CompManageWeb/Interfaces/ISsoService.cs
|
||||
- TTMT.CompManageWeb/Services/SsoSerivce.cs
|
||||
- TTMT.CompManageWeb/Controllers/OAuthController.cs
|
||||
- TTMT.CompManageWeb/Controllers/SsoController.cs
|
||||
- TTMT.CompManageWeb/Dtos/Auth/OAuthProviderOptions.cs
|
||||
- TTMT.CompManageWeb/Dtos/Auth/OAuthProvidersOptions.cs
|
||||
- TTMT.CompManageWeb/appsettings.json
|
||||
|
||||
----------------------------------------
|
||||
|
||||
## 2) Kien truc luong dang nhap
|
||||
|
||||
### 2.1 Luong OAuth Google
|
||||
|
||||
1. FE redirect user den:
|
||||
- GET /api/auth/oauth/google/login?returnUrl=<frontend-url>
|
||||
2. Backend build authorize URL va redirect qua Google.
|
||||
3. Google callback ve backend:
|
||||
- GET /api/auth/oauth/google/callback?code=...&state=...
|
||||
4. Backend:
|
||||
- Exchange code -> token endpoint.
|
||||
- Validate id_token.
|
||||
- Lay email/name.
|
||||
- Kiem tra domain.
|
||||
- Upsert user noi bo.
|
||||
- Issue JWT noi bo.
|
||||
- Tao one-time code (2 phut).
|
||||
- Redirect ve returnUrl?code=<one-time-code>.
|
||||
5. FE goi API exchange:
|
||||
- POST /api/auth/oauth/exchange
|
||||
- Body: { "code": "..." }
|
||||
6. Backend consume one-time code va tra payload login (token + role + permission).
|
||||
|
||||
### 2.2 Luong AzureAD cu
|
||||
|
||||
Van giu endpoint cu:
|
||||
- GET /api/auth/sso/login
|
||||
- GET /api/auth/sso/callback
|
||||
- POST /api/auth/sso/exchange
|
||||
|
||||
Nhung ben trong da dung chung service theo provider azuread.
|
||||
|
||||
----------------------------------------
|
||||
|
||||
## 3) Cau hinh bat buoc
|
||||
|
||||
### 3.1 appsettings.json
|
||||
|
||||
```json
|
||||
{
|
||||
"OAuthProviders": {
|
||||
"DefaultProvider": "azuread",
|
||||
"Providers": {
|
||||
"google": {
|
||||
"Authority": "https://accounts.google.com",
|
||||
"AuthorizationEndpoint": "https://accounts.google.com/o/oauth2/v2/auth",
|
||||
"TokenEndpoint": "https://oauth2.googleapis.com/token",
|
||||
"ClientId": "",
|
||||
"ClientSecret": "",
|
||||
"CallbackPath": "/api/auth/oauth/google/callback",
|
||||
"AllowedDomain": "",
|
||||
"PendingRoleName": "Pending",
|
||||
"Scopes": "openid profile email",
|
||||
"Issuer": "https://accounts.google.com",
|
||||
"EmailClaim": "email",
|
||||
"NameClaim": "name",
|
||||
"HostedDomainClaimName": "hd",
|
||||
"EnforceHostedDomainClaim": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Giai thich nhanh:
|
||||
- AllowedDomain:
|
||||
- Rong: cho phep moi domain.
|
||||
- Co gia tri (vd hust.edu.vn): chi cho email domain nay.
|
||||
- EnforceHostedDomainClaim=true:
|
||||
- Neu co claim hd trong token thi bat buoc phai khop AllowedDomain.
|
||||
- Neu token khong co hd, he thong fallback check theo email domain.
|
||||
- PendingRoleName: role duoc gan khi user moi dang nhap lan dau.
|
||||
|
||||
### 3.2 Google Cloud Console
|
||||
|
||||
1. Tao OAuth client (Web application).
|
||||
2. Authorized redirect URIs:
|
||||
- https://<your-domain>/api/auth/oauth/google/callback
|
||||
- (dev) https://localhost:<port>/api/auth/oauth/google/callback
|
||||
3. Lay ClientId, ClientSecret va dien vao appsettings (hoac secrets).
|
||||
|
||||
----------------------------------------
|
||||
|
||||
## 4) Dang ky DI va options
|
||||
|
||||
Trong Program.cs can co:
|
||||
|
||||
```csharp
|
||||
builder.Services.Configure<AzureAdOptions>(
|
||||
builder.Configuration.GetSection(AzureAdOptions.SectionName));
|
||||
|
||||
builder.Services.Configure<OAuthProvidersOptions>(
|
||||
builder.Configuration.GetSection(OAuthProvidersOptions.SectionName));
|
||||
|
||||
builder.Services.AddScoped<ISsoService, SsoService>();
|
||||
```
|
||||
|
||||
OAuthProvidersOptions dung dictionary provider theo key (vd google, azuread, okta).
|
||||
|
||||
----------------------------------------
|
||||
|
||||
## 5) Thiet ke interface va service chung
|
||||
|
||||
### 5.1 Interface ISsoService
|
||||
|
||||
Interface da duoc mo rong thanh provider-aware:
|
||||
- BuildAuthorizeUrl(provider, redirectUri, state)
|
||||
- ExchangeCodeAsync(provider, code, redirectUri, ct)
|
||||
- ValidateIdTokenAsync(provider, idToken, ct)
|
||||
- IsAllowedDomain(provider, email, principal)
|
||||
- UpsertUserAsync(provider, email, name, ct)
|
||||
|
||||
Va van giu overload cu de backward compatibility.
|
||||
|
||||
### 5.2 SsoService - logic tong
|
||||
|
||||
Core y tuong:
|
||||
- Resolve config theo provider key.
|
||||
- Provider nao khong co endpoint explicit thi suy ra tu metadata/authority.
|
||||
- AzureAD duoc fallback tu AzureAdOptions de khong vo luong cu.
|
||||
|
||||
Phan quan trong:
|
||||
|
||||
1) ResolveProvider(...)
|
||||
- Doc provider trong OAuthProviders.
|
||||
- Neu key = azuread ma khong co trong OAuthProviders, fallback AzureAdOptions.
|
||||
|
||||
2) BuildAuthorizeUrl(...)
|
||||
- Build URL authorize theo endpoint cua provider.
|
||||
- Them scope, state, redirect_uri.
|
||||
- Neu bat hosted-domain check thi them hd=<AllowedDomain>.
|
||||
|
||||
3) ExchangeCodeAsync(...)
|
||||
- Goi token endpoint voi client_id, client_secret, code, redirect_uri.
|
||||
- Parse OidcTokenResponse.
|
||||
- Bat buoc co id_token (flow hien tai dang OIDC-centric).
|
||||
|
||||
4) ValidateIdTokenAsync(...)
|
||||
- Lay metadata OIDC (.well-known/openid-configuration).
|
||||
- Validate issuer, audience, signing key, lifetime.
|
||||
|
||||
5) IsAllowedDomain(...)
|
||||
- Neu AllowedDomain rong -> cho phep.
|
||||
- Neu khong rong -> check duoi email.
|
||||
- Neu EnforceHostedDomainClaim bat va token co claim hd -> bat buoc hd trung AllowedDomain.
|
||||
|
||||
6) UpsertUserAsync(...)
|
||||
- Tim user theo email (UserName).
|
||||
- Neu ton tai: cap nhat ten + metadata update.
|
||||
- Neu chua ton tai: tao user moi, Password = null, gan role PendingRoleName.
|
||||
|
||||
7) One-time code
|
||||
- CreateOneTimeCodeAsync(...): tao code, luu bang SsoOneTimeCodes, het han sau 2 phut.
|
||||
- ExchangeOneTimeCodeForLoginAsync(...): consume code, tra payload login final.
|
||||
|
||||
----------------------------------------
|
||||
|
||||
## 6) Controller va contract API
|
||||
|
||||
### 6.1 OAuthController (da provider)
|
||||
|
||||
Route base: api/auth/oauth
|
||||
|
||||
1) Login
|
||||
- GET /api/auth/oauth/{provider}/login?returnUrl=...
|
||||
- Redirect sang provider authorize URL.
|
||||
|
||||
2) Callback
|
||||
- GET /api/auth/oauth/{provider}/callback?code=...&state=...
|
||||
- Xu ly token + domain + upsert + issue token + one-time code.
|
||||
- Redirect ve FE voi query code.
|
||||
|
||||
3) Exchange
|
||||
- POST /api/auth/oauth/exchange
|
||||
|
||||
Request:
|
||||
```json
|
||||
{
|
||||
"code": "one-time-code"
|
||||
}
|
||||
```
|
||||
|
||||
Response thanh cong:
|
||||
```json
|
||||
{
|
||||
"token": "<jwt-noi-bo>",
|
||||
"name": "...",
|
||||
"username": "email@domain",
|
||||
"access": [1, 2, 3],
|
||||
"role": {
|
||||
"roleName": "Pending",
|
||||
"priority": 99
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 SsoController (AzureAD legacy)
|
||||
|
||||
Van giu route cu, nhung da goi service chung voi provider azuread.
|
||||
|
||||
----------------------------------------
|
||||
|
||||
## 7) Rule user va role Pending
|
||||
|
||||
Rule bat buoc:
|
||||
- User OAuth lan dau phai duoc tao trong bang UserAccounts.
|
||||
- User moi phai vao role Pending.
|
||||
- Role Pending khong co permission nao (PermissionRoles.IsChecked = 0 hoac khong co row).
|
||||
- Admin se cap role/permission sau.
|
||||
|
||||
Model lien quan:
|
||||
- UserAccounts
|
||||
- Roles
|
||||
- PermissionRoles
|
||||
- SsoOneTimeCodes
|
||||
|
||||
Kiem tra nhanh trong DB:
|
||||
|
||||
```sql
|
||||
-- 1) Kiem tra role Pending co ton tai
|
||||
SELECT Id, RoleName, Priority
|
||||
FROM "Roles"
|
||||
WHERE "RoleName" = 'Pending';
|
||||
|
||||
-- 2) Kiem tra Pending role khong co permission active
|
||||
SELECT pr.*
|
||||
FROM "PermissionRoles" pr
|
||||
JOIN "Roles" r ON r."Id" = pr."RoleId"
|
||||
WHERE r."RoleName" = 'Pending'
|
||||
AND pr."IsChecked" = 1;
|
||||
|
||||
-- 3) Kiem tra user tao boi OAuth
|
||||
SELECT "Id", "UserName", "Password", "RoleId", "CreatedBy", "UpdatedBy"
|
||||
FROM "UserAccounts"
|
||||
WHERE "UserName" = '<email-user>';
|
||||
```
|
||||
|
||||
Ky vong:
|
||||
- Query #2 tra ve 0 rows.
|
||||
- User moi co Password = null, CreatedBy = 'SSO'.
|
||||
|
||||
----------------------------------------
|
||||
|
||||
## 8) Frontend integration (chi tiet)
|
||||
|
||||
1. Nguoi dung bam nut Google Login:
|
||||
- Window.location -> GET /api/auth/oauth/google/login?returnUrl=<FE_CALLBACK_URL>
|
||||
2. Sau callback backend, FE nhan code tu query string.
|
||||
3. FE goi:
|
||||
|
||||
```http
|
||||
POST /api/auth/oauth/exchange
|
||||
Content-Type: application/json
|
||||
|
||||
{ "code": "<code-tu-query>" }
|
||||
```
|
||||
|
||||
4. FE luu token va xu ly permission/role giong login cu.
|
||||
|
||||
----------------------------------------
|
||||
|
||||
## 9) Luu y bao mat va hardening
|
||||
|
||||
Nen lam tiep:
|
||||
- Validate returnUrl theo allowlist de tranh open redirect.
|
||||
- Luu va verify state/nonce server-side (cache/redis).
|
||||
- Dung IHttpClientFactory thay new HttpClient() de kiem soat timeout/retry.
|
||||
- Dua ClientSecret sang secret manager/env var.
|
||||
- Bat HTTPS va secure cookie policy day du.
|
||||
|
||||
----------------------------------------
|
||||
|
||||
## 10) Troubleshooting
|
||||
|
||||
1) Loi IdToken not found in token response
|
||||
- Provider dang khong tra OIDC token.
|
||||
- Kiem tra scope co openid chua.
|
||||
|
||||
2) Loi Email not found in token
|
||||
- Kiem tra claim trong Google token (email, preferred_username).
|
||||
|
||||
3) Loi Email domain is not allowed
|
||||
- Check AllowedDomain va claim hd.
|
||||
|
||||
4) Loi Role 'Pending' not found
|
||||
- Tao role Pending trong bang Roles.
|
||||
|
||||
5) Loi exchange code het han
|
||||
- One-time code chi song 2 phut va chi dung 1 lan.
|
||||
|
||||
----------------------------------------
|
||||
|
||||
## 11) Checklist test E2E truoc khi merge
|
||||
|
||||
- Login Google thanh cong voi account hop le.
|
||||
- User moi duoc tao trong DB va role = Pending.
|
||||
- Pending role khong co permission active.
|
||||
- User cu login lai thi khong tao duplicate user.
|
||||
- One-time code chi dung 1 lan.
|
||||
- AllowedDomain rong cho phep tat ca domain.
|
||||
- AllowedDomain co gia tri thi chan dung domain.
|
||||
|
||||
----------------------------------------
|
||||
|
||||
## 12) Ghi chu pham vi hien tai
|
||||
|
||||
Hien tai flow moi dang OIDC-centric (bat buoc id_token).
|
||||
Neu can support OAuth thuan (khong co id_token), can bo sung nhanh:
|
||||
- UserInfo endpoint call.
|
||||
- Mapping email/name tu userinfo response.
|
||||
- Branch logic trong callback de fallback userinfo.
|
||||
74
Users-API.md
74
Users-API.md
|
|
@ -1,74 +0,0 @@
|
|||
# User API
|
||||
|
||||
Tai lieu mo ta cac endpoint cap nhat role va thong tin nguoi dung.
|
||||
|
||||
----------------------------------------
|
||||
|
||||
## 1) Cap nhat thong tin nguoi dung
|
||||
- PUT /api/User/{id}
|
||||
- Permission: EDIT_USER_ROLE
|
||||
|
||||
### Request
|
||||
```json
|
||||
{
|
||||
"name": "Nguyen Van A",
|
||||
"userName": "nguyenvana",
|
||||
"accessRooms": [1, 2, 3]
|
||||
}
|
||||
```
|
||||
|
||||
### Response (200)
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "User updated successfully",
|
||||
"data": {
|
||||
"userId": 12,
|
||||
"userName": "nguyenvana",
|
||||
"name": "Nguyen Van A",
|
||||
"roleId": 3,
|
||||
"accessRooms": [1, 2, 3],
|
||||
"updatedAt": "2026-04-03T10:20:30Z",
|
||||
"updatedBy": "admin"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Ghi chu
|
||||
- Neu khong truyen `accessRooms` thi giu nguyen danh sach phong.
|
||||
- Neu truyen `accessRooms` = [] thi xoa tat ca phong.
|
||||
- Neu `userName` bi trung hoac khong hop le thi tra ve 400.
|
||||
|
||||
----------------------------------------
|
||||
|
||||
## 2) Cap nhat role nguoi dung
|
||||
- PUT /api/User/{id}/role
|
||||
- Permission: EDIT_USER_ROLE
|
||||
|
||||
### Request
|
||||
```json
|
||||
{
|
||||
"roleId": 2
|
||||
}
|
||||
```
|
||||
|
||||
### Response (200)
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "User role updated",
|
||||
"data": {
|
||||
"userId": 12,
|
||||
"userName": "nguyenvana",
|
||||
"roleId": 2,
|
||||
"roleName": "Manager",
|
||||
"updatedAt": "2026-04-03T10:20:30Z",
|
||||
"updatedBy": "admin"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Ghi chu
|
||||
- Chi System Admin moi duoc phep cap nhat role System Admin.
|
||||
|
||||
----------------------------------------
|
||||
183
nginx/nginx.conf
183
nginx/nginx.conf
|
|
@ -3,208 +3,79 @@
|
|||
# server 127.0.0.1:8080;
|
||||
# server 172.18.10.8:8080;
|
||||
# }
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name comp.soict.io;
|
||||
return 301 https://$host$request_uri; # Redirect HTTP sang HTTPS
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/certbot;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
server{
|
||||
listen 443 ssl;
|
||||
server_name comp.soict.io;
|
||||
ssl_certificate /etc/nginx/ssl/nginx-selfsigned.crt;
|
||||
ssl_certificate_key /etc/nginx/ssl/nginx-selfsigned.key;
|
||||
|
||||
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
154
package-lock.json
generated
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1,11 +0,0 @@
|
|||
{
|
||||
"version": 1,
|
||||
"skills": {
|
||||
"frontend-design": {
|
||||
"source": "anthropics/skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/frontend-design/SKILL.md",
|
||||
"computedHash": "516bd2154eb843a8240e43d5b285229129853114ad7075a5e141e1c08e408c84"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 có 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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))}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ export function RoomManagementCard({
|
|||
<Card className="bg-white/80 backdrop-blur border-muted/60 shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle>Quản lý phòng</CardTitle>
|
||||
<CardDescription>Thông tin tổng quan và các phòng đang không sử dụng</CardDescription>
|
||||
<CardDescription>Thông tin tổng quan và phòng cần chú ý</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
|
|
@ -47,7 +47,7 @@ export function RoomManagementCard({
|
|||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<div className="text-sm font-medium">Phòng không dùng</div>
|
||||
<div className="text-sm font-medium">Phòng cần chú ý</div>
|
||||
<div className="mt-2 space-y-2">
|
||||
{data?.roomsNeedAttention && data.roomsNeedAttention.length > 0 ? (
|
||||
data.roomsNeedAttention.map((r: RoomHealthStatus) => (
|
||||
|
|
|
|||
|
|
@ -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: () => (
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/u
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useState, useMemo } from "react";
|
||||
|
||||
export interface SelectItem {
|
||||
label: string;
|
||||
|
|
@ -16,7 +16,6 @@ interface SelectDialogProps {
|
|||
description?: string;
|
||||
icon?: React.ReactNode;
|
||||
items: SelectItem[];
|
||||
selectedValues?: string[];
|
||||
onConfirm: (values: string[]) => Promise<void> | void;
|
||||
}
|
||||
|
||||
|
|
@ -27,18 +26,11 @@ export function SelectDialog({
|
|||
description,
|
||||
icon,
|
||||
items,
|
||||
selectedValues,
|
||||
onConfirm,
|
||||
}: SelectDialogProps) {
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (!selectedValues) return;
|
||||
setSelected(selectedValues);
|
||||
}, [open, selectedValues]);
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
return items.filter((item) =>
|
||||
item.label.toLowerCase().includes(search.toLowerCase())
|
||||
|
|
|
|||
|
|
@ -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 = vô hạn.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
{/* Submit Button */}
|
||||
<div className="flex gap-3 pt-4">
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { Input } from "@/components/ui/input";
|
|||
import { Label } from "@/components/ui/label";
|
||||
import type { LoginResquest } from "@/types/auth";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { buildGoogleOAuthLoginUrl, buildMicrosoftSsoLoginUrl, login } from "@/services/auth.service";
|
||||
import { buildSsoLoginUrl, login } from "@/services/auth.service";
|
||||
import { useState } from "react";
|
||||
import { useNavigate, useRouter } from "@tanstack/react-router";
|
||||
import { Route } from "@/routes/(auth)/login";
|
||||
|
|
@ -44,22 +44,12 @@ export function LoginForm({ className }: React.ComponentProps<"form">) {
|
|||
}
|
||||
});
|
||||
|
||||
const handleGoogleLogin = () => {
|
||||
const returnUrl = new URL("/oauth/callback", window.location.origin);
|
||||
returnUrl.searchParams.set("provider", "google");
|
||||
const handleSsoLogin = () => {
|
||||
const returnUrl = new URL("/sso/callback", window.location.origin);
|
||||
if (search.redirect) {
|
||||
returnUrl.searchParams.set("redirect", search.redirect);
|
||||
}
|
||||
window.location.assign(buildGoogleOAuthLoginUrl(returnUrl.toString()));
|
||||
};
|
||||
|
||||
const handleMicrosoftLogin = () => {
|
||||
const returnUrl = new URL("/oauth/callback", window.location.origin);
|
||||
returnUrl.searchParams.set("provider", "azuread");
|
||||
if (search.redirect) {
|
||||
returnUrl.searchParams.set("redirect", search.redirect);
|
||||
}
|
||||
window.location.assign(buildMicrosoftSsoLoginUrl(returnUrl.toString()));
|
||||
window.location.assign(buildSsoLoginUrl(returnUrl.toString()));
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
|
|
@ -122,33 +112,12 @@ export function LoginForm({ className }: React.ComponentProps<"form">) {
|
|||
</Button>
|
||||
)}
|
||||
<div className="text-center text-sm text-muted-foreground">Hoặc</div>
|
||||
<Button type="button" variant="outline" className="w-full gap-2" onClick={handleGoogleLogin}>
|
||||
<Button type="button" variant="outline" className="w-full gap-2" onClick={handleSsoLogin}>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" className="h-4 w-4">
|
||||
<path
|
||||
fill="#4285F4"
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.02 5.02 0 0 1-2.18 3.29v2.74h3.52c2.05-1.89 3.3-4.67 3.3-8.04Z"
|
||||
/>
|
||||
<path
|
||||
fill="#34A853"
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.52-2.74c-.98.66-2.23 1.06-3.76 1.06-2.89 0-5.33-1.95-6.2-4.56H2.18v2.84A11 11 0 0 0 12 23Z"
|
||||
/>
|
||||
<path
|
||||
fill="#FBBC05"
|
||||
d="M5.8 14.1A6.62 6.62 0 0 1 5.45 12c0-.73.13-1.44.35-2.1V7.06H2.18A11 11 0 0 0 1 12c0 1.77.42 3.44 1.18 4.94L5.8 14.1Z"
|
||||
/>
|
||||
<path
|
||||
fill="#EA4335"
|
||||
d="M12 5.34c1.61 0 3.05.56 4.18 1.64l3.14-3.14C17.45 2.09 14.97 1 12 1a11 11 0 0 0-9.82 6.06L5.8 9.9C6.67 7.29 9.11 5.34 12 5.34Z"
|
||||
/>
|
||||
</svg>
|
||||
Đăng nhập với Google
|
||||
</Button>
|
||||
<Button type="button" variant="outline" className="w-full gap-2" onClick={handleMicrosoftLogin}>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" className="h-4 w-4">
|
||||
<rect x="2" y="2" width="9" height="9" fill="#F35325" />
|
||||
<rect x="13" y="2" width="9" height="9" fill="#81BC06" />
|
||||
<rect x="2" y="13" width="9" height="9" fill="#05A6F0" />
|
||||
<rect x="13" y="13" width="9" height="9" fill="#FFBA08" />
|
||||
<rect x="1" y="1" width="10" height="10" fill="#F25022" />
|
||||
<rect x="13" y="1" width="10" height="10" fill="#7FBA00" />
|
||||
<rect x="1" y="13" width="10" height="10" fill="#00A4EF" />
|
||||
<rect x="13" y="13" width="10" height="10" fill="#FFB900" />
|
||||
</svg>
|
||||
Đăng nhập với Microsoft
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -18,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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (21–40) */}
|
||||
{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 (1–20) */}
|
||||
{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">
|
||||
|
|
|
|||
|
|
@ -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 có 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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">-></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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -1,46 +1,16 @@
|
|||
const isDev = import.meta.env.MODE === "development";
|
||||
|
||||
const trimTrailingSlash = (value: string) => value.replace(/\/+$/, "");
|
||||
|
||||
export const BASE_URL = isDev
|
||||
? import.meta.env.VITE_API_URL_DEV
|
||||
: "/api";
|
||||
|
||||
export const BASE_MESH_URL = isDev
|
||||
? (import.meta.env.VITE_API_MESH || import.meta.env.VITE_API_MESH_DEV || "")
|
||||
: (import.meta.env.VITE_API_MESH || "");
|
||||
|
||||
export const buildMeshProxyUrl = (meshPathAndQuery: string) => {
|
||||
const cleanPath = meshPathAndQuery.startsWith("/")
|
||||
? meshPathAndQuery.substring(1)
|
||||
: meshPathAndQuery;
|
||||
const proxyPath = `/api/meshcentral/proxy/${cleanPath}`;
|
||||
|
||||
// If an explicit mesh host is configured, always use it.
|
||||
// This allows forcing proxy URLs to https://<IP>:<port>/api/meshcentral/proxy/...
|
||||
if (BASE_MESH_URL && BASE_MESH_URL.startsWith("http")) {
|
||||
return `${trimTrailingSlash(BASE_MESH_URL)}${proxyPath}`;
|
||||
}
|
||||
|
||||
// In development, BASE_URL is usually absolute (e.g. http://localhost:5218/api).
|
||||
// Build an absolute proxy URL to backend so iframe requests do not hit Vite dev server.
|
||||
if (BASE_URL.startsWith("http")) {
|
||||
const apiBase = trimTrailingSlash(BASE_URL);
|
||||
const backendOrigin = apiBase.endsWith("/api")
|
||||
? apiBase.slice(0, -4)
|
||||
: apiBase;
|
||||
return `${backendOrigin}${proxyPath}`;
|
||||
}
|
||||
|
||||
return proxyPath;
|
||||
};
|
||||
? import.meta.env.VITE_API_MESH_DEV
|
||||
: "/meshapi";
|
||||
|
||||
export const API_ENDPOINTS = {
|
||||
AUTH: {
|
||||
LOGIN: `${BASE_URL}/login`,
|
||||
OAUTH_LOGIN: (provider: string) =>
|
||||
`${BASE_URL}/auth/oauth/${encodeURIComponent(provider)}/login`,
|
||||
OAUTH_EXCHANGE: `${BASE_URL}/auth/oauth/exchange`,
|
||||
SSO_LOGIN: `${BASE_URL}/auth/sso/login`,
|
||||
SSO_EXCHANGE: `${BASE_URL}/auth/sso/exchange`,
|
||||
LOGOUT: `${BASE_URL}/logout`,
|
||||
|
|
@ -51,10 +21,6 @@ export const API_ENDPOINTS = {
|
|||
CREATE_ACCOUNT: `${BASE_URL}/auth/create-account`,
|
||||
GET_USERS_LIST: `${BASE_URL}/users-info`,
|
||||
},
|
||||
USER: {
|
||||
UPDATE_INFO: (id: number) => `${BASE_URL}/User/${id}`,
|
||||
UPDATE_ROLE: (id: number) => `${BASE_URL}/User/${id}/role`,
|
||||
},
|
||||
APP_VERSION: {
|
||||
//agent and app api
|
||||
GET_VERSION: `${BASE_URL}/AppVersion/version`,
|
||||
|
|
@ -72,10 +38,7 @@ export const API_ENDPOINTS = {
|
|||
GET_REQUIRED_FILES: `${BASE_URL}/AppVersion/requirefiles`,
|
||||
ADD_REQUIRED_FILE: `${BASE_URL}/AppVersion/requirefile/add`,
|
||||
DELETE_REQUIRED_FILE: `${BASE_URL}/AppVersion/requirefile/delete`,
|
||||
DELETE_FILES: `${BASE_URL}/AppVersion/delete`,
|
||||
},
|
||||
MANIFEST: {
|
||||
SEND_ALL: `${BASE_URL}/Manifest/sendall`,
|
||||
DELETE_FILES: (fileId: number) => `${BASE_URL}/AppVersion/delete/${fileId}`,
|
||||
},
|
||||
DEVICE_COMM: {
|
||||
DOWNLOAD_FILES: (roomName: string) => `${BASE_URL}/DeviceComm/downloadfile/${roomName}`,
|
||||
|
|
|
|||
|
|
@ -176,7 +176,7 @@ export function useDeleteFile() {
|
|||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: { MsiFileIds: number[] }) => appVersionService.deleteFile(data),
|
||||
mutationFn: (fileId: number) => appVersionService.deleteFile(fileId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: APP_VERSION_QUERY_KEYS.softwareList(),
|
||||
|
|
@ -184,12 +184,3 @@ export function useDeleteFile() {
|
|||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook để gửi manifest
|
||||
*/
|
||||
export function useSendManifest() {
|
||||
return useMutation({
|
||||
mutationFn: () => appVersionService.sendManifest(),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1,10 +1,6 @@
|
|||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import * as userService from "@/services/user.service";
|
||||
import type {
|
||||
UserProfile,
|
||||
UpdateUserInfoRequest,
|
||||
UpdateUserRoleRequest,
|
||||
} from "@/types/user-profile";
|
||||
import type { UserProfile } from "@/types/user-profile";
|
||||
|
||||
const USER_QUERY_KEYS = {
|
||||
all: ["users"] as const,
|
||||
|
|
@ -22,47 +18,3 @@ export function useGetUsersInfo(enabled = true) {
|
|||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook để cập nhật thông tin người dùng
|
||||
*/
|
||||
export function useUpdateUserInfo() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
id,
|
||||
data,
|
||||
}: {
|
||||
id: number;
|
||||
data: UpdateUserInfoRequest;
|
||||
}) => userService.updateUserInfo(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: USER_QUERY_KEYS.list(),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook để cập nhật role người dùng
|
||||
*/
|
||||
export function useUpdateUserRole() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
id,
|
||||
data,
|
||||
}: {
|
||||
id: number;
|
||||
data: UpdateUserRoleRequest;
|
||||
}) => userService.updateUserRole(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: USER_QUERY_KEYS.list(),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,9 +28,8 @@ import { Route as AuthRoomsRoomNameIndexRouteImport } from './routes/_auth/rooms
|
|||
import { Route as AuthRoleCreateIndexRouteImport } from './routes/_auth/role/create/index'
|
||||
import { Route as AuthProfileChangePasswordIndexRouteImport } from './routes/_auth/profile/change-password/index'
|
||||
import { Route as AuthProfileUserNameIndexRouteImport } from './routes/_auth/profile/$userName/index'
|
||||
import { Route as authOauthCallbackIndexRouteImport } from './routes/(auth)/oauth/callback/index'
|
||||
import { Route as authSsoCallbackIndexRouteImport } from './routes/(auth)/sso/callback/index'
|
||||
import { Route as AuthUserRoleRoleIdIndexRouteImport } from './routes/_auth/user/role/$roleId/index'
|
||||
import { Route as AuthUserEditUserNameIndexRouteImport } from './routes/_auth/user/edit/$userName/index'
|
||||
import { Route as AuthUserChangePasswordUserNameIndexRouteImport } from './routes/_auth/user/change-password/$userName/index'
|
||||
import { Route as AuthRoomsRoomNameFolderStatusIndexRouteImport } from './routes/_auth/rooms/$roomName/folder-status/index'
|
||||
import { Route as AuthRoomsRoomNameConnectIndexRouteImport } from './routes/_auth/rooms/$roomName/connect/index'
|
||||
|
|
@ -132,9 +131,9 @@ const AuthProfileUserNameIndexRoute =
|
|||
path: '/profile/$userName/',
|
||||
getParentRoute: () => AuthRoute,
|
||||
} as any)
|
||||
const authOauthCallbackIndexRoute = authOauthCallbackIndexRouteImport.update({
|
||||
id: '/(auth)/oauth/callback/',
|
||||
path: '/oauth/callback/',
|
||||
const authSsoCallbackIndexRoute = authSsoCallbackIndexRouteImport.update({
|
||||
id: '/(auth)/sso/callback/',
|
||||
path: '/sso/callback/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const AuthUserRoleRoleIdIndexRoute = AuthUserRoleRoleIdIndexRouteImport.update({
|
||||
|
|
@ -142,12 +141,6 @@ const AuthUserRoleRoleIdIndexRoute = AuthUserRoleRoleIdIndexRouteImport.update({
|
|||
path: '/user/role/$roleId/',
|
||||
getParentRoute: () => AuthRoute,
|
||||
} as any)
|
||||
const AuthUserEditUserNameIndexRoute =
|
||||
AuthUserEditUserNameIndexRouteImport.update({
|
||||
id: '/user/edit/$userName/',
|
||||
path: '/user/edit/$userName/',
|
||||
getParentRoute: () => AuthRoute,
|
||||
} as any)
|
||||
const AuthUserChangePasswordUserNameIndexRoute =
|
||||
AuthUserChangePasswordUserNameIndexRouteImport.update({
|
||||
id: '/user/change-password/$userName/',
|
||||
|
|
@ -186,7 +179,7 @@ export interface FileRoutesByFullPath {
|
|||
'/role': typeof AuthRoleIndexRoute
|
||||
'/rooms': typeof AuthRoomsIndexRoute
|
||||
'/user': typeof AuthUserIndexRoute
|
||||
'/oauth/callback': typeof authOauthCallbackIndexRoute
|
||||
'/sso/callback': typeof authSsoCallbackIndexRoute
|
||||
'/profile/$userName': typeof AuthProfileUserNameIndexRoute
|
||||
'/profile/change-password': typeof AuthProfileChangePasswordIndexRoute
|
||||
'/role/create': typeof AuthRoleCreateIndexRoute
|
||||
|
|
@ -196,7 +189,6 @@ export interface FileRoutesByFullPath {
|
|||
'/rooms/$roomName/connect': typeof AuthRoomsRoomNameConnectIndexRoute
|
||||
'/rooms/$roomName/folder-status': typeof AuthRoomsRoomNameFolderStatusIndexRoute
|
||||
'/user/change-password/$userName': typeof AuthUserChangePasswordUserNameIndexRoute
|
||||
'/user/edit/$userName': typeof AuthUserEditUserNameIndexRoute
|
||||
'/user/role/$roleId': typeof AuthUserRoleRoleIdIndexRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
|
|
@ -213,7 +205,7 @@ export interface FileRoutesByTo {
|
|||
'/role': typeof AuthRoleIndexRoute
|
||||
'/rooms': typeof AuthRoomsIndexRoute
|
||||
'/user': typeof AuthUserIndexRoute
|
||||
'/oauth/callback': typeof authOauthCallbackIndexRoute
|
||||
'/sso/callback': typeof authSsoCallbackIndexRoute
|
||||
'/profile/$userName': typeof AuthProfileUserNameIndexRoute
|
||||
'/profile/change-password': typeof AuthProfileChangePasswordIndexRoute
|
||||
'/role/create': typeof AuthRoleCreateIndexRoute
|
||||
|
|
@ -223,7 +215,6 @@ export interface FileRoutesByTo {
|
|||
'/rooms/$roomName/connect': typeof AuthRoomsRoomNameConnectIndexRoute
|
||||
'/rooms/$roomName/folder-status': typeof AuthRoomsRoomNameFolderStatusIndexRoute
|
||||
'/user/change-password/$userName': typeof AuthUserChangePasswordUserNameIndexRoute
|
||||
'/user/edit/$userName': typeof AuthUserEditUserNameIndexRoute
|
||||
'/user/role/$roleId': typeof AuthUserRoleRoleIdIndexRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
|
|
@ -242,7 +233,7 @@ export interface FileRoutesById {
|
|||
'/_auth/role/': typeof AuthRoleIndexRoute
|
||||
'/_auth/rooms/': typeof AuthRoomsIndexRoute
|
||||
'/_auth/user/': typeof AuthUserIndexRoute
|
||||
'/(auth)/oauth/callback/': typeof authOauthCallbackIndexRoute
|
||||
'/(auth)/sso/callback/': typeof authSsoCallbackIndexRoute
|
||||
'/_auth/profile/$userName/': typeof AuthProfileUserNameIndexRoute
|
||||
'/_auth/profile/change-password/': typeof AuthProfileChangePasswordIndexRoute
|
||||
'/_auth/role/create/': typeof AuthRoleCreateIndexRoute
|
||||
|
|
@ -252,7 +243,6 @@ export interface FileRoutesById {
|
|||
'/_auth/rooms/$roomName/connect/': typeof AuthRoomsRoomNameConnectIndexRoute
|
||||
'/_auth/rooms/$roomName/folder-status/': typeof AuthRoomsRoomNameFolderStatusIndexRoute
|
||||
'/_auth/user/change-password/$userName/': typeof AuthUserChangePasswordUserNameIndexRoute
|
||||
'/_auth/user/edit/$userName/': typeof AuthUserEditUserNameIndexRoute
|
||||
'/_auth/user/role/$roleId/': typeof AuthUserRoleRoleIdIndexRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
|
|
@ -271,7 +261,7 @@ export interface FileRouteTypes {
|
|||
| '/role'
|
||||
| '/rooms'
|
||||
| '/user'
|
||||
| '/oauth/callback'
|
||||
| '/sso/callback'
|
||||
| '/profile/$userName'
|
||||
| '/profile/change-password'
|
||||
| '/role/create'
|
||||
|
|
@ -281,7 +271,6 @@ export interface FileRouteTypes {
|
|||
| '/rooms/$roomName/connect'
|
||||
| '/rooms/$roomName/folder-status'
|
||||
| '/user/change-password/$userName'
|
||||
| '/user/edit/$userName'
|
||||
| '/user/role/$roleId'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
|
|
@ -298,7 +287,7 @@ export interface FileRouteTypes {
|
|||
| '/role'
|
||||
| '/rooms'
|
||||
| '/user'
|
||||
| '/oauth/callback'
|
||||
| '/sso/callback'
|
||||
| '/profile/$userName'
|
||||
| '/profile/change-password'
|
||||
| '/role/create'
|
||||
|
|
@ -308,7 +297,6 @@ export interface FileRouteTypes {
|
|||
| '/rooms/$roomName/connect'
|
||||
| '/rooms/$roomName/folder-status'
|
||||
| '/user/change-password/$userName'
|
||||
| '/user/edit/$userName'
|
||||
| '/user/role/$roleId'
|
||||
id:
|
||||
| '__root__'
|
||||
|
|
@ -326,7 +314,7 @@ export interface FileRouteTypes {
|
|||
| '/_auth/role/'
|
||||
| '/_auth/rooms/'
|
||||
| '/_auth/user/'
|
||||
| '/(auth)/oauth/callback/'
|
||||
| '/(auth)/sso/callback/'
|
||||
| '/_auth/profile/$userName/'
|
||||
| '/_auth/profile/change-password/'
|
||||
| '/_auth/role/create/'
|
||||
|
|
@ -336,7 +324,6 @@ export interface FileRouteTypes {
|
|||
| '/_auth/rooms/$roomName/connect/'
|
||||
| '/_auth/rooms/$roomName/folder-status/'
|
||||
| '/_auth/user/change-password/$userName/'
|
||||
| '/_auth/user/edit/$userName/'
|
||||
| '/_auth/user/role/$roleId/'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
|
|
@ -344,7 +331,7 @@ export interface RootRouteChildren {
|
|||
IndexRoute: typeof IndexRoute
|
||||
AuthRoute: typeof AuthRouteWithChildren
|
||||
authLoginIndexRoute: typeof authLoginIndexRoute
|
||||
authOauthCallbackIndexRoute: typeof authOauthCallbackIndexRoute
|
||||
authSsoCallbackIndexRoute: typeof authSsoCallbackIndexRoute
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
|
|
@ -482,11 +469,11 @@ declare module '@tanstack/react-router' {
|
|||
preLoaderRoute: typeof AuthProfileUserNameIndexRouteImport
|
||||
parentRoute: typeof AuthRoute
|
||||
}
|
||||
'/(auth)/oauth/callback/': {
|
||||
id: '/(auth)/oauth/callback/'
|
||||
path: '/oauth/callback'
|
||||
fullPath: '/oauth/callback'
|
||||
preLoaderRoute: typeof authOauthCallbackIndexRouteImport
|
||||
'/(auth)/sso/callback/': {
|
||||
id: '/(auth)/sso/callback/'
|
||||
path: '/sso/callback'
|
||||
fullPath: '/sso/callback'
|
||||
preLoaderRoute: typeof authSsoCallbackIndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/_auth/user/role/$roleId/': {
|
||||
|
|
@ -496,13 +483,6 @@ declare module '@tanstack/react-router' {
|
|||
preLoaderRoute: typeof AuthUserRoleRoleIdIndexRouteImport
|
||||
parentRoute: typeof AuthRoute
|
||||
}
|
||||
'/_auth/user/edit/$userName/': {
|
||||
id: '/_auth/user/edit/$userName/'
|
||||
path: '/user/edit/$userName'
|
||||
fullPath: '/user/edit/$userName'
|
||||
preLoaderRoute: typeof AuthUserEditUserNameIndexRouteImport
|
||||
parentRoute: typeof AuthRoute
|
||||
}
|
||||
'/_auth/user/change-password/$userName/': {
|
||||
id: '/_auth/user/change-password/$userName/'
|
||||
path: '/user/change-password/$userName'
|
||||
|
|
@ -555,7 +535,6 @@ interface AuthRouteChildren {
|
|||
AuthRoomsRoomNameConnectIndexRoute: typeof AuthRoomsRoomNameConnectIndexRoute
|
||||
AuthRoomsRoomNameFolderStatusIndexRoute: typeof AuthRoomsRoomNameFolderStatusIndexRoute
|
||||
AuthUserChangePasswordUserNameIndexRoute: typeof AuthUserChangePasswordUserNameIndexRoute
|
||||
AuthUserEditUserNameIndexRoute: typeof AuthUserEditUserNameIndexRoute
|
||||
AuthUserRoleRoleIdIndexRoute: typeof AuthUserRoleRoleIdIndexRoute
|
||||
}
|
||||
|
||||
|
|
@ -582,7 +561,6 @@ const AuthRouteChildren: AuthRouteChildren = {
|
|||
AuthRoomsRoomNameFolderStatusIndexRoute,
|
||||
AuthUserChangePasswordUserNameIndexRoute:
|
||||
AuthUserChangePasswordUserNameIndexRoute,
|
||||
AuthUserEditUserNameIndexRoute: AuthUserEditUserNameIndexRoute,
|
||||
AuthUserRoleRoleIdIndexRoute: AuthUserRoleRoleIdIndexRoute,
|
||||
}
|
||||
|
||||
|
|
@ -592,7 +570,7 @@ const rootRouteChildren: RootRouteChildren = {
|
|||
IndexRoute: IndexRoute,
|
||||
AuthRoute: AuthRouteWithChildren,
|
||||
authLoginIndexRoute: authLoginIndexRoute,
|
||||
authOauthCallbackIndexRoute: authOauthCallbackIndexRoute,
|
||||
authSsoCallbackIndexRoute: authSsoCallbackIndexRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
|
|
|
|||
|
|
@ -1,63 +1,36 @@
|
|||
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useExchangeSsoCode } from "@/hooks/queries";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LoaderCircle } from "lucide-react";
|
||||
import axios from "axios";
|
||||
import { exchangeCodeByProvider } from "@/services/auth.service";
|
||||
import type { LoginResponse } from "@/types/auth";
|
||||
|
||||
const inFlightExchanges = new Map<string, Promise<LoginResponse>>();
|
||||
const consumedCodes = new Set<string>();
|
||||
|
||||
export const Route = createFileRoute("/(auth)/oauth/callback/")({
|
||||
component: OAuthCallbackPage,
|
||||
export const Route = createFileRoute("/(auth)/sso/callback/")({
|
||||
component: SsoCallbackPage,
|
||||
});
|
||||
|
||||
function OAuthCallbackPage() {
|
||||
function SsoCallbackPage() {
|
||||
const auth = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const search = Route.useSearch() as { code?: string; redirect?: string; provider?: string };
|
||||
const exchangeMutation = useExchangeSsoCode();
|
||||
const search = Route.useSearch() as { code?: string; redirect?: string };
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [isExchanging, setIsExchanging] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const code = search.code;
|
||||
const provider = (search.provider || "").toLowerCase();
|
||||
if (!code) {
|
||||
setErrorMessage("OAuth code is missing.");
|
||||
return;
|
||||
}
|
||||
|
||||
const exchangeId = `${provider || "auto"}:${code}`;
|
||||
|
||||
if (consumedCodes.has(exchangeId)) {
|
||||
setErrorMessage("Mã đăng nhập đã được sử dụng. Vui lòng đăng nhập lại.");
|
||||
if (!search.code) {
|
||||
setErrorMessage("SSO code is missing.");
|
||||
return;
|
||||
}
|
||||
|
||||
setErrorMessage(null);
|
||||
setIsExchanging(true);
|
||||
|
||||
let cancelled = false;
|
||||
let exchangePromise = inFlightExchanges.get(exchangeId);
|
||||
if (!exchangePromise) {
|
||||
exchangePromise = exchangeCodeByProvider(code, provider);
|
||||
inFlightExchanges.set(exchangeId, exchangePromise);
|
||||
}
|
||||
|
||||
exchangePromise
|
||||
.then(async (data) => {
|
||||
if (cancelled) return;
|
||||
|
||||
exchangeMutation.mutate(search.code, {
|
||||
onSuccess: async (data) => {
|
||||
if (!data.token) {
|
||||
setErrorMessage("OAuth response missing token.");
|
||||
setErrorMessage("SSO response missing token.");
|
||||
return;
|
||||
}
|
||||
|
||||
consumedCodes.add(exchangeId);
|
||||
|
||||
localStorage.setItem("token", data.token);
|
||||
localStorage.setItem("username", data.username || "");
|
||||
localStorage.setItem("name", data.name || "");
|
||||
|
|
@ -72,38 +45,22 @@ function OAuthCallbackPage() {
|
|||
auth.login(data.username || "");
|
||||
|
||||
await navigate({ to: search.redirect || "/dashboard" });
|
||||
})
|
||||
.catch((error) => {
|
||||
if (cancelled) return;
|
||||
|
||||
if (axios.isAxiosError(error) && error.response?.status === 401) {
|
||||
consumedCodes.add(exchangeId);
|
||||
setErrorMessage("Mã đăng nhập đã hết hạn hoặc đã được sử dụng. Vui lòng đăng nhập lại.");
|
||||
return;
|
||||
}
|
||||
setErrorMessage("OAuth exchange failed.");
|
||||
})
|
||||
.finally(() => {
|
||||
inFlightExchanges.delete(exchangeId);
|
||||
if (!cancelled) {
|
||||
setIsExchanging(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [auth, navigate, search.code, search.provider, search.redirect]);
|
||||
},
|
||||
onError: () => {
|
||||
setErrorMessage("SSO exchange failed.");
|
||||
},
|
||||
});
|
||||
}, [auth, exchangeMutation, navigate, search.code, search.redirect]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-gradient-to-br from-background to-muted/20">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-xl">Đang xác thực OAuth</CardTitle>
|
||||
<CardTitle className="text-xl">Đang xác thực SSO</CardTitle>
|
||||
<CardDescription>Vui lòng đợi trong giây lát.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col items-center gap-4">
|
||||
{isExchanging && (
|
||||
{exchangeMutation.isPending && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<LoaderCircle className="w-4 h-4 animate-spin" />
|
||||
Đang trao đổi mã đăng nhập
|
||||
|
|
@ -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]
|
||||
|
|
@ -140,10 +137,11 @@ function AppsComponent() {
|
|||
return;
|
||||
}
|
||||
|
||||
const MsiFileIds = selectedRows.map((row: any) => row.original.id);
|
||||
|
||||
try {
|
||||
await deleteMutation.mutateAsync({ MsiFileIds });
|
||||
for (const row of selectedRows) {
|
||||
const { id } = row.original;
|
||||
await deleteMutation.mutateAsync(id);
|
||||
}
|
||||
toast.success("Xóa phần mềm thành công!");
|
||||
} catch (e) {
|
||||
toast.error("Xóa phần mềm thất bại!");
|
||||
|
|
@ -177,10 +175,12 @@ function AppsComponent() {
|
|||
if (!table) return;
|
||||
|
||||
const selectedRows = table.getSelectedRowModel().rows;
|
||||
const MsiFileIds = selectedRows.map((row: any) => row.original.id);
|
||||
|
||||
try {
|
||||
await deleteMutation.mutateAsync({ MsiFileIds });
|
||||
for (const row of selectedRows) {
|
||||
const { id } = row.original;
|
||||
await deleteMutation.mutateAsync(id);
|
||||
}
|
||||
toast.success("Xóa phần mềm từ server thành công!");
|
||||
if (table) {
|
||||
table.setRowSelection({});
|
||||
|
|
@ -191,21 +191,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 +235,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}
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
/>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
Sơ đồ
|
||||
</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 có thiết bị</h3>
|
||||
<p className="text-muted-foreground text-center max-w-sm">
|
||||
Phòng này chưa có 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" />
|
||||
Sơ đồ
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Sơ đồ chỉ hỗ trợ phòng <= 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" />
|
||||
Sơ đồ
|
||||
</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 có thiết bị</h3>
|
||||
<p className="text-muted-foreground text-center max-w-sm">
|
||||
Phòng này chưa có 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 có {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 có nhiều thiết bị. Bạn có 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 có 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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,9 +9,6 @@ import { LoaderCircle } from "lucide-react";
|
|||
import { useState } from "react";
|
||||
|
||||
export const Route = createFileRoute("/_auth/user/change-password/$userName/")({
|
||||
head: () => ({
|
||||
meta: [{ title: "Thay đổi mật khẩu" }],
|
||||
}),
|
||||
component: AdminChangePasswordComponent,
|
||||
loader: async ({ context, params }) => {
|
||||
context.breadcrumbs = [
|
||||
|
|
|
|||
|
|
@ -22,9 +22,6 @@ import { UserPlus, ArrowLeft, Save, Loader2 } from "lucide-react";
|
|||
import { toast } from "sonner";
|
||||
|
||||
export const Route = createFileRoute("/_auth/user/create/")({
|
||||
head: () => ({
|
||||
meta: [{ title: "Tạo người dùng mới" }],
|
||||
}),
|
||||
component: CreateUserComponent,
|
||||
loader: async ({ context }) => {
|
||||
context.breadcrumbs = [
|
||||
|
|
@ -62,8 +59,7 @@ function CreateUserComponent() {
|
|||
if (!formData.userName) {
|
||||
newErrors.userName = "Tên đăng nhập không được để trống";
|
||||
} else if (!validateUserName(formData.userName)) {
|
||||
newErrors.userName =
|
||||
"Tên đăng nhập chỉ cho phép chữ cái, số, dấu chấm và gạch dưới (3-20 ký tự)";
|
||||
newErrors.userName = "Tên đăng nhập chỉ cho phép chữ cái, số, dấu chấm và gạch dưới (3-20 ký tự)";
|
||||
}
|
||||
|
||||
// Validate name
|
||||
|
|
@ -110,8 +106,7 @@ function CreateUserComponent() {
|
|||
toast.success("Tạo tài khoản thành công!");
|
||||
navigate({ to: "/dashboard" }); // TODO: Navigate to user list page when it exists
|
||||
} catch (error: any) {
|
||||
const errorMessage =
|
||||
error.response?.data?.message || "Tạo tài khoản thất bại!";
|
||||
const errorMessage = error.response?.data?.message || "Tạo tài khoản thất bại!";
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
};
|
||||
|
|
@ -133,14 +128,15 @@ function CreateUserComponent() {
|
|||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">
|
||||
Tạo người dùng mới
|
||||
</h1>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Tạo người dùng mới</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Thêm tài khoản người dùng mới vào hệ thống
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => navigate({ to: "/user" })}>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => navigate({ to: "/user" })}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Quay lại
|
||||
</Button>
|
||||
|
|
@ -168,9 +164,7 @@ function CreateUserComponent() {
|
|||
<Input
|
||||
id="userName"
|
||||
value={formData.userName}
|
||||
onChange={(e) =>
|
||||
handleInputChange("userName", e.target.value)
|
||||
}
|
||||
onChange={(e) => handleInputChange("userName", e.target.value)}
|
||||
placeholder="Nhập tên đăng nhập (3-20 ký tự, chỉ chữ, số, . và _)"
|
||||
disabled={createMutation.isPending}
|
||||
className="h-10"
|
||||
|
|
@ -208,9 +202,7 @@ function CreateUserComponent() {
|
|||
id="password"
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(e) =>
|
||||
handleInputChange("password", e.target.value)
|
||||
}
|
||||
onChange={(e) => handleInputChange("password", e.target.value)}
|
||||
placeholder="Nhập mật khẩu (tối thiểu 6 ký tự)"
|
||||
disabled={createMutation.isPending}
|
||||
className="h-10"
|
||||
|
|
@ -228,17 +220,13 @@ function CreateUserComponent() {
|
|||
id="confirmPassword"
|
||||
type="password"
|
||||
value={formData.confirmPassword}
|
||||
onChange={(e) =>
|
||||
handleInputChange("confirmPassword", e.target.value)
|
||||
}
|
||||
onChange={(e) => handleInputChange("confirmPassword", e.target.value)}
|
||||
placeholder="Nhập lại mật khẩu"
|
||||
disabled={createMutation.isPending}
|
||||
className="h-10"
|
||||
/>
|
||||
{errors.confirmPassword && (
|
||||
<p className="text-sm text-destructive">
|
||||
{errors.confirmPassword}
|
||||
</p>
|
||||
<p className="text-sm text-destructive">{errors.confirmPassword}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,361 +0,0 @@
|
|||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
useGetRoleList,
|
||||
useGetRoomList,
|
||||
useGetUsersInfo,
|
||||
useUpdateUserInfo,
|
||||
useUpdateUserRole,
|
||||
} from "@/hooks/queries";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { SelectDialog } from "@/components/dialogs/select-dialog";
|
||||
import { ArrowLeft, Save } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import type { UserProfile } from "@/types/user-profile";
|
||||
|
||||
export const Route = createFileRoute("/_auth/user/edit/$userName/")({
|
||||
head: () => ({
|
||||
meta: [{ title: "Chỉnh sửa người dùng" }],
|
||||
}),
|
||||
component: EditUserComponent,
|
||||
loader: async ({ context, params }) => {
|
||||
context.breadcrumbs = [
|
||||
{ title: "Quản lý người dùng", path: "/user" },
|
||||
{
|
||||
title: `Chỉnh sửa thông tin người dùng ${params.userName}`,
|
||||
path: `/user/edit/${params.userName}`,
|
||||
},
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
function EditUserComponent() {
|
||||
const { userName } = Route.useParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { data: users = [], isLoading } = useGetUsersInfo();
|
||||
const { data: roomData = [], isLoading: roomsLoading } = useGetRoomList();
|
||||
const { data: roles = [], isLoading: rolesLoading } = useGetRoleList();
|
||||
const updateUserInfoMutation = useUpdateUserInfo();
|
||||
const updateUserRoleMutation = useUpdateUserRole();
|
||||
|
||||
const user = useMemo(() => {
|
||||
return users.find((u) => u.userName === userName) as
|
||||
| UserProfile
|
||||
| undefined;
|
||||
}, [users, userName]);
|
||||
|
||||
const [editForm, setEditForm] = useState({
|
||||
userName: "",
|
||||
name: "",
|
||||
});
|
||||
const [selectedRoleId, setSelectedRoleId] = useState<string>("");
|
||||
const [selectedRoomValues, setSelectedRoomValues] = useState<string[]>([]);
|
||||
const [isRoomDialogOpen, setIsRoomDialogOpen] = useState(false);
|
||||
|
||||
const roomOptions = useMemo(() => {
|
||||
const list = Array.isArray(roomData) ? roomData : [];
|
||||
return list
|
||||
.map((room: any) => {
|
||||
const rawValue =
|
||||
room.id ??
|
||||
room.roomId ??
|
||||
room.roomID ??
|
||||
room.Id ??
|
||||
room.ID ??
|
||||
room.RoomId ??
|
||||
room.RoomID ??
|
||||
room.name ??
|
||||
room.roomName ??
|
||||
room.RoomName ??
|
||||
"";
|
||||
const label =
|
||||
room.name ?? room.roomName ?? room.RoomName ?? (rawValue ? String(rawValue) : "");
|
||||
if (!rawValue || !label) return null;
|
||||
return { label: String(label), value: String(rawValue) };
|
||||
})
|
||||
.filter((item): item is { label: string; value: string } => !!item);
|
||||
}, [roomData]);
|
||||
|
||||
const roomLabelMap = useMemo(() => {
|
||||
return new Map(roomOptions.map((room) => [room.value, room.label]));
|
||||
}, [roomOptions]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
setEditForm({
|
||||
userName: user.userName ?? "",
|
||||
name: user.name ?? "",
|
||||
});
|
||||
setSelectedRoleId(user.roleId ? String(user.roleId) : "");
|
||||
setSelectedRoomValues(
|
||||
Array.isArray(user.accessRooms)
|
||||
? user.accessRooms.map((roomId) => String(roomId))
|
||||
: []
|
||||
);
|
||||
}, [user]);
|
||||
|
||||
const handleUpdateUserInfo = async () => {
|
||||
if (!user?.userId) {
|
||||
toast.error("Không tìm thấy userId để cập nhật.");
|
||||
return;
|
||||
}
|
||||
|
||||
const nextUserName = editForm.userName.trim();
|
||||
const nextName = editForm.name.trim();
|
||||
|
||||
if (!nextUserName || !nextName) {
|
||||
toast.error("Vui lòng nhập đầy đủ tên đăng nhập và họ tên.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const accessRooms = selectedRoomValues
|
||||
.map((value) => Number(value))
|
||||
.filter((value) => Number.isFinite(value));
|
||||
|
||||
if (
|
||||
selectedRoomValues.length > 0 &&
|
||||
accessRooms.length !== selectedRoomValues.length
|
||||
) {
|
||||
toast.error("Danh sách phòng không hợp lệ, vui lòng chọn lại.");
|
||||
return;
|
||||
}
|
||||
|
||||
await updateUserInfoMutation.mutateAsync({
|
||||
id: user.userId,
|
||||
data: {
|
||||
userName: nextUserName,
|
||||
name: nextName,
|
||||
accessRooms,
|
||||
},
|
||||
});
|
||||
toast.success("Cập nhật thông tin người dùng thành công!");
|
||||
} catch (error: any) {
|
||||
const message = error?.response?.data?.message || "Cập nhật thất bại!";
|
||||
toast.error(message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateUserRole = async () => {
|
||||
if (!user?.userId) {
|
||||
toast.error("Không tìm thấy userId để cập nhật role.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedRoleId) {
|
||||
toast.error("Vui lòng chọn vai trò.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await updateUserRoleMutation.mutateAsync({
|
||||
id: user.userId,
|
||||
data: { roleId: Number(selectedRoleId) },
|
||||
});
|
||||
toast.success("Cập nhật vai trò thành công!");
|
||||
} catch (error: any) {
|
||||
const message =
|
||||
error?.response?.data?.message || "Cập nhật vai trò thất bại!";
|
||||
toast.error(message);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="w-full px-6 py-8">
|
||||
<div className="flex items-center justify-center min-h-[320px]">
|
||||
<div className="text-muted-foreground">
|
||||
Đang tải thông tin người dùng...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="w-full px-6 py-8 space-y-4">
|
||||
<Button variant="outline" onClick={() => navigate({ to: "/user" })}>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Quay lại
|
||||
</Button>
|
||||
<div className="text-muted-foreground">
|
||||
Không tìm thấy người dùng cần chỉnh sửa.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full px-6 py-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">
|
||||
Chỉnh sửa người dùng
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Tài khoản: {user.userName}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => navigate({ to: "/user" })}>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Quay lại
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card className="shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle>Thông tin người dùng</CardTitle>
|
||||
<CardDescription>
|
||||
Cập nhật họ tên, username và danh sách phòng truy cập.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-userName">Tên đăng nhập</Label>
|
||||
<Input
|
||||
id="edit-userName"
|
||||
value={editForm.userName}
|
||||
onChange={(e) =>
|
||||
setEditForm((prev) => ({ ...prev, userName: e.target.value }))
|
||||
}
|
||||
disabled={updateUserInfoMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-name">Họ và tên</Label>
|
||||
<Input
|
||||
id="edit-name"
|
||||
value={editForm.name}
|
||||
onChange={(e) =>
|
||||
setEditForm((prev) => ({ ...prev, name: e.target.value }))
|
||||
}
|
||||
disabled={updateUserInfoMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Các phòng phụ trách</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedRoomValues.length > 0 ? (
|
||||
selectedRoomValues.map((value) => (
|
||||
<Badge key={value} variant="secondary">
|
||||
{roomLabelMap.get(value) ?? value}
|
||||
</Badge>
|
||||
))
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Chưa chọn phòng.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setIsRoomDialogOpen(true)}
|
||||
disabled={roomsLoading || updateUserInfoMutation.isPending}
|
||||
>
|
||||
Chọn phòng
|
||||
</Button>
|
||||
{roomsLoading && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Đang tải danh sách phòng...
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleUpdateUserInfo}
|
||||
disabled={updateUserInfoMutation.isPending}
|
||||
className="gap-2"
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
{updateUserInfoMutation.isPending
|
||||
? "Đang lưu..."
|
||||
: "Lưu thông tin"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle>Vai trò</CardTitle>
|
||||
<CardDescription>Cập nhật vai trò của người dùng.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2 max-w-md">
|
||||
<Label>Vai trò</Label>
|
||||
<Select
|
||||
value={selectedRoleId}
|
||||
onValueChange={setSelectedRoleId}
|
||||
disabled={rolesLoading || updateUserRoleMutation.isPending}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue
|
||||
placeholder={
|
||||
rolesLoading ? "Đang tải vai trò..." : "Chọn vai trò"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{roles.map((role) => (
|
||||
<SelectItem key={role.id} value={String(role.id)}>
|
||||
{role.roleName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleUpdateUserRole}
|
||||
disabled={updateUserRoleMutation.isPending || rolesLoading}
|
||||
className="gap-2"
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
{updateUserRoleMutation.isPending ? "Đang lưu..." : "Lưu vai trò"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<SelectDialog
|
||||
open={isRoomDialogOpen}
|
||||
onClose={() => setIsRoomDialogOpen(false)}
|
||||
title="Chọn phòng phụ trách"
|
||||
description="Chọn một hoặc nhiều phòng để gán quyền truy cập."
|
||||
items={roomOptions}
|
||||
selectedValues={selectedRoomValues}
|
||||
onConfirm={(values) => setSelectedRoomValues(values)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -10,13 +10,10 @@ import {
|
|||
} from "@/components/ui/tooltip";
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import { VersionTable } from "@/components/tables/version-table";
|
||||
import { Edit2, Settings, Shield, Trash2 } from "lucide-react";
|
||||
import { Edit2, Trash2, Shield } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export const Route = createFileRoute("/_auth/user/")({
|
||||
head: () => ({
|
||||
meta: [{ title: "Danh sách người dùng" }],
|
||||
}),
|
||||
component: RouteComponent,
|
||||
loader: async ({ context }) => {
|
||||
context.breadcrumbs = [
|
||||
|
|
@ -68,6 +65,21 @@ function RouteComponent() {
|
|||
<div className="flex justify-center">{Array.isArray(getValue()) ? (getValue() as number[]).join(", ") : "-"}</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "select",
|
||||
header: () => <div className="text-center whitespace-normal max-w-xs">Chọn</div>,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex justify-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={row.getIsSelected?.() ?? false}
|
||||
onChange={row.getToggleSelectedHandler?.()}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: () => (
|
||||
|
|
@ -75,78 +87,42 @@ function RouteComponent() {
|
|||
),
|
||||
cell: ({ row }) => (
|
||||
<div className="flex gap-2 justify-center items-center">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate({
|
||||
to: "/user/edit/$userName",
|
||||
params: { userName: row.original.userName },
|
||||
} as any);
|
||||
}}
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Đổi thông tin</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate({
|
||||
to: "/user/change-password/$userName",
|
||||
params: { userName: row.original.userName },
|
||||
} as any);
|
||||
}}
|
||||
>
|
||||
<Edit2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Đổi mật khẩu</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate({
|
||||
to: "/user/role/$roleId",
|
||||
params: { roleId: String(row.original.roleId) },
|
||||
} as any);
|
||||
}}
|
||||
>
|
||||
<Shield className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Xem quyền</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
if (!confirm("Bạn có chắc muốn xóa người dùng này?")) return;
|
||||
// Placeholder delete - implement API call as needed
|
||||
toast.success("Xóa người dùng (chưa thực thi API)");
|
||||
if (table) table.setRowSelection({});
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-red-500" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Xóa người dùng</TooltipContent>
|
||||
</Tooltip>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate({
|
||||
to: "/user/change-password/$userName",
|
||||
params: { userName: row.original.userName },
|
||||
} as any);
|
||||
}}
|
||||
>
|
||||
<Edit2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate({ to: "/user/role/$roleId", params: { roleId: String(row.original.roleId) } } as any);
|
||||
}}
|
||||
>
|
||||
<Shield className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
if (!confirm("Bạn có chắc muốn xóa người dùng này?")) return;
|
||||
// Placeholder delete - implement API call as needed
|
||||
toast.success("Xóa người dùng (chưa thực thi API)");
|
||||
if (table) table.setRowSelection({});
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-red-500" />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
enableSorting: false,
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import type { PermissionOnRole } from "@/types/permission";
|
|||
|
||||
export const Route = createFileRoute("/_auth/user/role/$roleId/")({
|
||||
head: () => ({
|
||||
meta: [{ title: "Quyền của người dùng" }]
|
||||
meta: [{ title: "Quyền của người dùng | AccessControl" }]
|
||||
}),
|
||||
component: ViewRolePermissionsComponent,
|
||||
loader: async ({ context, params }) => {
|
||||
|
|
|
|||
|
|
@ -120,20 +120,9 @@ export async function deleteRequiredFile(data: { MsiFileIds: number[] }): Promis
|
|||
|
||||
/**
|
||||
* Xóa file từ server
|
||||
* @param data - DownloadMsiRequest { MsiFileIds: number[] }
|
||||
* @param fileId - ID file
|
||||
*/
|
||||
export async function deleteFile(data: { MsiFileIds: number[] }): Promise<{ message: string }> {
|
||||
const response = await axios.delete(
|
||||
API_ENDPOINTS.APP_VERSION.DELETE_FILES,
|
||||
{ data }
|
||||
);
|
||||
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);
|
||||
export async function deleteFile(fileId: number): Promise<{ message: string }> {
|
||||
const response = await axios.delete(API_ENDPOINTS.APP_VERSION.DELETE_FILES(fileId));
|
||||
return response.data;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import axios from "@/config/axios";
|
||||
import { API_ENDPOINTS } from "@/config/api";
|
||||
import rawAxios from "axios";
|
||||
import type { LoginResquest, LoginResponse, CreateAccountRequest } from "@/types/auth";
|
||||
|
||||
/**
|
||||
|
|
@ -17,93 +16,25 @@ export async function login(credentials: LoginResquest): Promise<LoginResponse>
|
|||
}
|
||||
|
||||
/**
|
||||
* Build OAuth login URL by provider
|
||||
* @param provider - OAuth provider key (e.g. google, azuread)
|
||||
* Build SSO login URL
|
||||
* @param returnUrl - FE callback url
|
||||
*/
|
||||
export function buildOAuthLoginUrl(provider: string, returnUrl: string): string {
|
||||
const base = API_ENDPOINTS.AUTH.OAUTH_LOGIN(provider);
|
||||
const encoded = encodeURIComponent(returnUrl);
|
||||
return `${base}?returnUrl=${encoded}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Google OAuth login URL
|
||||
* @param returnUrl - FE callback url
|
||||
*/
|
||||
export function buildGoogleOAuthLoginUrl(returnUrl: string): string {
|
||||
return buildOAuthLoginUrl("google", returnUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Microsoft SSO login URL (legacy endpoint)
|
||||
* @param returnUrl - FE callback url
|
||||
*/
|
||||
export function buildMicrosoftSsoLoginUrl(returnUrl: string): string {
|
||||
export function buildSsoLoginUrl(returnUrl: string): string {
|
||||
const base = API_ENDPOINTS.AUTH.SSO_LOGIN;
|
||||
const encoded = encodeURIComponent(returnUrl);
|
||||
return `${base}?returnUrl=${encoded}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange one-time OAuth code for login payload
|
||||
* Exchange one-time code for login payload
|
||||
* @param code - one-time code
|
||||
*/
|
||||
export async function exchangeOAuthCode(code: string): Promise<LoginResponse> {
|
||||
try {
|
||||
const response = await rawAxios.post<LoginResponse>(
|
||||
API_ENDPOINTS.AUTH.OAUTH_EXCHANGE,
|
||||
{ code }
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
if (rawAxios.isAxiosError(error)) {
|
||||
const status = error.response?.status;
|
||||
if (status === 401 || status === 404 || status === 405) {
|
||||
const fallbackResponse = await rawAxios.post<LoginResponse>(
|
||||
API_ENDPOINTS.AUTH.SSO_EXCHANGE,
|
||||
{ code }
|
||||
);
|
||||
return fallbackResponse.data;
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy AzureAD SSO URL builder kept for backward compatibility.
|
||||
*/
|
||||
export function buildSsoLoginUrl(returnUrl: string): string {
|
||||
return buildOAuthLoginUrl("azuread", returnUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy SSO exchange alias kept for backward compatibility.
|
||||
*/
|
||||
export async function exchangeSsoCode(code: string): Promise<LoginResponse> {
|
||||
return exchangeOAuthCode(code);
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange one-time code by provider without breaking existing flows.
|
||||
* - azuread/microsoft: force legacy SSO exchange endpoint
|
||||
* - default: use OAuth exchange flow
|
||||
*/
|
||||
export async function exchangeCodeByProvider(
|
||||
code: string,
|
||||
provider?: string
|
||||
): Promise<LoginResponse> {
|
||||
const providerKey = (provider || "").toLowerCase();
|
||||
if (providerKey === "microsoft" || providerKey === "azuread") {
|
||||
const response = await rawAxios.post<LoginResponse>(
|
||||
API_ENDPOINTS.AUTH.SSO_EXCHANGE,
|
||||
{ code }
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
return exchangeOAuthCode(code);
|
||||
const response = await axios.post<LoginResponse>(
|
||||
API_ENDPOINTS.AUTH.SSO_EXCHANGE,
|
||||
{ code }
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,20 +1,6 @@
|
|||
import axios from "@/config/axios";
|
||||
import { API_ENDPOINTS } from "@/config/api";
|
||||
import type {
|
||||
UserProfile,
|
||||
UpdateUserInfoRequest,
|
||||
UpdateUserRoleRequest,
|
||||
UpdateUserInfoResponse,
|
||||
UpdateUserRoleResponse,
|
||||
} from "@/types/user-profile";
|
||||
|
||||
// Helper to extract data from wrapped or unwrapped response
|
||||
function extractData<T>(responseData: any): T {
|
||||
if (responseData && typeof responseData === "object" && "success" in responseData && "data" in responseData) {
|
||||
return responseData.data as T;
|
||||
}
|
||||
return responseData as T;
|
||||
}
|
||||
import type { UserProfile } from "@/types/user-profile";
|
||||
|
||||
/**
|
||||
* Lấy danh sách thông tin người dùng và chuyển sang camelCase keys
|
||||
|
|
@ -25,7 +11,6 @@ export async function getUsersInfo(): Promise<UserProfile[]> {
|
|||
const list = Array.isArray(response.data) ? response.data : [];
|
||||
|
||||
return list.map((u: any) => ({
|
||||
userId: u.id ?? u.Id ?? u.userId ?? u.UserId ?? undefined,
|
||||
userName: u.userName ?? u.UserName ?? "",
|
||||
name: u.name ?? u.Name ?? "",
|
||||
role: u.role ?? u.Role ?? "",
|
||||
|
|
@ -46,32 +31,4 @@ export async function getUsersInfo(): Promise<UserProfile[]> {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cập nhật thông tin người dùng
|
||||
*/
|
||||
export async function updateUserInfo(
|
||||
userId: number,
|
||||
data: UpdateUserInfoRequest
|
||||
): Promise<UpdateUserInfoResponse> {
|
||||
const response = await axios.put(
|
||||
API_ENDPOINTS.USER.UPDATE_INFO(userId),
|
||||
data
|
||||
);
|
||||
return extractData<UpdateUserInfoResponse>(response.data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cập nhật role người dùng
|
||||
*/
|
||||
export async function updateUserRole(
|
||||
userId: number,
|
||||
data: UpdateUserRoleRequest
|
||||
): Promise<UpdateUserRoleResponse> {
|
||||
const response = await axios.put(
|
||||
API_ENDPOINTS.USER.UPDATE_ROLE(userId),
|
||||
data
|
||||
);
|
||||
return extractData<UpdateUserRoleResponse>(response.data);
|
||||
}
|
||||
|
||||
export default { getUsersInfo, updateUserInfo, updateUserRole };
|
||||
export default { getUsersInfo };
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 và 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}>
|
||||
|
|
|
|||
|
|
@ -168,7 +168,7 @@ export function DashboardTemplate({
|
|||
variant={usageRange === "weekly" ? "default" : "outline"}
|
||||
onClick={() => setUsageRange("weekly")}
|
||||
>
|
||||
7 ngày
|
||||
7 ngay
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
|
|
@ -176,7 +176,7 @@ export function DashboardTemplate({
|
|||
variant={usageRange === "monthly" ? "default" : "outline"}
|
||||
onClick={() => setUsageRange("monthly")}
|
||||
>
|
||||
30 ngày
|
||||
30 ngay
|
||||
</Button>
|
||||
</div>
|
||||
</CardAction>
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ export const appSidebarSection = {
|
|||
code: AppSidebarSectionCode.DASHBOARD,
|
||||
icon: Home,
|
||||
permissions: [PermissionEnum.ALLOW_ALL],
|
||||
}
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
@ -40,13 +40,6 @@ export const appSidebarSection = {
|
|||
icon: Building,
|
||||
permissions: [PermissionEnum.VIEW_ROOM],
|
||||
},
|
||||
{
|
||||
title: "Điều khiển trực tiếp",
|
||||
url: "/remote-control",
|
||||
code: AppSidebarSectionCode.REMOTE_LIVE_CONTROL,
|
||||
icon: Monitor,
|
||||
permissions: [PermissionEnum.VIEW_REMOTE_CONTROL],
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
@ -102,6 +95,18 @@ export const appSidebarSection = {
|
|||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "Điều khiển từ xa",
|
||||
items: [
|
||||
{
|
||||
title: "Điều khiển trực tiếp",
|
||||
url: "/remote-control",
|
||||
code: AppSidebarSectionCode.REMOTE_LIVE_CONTROL,
|
||||
icon: Monitor,
|
||||
permissions: [PermissionEnum.ALLOW_ALL],
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "Audits",
|
||||
items: [
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ export interface CommandRegistry {
|
|||
commandContent: string;
|
||||
qoS: 0 | 1 | 2;
|
||||
isRetained: boolean;
|
||||
ttlMinutes: number;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ export type Version = {
|
|||
version: string;
|
||||
fileName: string;
|
||||
folderPath: string;
|
||||
syncFolder?: string;
|
||||
updatedAt?: string;
|
||||
requestUpdateAt?: string;
|
||||
isRequired: boolean;
|
||||
|
|
|
|||
|
|
@ -101,11 +101,6 @@ export enum PermissionEnum {
|
|||
AUDIT_OPERATION = 190,
|
||||
VIEW_AUDIT_LOGS = 191,
|
||||
|
||||
//REMOTE CONTROL
|
||||
REMOTE_CONTROL_OPERATION = 200,
|
||||
VIEW_REMOTE_CONTROL = 201,
|
||||
CONTROL_REMOTE = 202,
|
||||
|
||||
//Undefined
|
||||
UNDEFINED = 9999,
|
||||
|
||||
|
|
|
|||
|
|
@ -1,41 +1,11 @@
|
|||
export type UserProfile = {
|
||||
userId?: number;
|
||||
userName: string;
|
||||
name: string;
|
||||
role: string;
|
||||
roleId: number;
|
||||
accessRooms: string[];
|
||||
accessRooms: number[];
|
||||
createdAt?: string | null;
|
||||
createdBy?: string | null;
|
||||
updatedAt?: string | null;
|
||||
updatedBy?: string | null;
|
||||
};
|
||||
|
||||
export type UpdateUserInfoRequest = {
|
||||
name: string;
|
||||
userName: string;
|
||||
accessRooms?: number[];
|
||||
};
|
||||
|
||||
export type UpdateUserRoleRequest = {
|
||||
roleId: number;
|
||||
};
|
||||
|
||||
export type UpdateUserInfoResponse = {
|
||||
userId: number;
|
||||
userName: string;
|
||||
name: string;
|
||||
roleId: number;
|
||||
accessRooms: number[];
|
||||
updatedAt?: string | null;
|
||||
updatedBy?: string | null;
|
||||
};
|
||||
|
||||
export type UpdateUserRoleResponse = {
|
||||
userId: number;
|
||||
userName: string;
|
||||
roleId: number;
|
||||
roleName?: string | null;
|
||||
updatedAt?: string | null;
|
||||
updatedBy?: string | null;
|
||||
};
|
||||
|
|
@ -4,7 +4,6 @@
|
|||
"outDir": "./dist",
|
||||
"composite": true,
|
||||
"noEmit": false,
|
||||
"ignoreDeprecations": "6.0",
|
||||
"types": []
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user