security + reponsive

This commit is contained in:
2026-07-17 14:16:37 +07:00
parent 00e271fada
commit 57fc826ebf
16 changed files with 2124 additions and 2596 deletions

45
.env.example Normal file
View File

@@ -0,0 +1,45 @@
NODE_ENV=production
APP_PORT=3000
PORT=3000
TZ=Asia/Ho_Chi_Minh
APP_TIME_ZONE=Asia/Ho_Chi_Minh
# Database: never commit real credentials.
DB_SERVER=sql-server.internal
DB_USER=accmanager_app
DB_PASSWORD=replace-with-a-strong-database-password
DB_NAME=AccManager
DB_ENCRYPT=true
DB_TRUST_CERTIFICATE=false
DB_CONNECT_TIMEOUT=30000
# Generate a separate random value of at least 32 bytes and keep it stable.
# Changing this value without a key-rotation migration will make stored
# application credentials unreadable.
DATA_ENCRYPTION_SECRET=replace-with-at-least-32-random-bytes
BCRYPT_ROUNDS=12
# Public HTTPS origin used by email links, CORS and secure cookies.
APP_BASE_URL=https://accmanager.example.internal
CORS_ALLOWED_ORIGINS=https://accmanager.example.internal
COOKIE_SECURE=true
TRUST_PROXY_HOPS=1
SESSION_TTL_HOURS=12
REMEMBER_SESSION_TTL_DAYS=14
ALLOW_SELF_REGISTRATION=false
# Only needed once when bootstrapping an empty database. Remove the password
# from the environment after the first administrator has been created.
INITIAL_ADMIN_USERNAME=admin
INITIAL_ADMIN_PASSWORD=
INITIAL_ADMIN_EMAIL=admin@example.internal
SMTP_HOST=
SMTP_PORT=587
SMTP_SECURE=false
SMTP_REQUIRE_TLS=true
SMTP_USER=
SMTP_PASS=
SMTP_FROM=
EMAIL_VERIFY_TOKEN_TTL_MINUTES=30
PASSWORD_RESET_TOKEN_TTL_MINUTES=30

3
.gitignore vendored
View File

@@ -1,5 +1,8 @@
node_modules node_modules
.env .env
.env.*
!.env.example
DEPLOYMENT_GUIDE.md
docs-output docs-output
scripts scripts
.codex-server.err.log .codex-server.err.log

View File

@@ -1,296 +0,0 @@
# 📘 Hướng dẫn Triển khai AccManager
Tài liệu này hướng dẫn chi tiết cách build Docker image, push lên registry, và triển khai trên server.
---
## 📋 Mục lục
1. [Chuẩn bị](#chuẩn-bị)
2. [Máy DEV: Build & Push Image](#máy-dev-build--push-image)
3. [Máy Server: Pull & Deploy](#máy-server-pull--deploy)
4. [Kiểm tra & Troubleshoot](#kiểm-tra--troubleshoot)
5. [Cập nhật bản mới](#cập-nhật-bản-mới)
6. [Public Domain qua Nginx Proxy Manager](#public-domain-qua-nginx-proxy-manager)
---
## 🔧 Chuẩn bị
### Tài khoản & Biến môi trường
1. **Docker Hub Account**: Tạo account tại https://hub.docker.com
- Username: `toiiiiday` (dùng username của bạn)
- Repository: `accmanager`
2. **File .env trên máy dev** - Kiểm tra nội dung:
```env
NODE_ENV=production
APP_PORT=3000
DOCKER_IMAGE=toiiiiday/accmanager:1.0.1
PORT=3000
DB_SERVER=172.20.235.176
DB_USER=sa
DB_PASSWORD=robotics@2022
DB_NAME=AccManager
DB_ENCRYPT=false
DB_TRUST_CERTIFICATE=true
DB_CONNECT_TIMEOUT=30000
BCRYPT_ROUNDS=12
```
3. **Thư mục trên server** - SSH vào server tạo:
```bash
mkdir -p ~/accmanager
cd ~/accmanager
```
---
## 🖥️ Máy DEV: Build & Push Image
### Bước 1: Chọn version mới
Mỗi lần sửa code và muốn deploy, chọn một tag mới theo thứ tự tăng dần.
Ví dụ:
- Bản cũ đang chạy: `1.0.2`
- Bản mới sau khi sửa code: `1.0.3`
### Bước 2: Build image mới (trên máy DEV)
```powershell
cd D:\RoboticsSource\AccManager
docker build -t toiiiiday/accmanager:"version" .
```
### Bước 3: Push image mới lên Docker Hub
```powershell
docker push toiiiiday/accmanager:"version"
```
### Bước 4: Kiểm tra image đã có trên registry
```powershell
docker image ls | findstr toiiiiday/accmanager
```
Hoặc kiểm tra trên Docker Hub:
https://hub.docker.com/r/toiiiiday/accmanager/tags
### Bước 5: Cập nhật .env
Sửa dòng `DOCKER_IMAGE`:
```
DOCKER_IMAGE=toiiiiday/accmanager:"version"
```
Mẹo PowerShell (cập nhật nhanh):
```powershell
(Get-Content .env) -replace '^DOCKER_IMAGE=.*', 'DOCKER_IMAGE=toiiiiday/accmanager:1.0.3' | Set-Content .env
```
### Bước 6: Copy confi lên server
Từ máy dev:
```powershell
scp .env robotics@172.20.235.176:~/accmanager/.env
scp docker-compose.yml robotics@172.20.235.176:~/accmanager/docker-compose.yml
scp docker-compose.image.yml robotics@172.20.235.176:~/accmanager/docker-compose.image.yml
```
---
## 🐧 Máy Server: Pull & Deploy
### Bước 1: SSH vào server
```bash
ssh robotics@172.20.235.176
```
### Bước 2: Vào thư mục deploy
```bash
cd ~/accmanager
```
### Bước 3: Pull image mới và chạy lại container
```bash
docker compose --env-file .env -f docker-compose.image.yml pull accmanager
docker compose --env-file .env -f docker-compose.image.yml up -d accmanager
```
Kiểm tra trạng thái:
```bash
docker compose -f docker-compose.image.yml ps
```
Xem log:
```bash
docker compose -f docker-compose.image.yml logs -f accmanager
```
---
## ✅ Kiểm tra & Troubleshoot
### Kiểm tra app chạy OK
```bash
# Kiểm tra container đang running
docker compose -f docker-compose.image.yml ps
# Xem log (tìm "Server running")
docker compose -f docker-compose.image.yml logs --tail=50 accmanager
### Nếu gặp lỗi
**Lỗi: "image not found"**
- Kiểm tra: `cat .env | grep DOCKER_IMAGE`
- Đảm bảo image đã push lên Docker Hub, kiểm tra: https://hub.docker.com/r/toiiiiday/accmanager
**Lỗi: "connection refused"**
- Kiểm tra DB Server có chạy: `ssh robotics@172.20.235.176`
- Kiểm tra DB credentials trong .env
**Lỗi: Container restart liên tục**
- Xem log: `docker compose -f docker-compose.image.yml logs --tail=100 accmanager`
---
## 🔄 Cập nhật bản mới
Áp dụng đúng 8 bước sau cho mỗi lần sửa code:
### Trên máy DEV
1. Chọn version mới (ví dụ `1.0.3`)
2. Build:
```powershell
docker build -t toiiiiday/accmanager:"version" .
```
3. Push:
```powershell
docker push toiiiiday/accmanager:"version"
```
4. Cập nhật `.env`:
```env
DOCKER_IMAGE=toiiiiday/accmanager:"version"
```
5. Copy `.env` lên server:
```powershell
scp .env robotics@172.20.235.176:~/accmanager/.env
```
### Trên máy SERVER
6. SSH và vào thư mục deploy:
```bash
ssh robotics@172.20.235.176
cd ~/accmanager
```
7. Pull + Up:
```bash
docker compose --env-file .env -f docker-compose.image.yml pull accmanager
docker compose --env-file .env -f docker-compose.image.yml up -d accmanager
```
8. Kiểm tra bản mới đã chạy:
```bash
docker compose -f docker-compose.image.yml ps
docker compose -f docker-compose.image.yml logs --tail=50 accmanager
```
### Có cần gắn tag/version cho mỗi phiên bản mới không?
**Có, nên làm bắt buộc cho production.**
Lý do:
1. Tránh đè image cũ và tránh nhầm lẫn khi deploy.
2. Rollback nhanh về bản ổn định trước đó.
3. Truy vết được bản code nào đang chạy trên server.
4. Tránh rủi ro do dùng `latest` (khó kiểm soát).
Quy ước khuyến nghị:
- `1.0.2` -> fix nhỏ
- `1.1.0` -> thêm tính năng
- `2.0.0` -> thay đổi lớn/breaking
Ví dụ rollback về bản cũ `1.0.2`:
```env
DOCKER_IMAGE=toiiiiday/accmanager:1.0.2
```
```bash
docker compose --env-file .env -f docker-compose.image.yml pull accmanager
docker compose --env-file .env -f docker-compose.image.yml up -d accmanager
```
---
## 🌐 Public Domain qua Nginx Proxy Manager
### Chuẩn bị
1. **Domain**
- Trỏ DNS A record về IP public nơi đặt Nginx Proxy Manager
2. **Firewall/Router**
- Mở inbound port 80, 443 từ Internet
- Port 3000 chỉ nội bộ (không public)
---
## 📝 Các file liên quan
- `docker-compose.yml` - Build local
- `docker-compose.image.yml` - Pull & run từ registry
- `.env` - Biến môi trường
- `.dockerignore` - Ignore file khi build
- `Dockerfile` - Config image
- `deploy-dev.ps1` - Script build & push (Windows)
- `deploy-server.sh` - Script pull & deploy (Linux)
---
## 🎯 Tóm tắt quy trình
```
Máy DEV
├─ Chỉnh sửa code
├─ docker build -t toiiiiday/accmanager:X .
├─ docker push toiiiiday/accmanager:X
├─ sửa DOCKER_IMAGE trong .env
└─ scp .env server (copy env)
Máy Server
├─ ssh vào server
├─ cd ~/accmanager
├─ docker compose pull accmanager
└─ docker compose up -d accmanager
Nginx Proxy Manager
└─ Forward từ domain → http://172.20.235.176:3000
```
---
## 💡 Mẹo
1. **Luôn tăng version tag**: 1.0.1 → 1.0.2 → 1.0.3
2. **Rollback nhanh**: Chỉ cần đổi DOCKER_IMAGE trong .env sang tag cũ rồi deploy lại
3. **Giữ log**: `docker compose logs --tail=1000 > backup.log`
4. **Restart container**: `docker compose -f docker-compose.image.yml restart accmanager`
5. **Xóa container cũ**: `docker compose -f docker-compose.image.yml down`
6. **Không dùng `latest` cho production**: luôn deploy bằng tag cụ thể
---
**Cần giúp? Xem log chi tiết:**
```bash
docker compose -f docker-compose.image.yml logs --tail=200 accmanager
```

View File

@@ -1,4 +1,4 @@
FROM node:20-bookworm-slim FROM node:22-bookworm-slim
WORKDIR /app WORKDIR /app

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,6 @@
-- =========================================== -- ===========================================
-- SQL Server Setup Script for AccManager -- SQL Server Setup Script for AccManager
-- Database: AccManager -- Database: AccManager
-- Server: 172.20.235.176
-- =========================================== -- ===========================================
-- Create Database -- Create Database
@@ -39,6 +38,24 @@ BEGIN
PRINT 'Table Users created successfully.'; PRINT 'Table Users created successfully.';
END END
-- Server-side authentication sessions. Only SHA-256 token hashes are stored.
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AuthSessions')
BEGIN
CREATE TABLE AuthSessions (
SessionId BIGINT PRIMARY KEY IDENTITY(1,1),
TokenHash CHAR(64) UNIQUE NOT NULL,
UserId INT NOT NULL,
ExpiresAt DATETIME2 NOT NULL,
CreatedAt DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME(),
UserAgent NVARCHAR(500),
IpAddress NVARCHAR(64),
FOREIGN KEY (UserId) REFERENCES Users(UserId) ON DELETE CASCADE
);
CREATE INDEX IX_AuthSessions_UserId ON AuthSessions(UserId);
CREATE INDEX IX_AuthSessions_ExpiresAt ON AuthSessions(ExpiresAt);
PRINT 'Table AuthSessions created successfully.';
END
-- =========================================== -- ===========================================
-- 2. CREATE APPLICATIONS TABLE -- 2. CREATE APPLICATIONS TABLE
-- =========================================== -- ===========================================
@@ -67,7 +84,7 @@ BEGIN
UserId INT NOT NULL, UserId INT NOT NULL,
AppId INT NOT NULL, AppId INT NOT NULL,
AccountUsername NVARCHAR(100), AccountUsername NVARCHAR(100),
AccountPassword NVARCHAR(255), AccountPassword NVARCHAR(2048),
Email NVARCHAR(100), Email NVARCHAR(100),
AccessLevel NVARCHAR(50), AccessLevel NVARCHAR(50),
Status NVARCHAR(20) DEFAULT 'Active', Status NVARCHAR(20) DEFAULT 'Active',
@@ -80,6 +97,11 @@ BEGIN
PRINT 'Table Accounts created successfully.'; PRINT 'Table Accounts created successfully.';
END END
IF COL_LENGTH('dbo.Accounts', 'AccountPassword') IS NOT NULL
BEGIN
ALTER TABLE Accounts ALTER COLUMN AccountPassword NVARCHAR(2048) NULL;
END
-- =========================================== -- ===========================================
-- 4. CREATE ASSET INVENTORY TABLE -- 4. CREATE ASSET INVENTORY TABLE
-- =========================================== -- ===========================================

View File

@@ -10,19 +10,31 @@ services:
PORT: 3000 PORT: 3000
TZ: ${TZ:-Asia/Ho_Chi_Minh} TZ: ${TZ:-Asia/Ho_Chi_Minh}
APP_TIME_ZONE: ${APP_TIME_ZONE:-Asia/Ho_Chi_Minh} APP_TIME_ZONE: ${APP_TIME_ZONE:-Asia/Ho_Chi_Minh}
DB_SERVER: ${DB_SERVER:-172.20.235.176} DB_SERVER: ${DB_SERVER:?DB_SERVER is required}
DB_USER: ${DB_USER:-sa} DB_USER: ${DB_USER:?DB_USER is required}
DB_PASSWORD: ${DB_PASSWORD:-changeme} DB_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required}
DB_NAME: ${DB_NAME:-AccManager} DB_NAME: ${DB_NAME:-AccManager}
DB_ENCRYPT: ${DB_ENCRYPT:-false} DB_ENCRYPT: ${DB_ENCRYPT:-false}
DB_TRUST_CERTIFICATE: ${DB_TRUST_CERTIFICATE:-true} DB_TRUST_CERTIFICATE: ${DB_TRUST_CERTIFICATE:-true}
DB_CONNECT_TIMEOUT: ${DB_CONNECT_TIMEOUT:-30000} DB_CONNECT_TIMEOUT: ${DB_CONNECT_TIMEOUT:-30000}
BCRYPT_ROUNDS: ${BCRYPT_ROUNDS:-12} BCRYPT_ROUNDS: ${BCRYPT_ROUNDS:-12}
APP_BASE_URL: ${APP_BASE_URL:-http://localhost:3000} DATA_ENCRYPTION_SECRET: ${DATA_ENCRYPTION_SECRET:?DATA_ENCRYPTION_SECRET is required}
APP_BASE_URL: ${APP_BASE_URL:?APP_BASE_URL is required}
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-}
COOKIE_SECURE: ${COOKIE_SECURE:-true}
SESSION_TTL_HOURS: ${SESSION_TTL_HOURS:-12}
REMEMBER_SESSION_TTL_DAYS: ${REMEMBER_SESSION_TTL_DAYS:-14}
ALLOW_SELF_REGISTRATION: ${ALLOW_SELF_REGISTRATION:-false}
TRUST_PROXY_HOPS: ${TRUST_PROXY_HOPS:-1}
INITIAL_ADMIN_USERNAME: ${INITIAL_ADMIN_USERNAME:-admin}
INITIAL_ADMIN_PASSWORD: ${INITIAL_ADMIN_PASSWORD:-}
INITIAL_ADMIN_EMAIL: ${INITIAL_ADMIN_EMAIL:-admin@accmanager.local}
SMTP_HOST: ${SMTP_HOST:-} SMTP_HOST: ${SMTP_HOST:-}
SMTP_PORT: ${SMTP_PORT:-587} SMTP_PORT: ${SMTP_PORT:-587}
SMTP_SECURE: ${SMTP_SECURE:-false} SMTP_SECURE: ${SMTP_SECURE:-false}
SMTP_REQUIRE_TLS: ${SMTP_REQUIRE_TLS:-true}
SMTP_USER: ${SMTP_USER:-} SMTP_USER: ${SMTP_USER:-}
SMTP_PASS: ${SMTP_PASS:-} SMTP_PASS: ${SMTP_PASS:-}
SMTP_FROM: ${SMTP_FROM:-} SMTP_FROM: ${SMTP_FROM:-}
EMAIL_VERIFY_TOKEN_TTL_MINUTES: ${EMAIL_VERIFY_TOKEN_TTL_MINUTES:-30} EMAIL_VERIFY_TOKEN_TTL_MINUTES: ${EMAIL_VERIFY_TOKEN_TTL_MINUTES:-30}
PASSWORD_RESET_TOKEN_TTL_MINUTES: ${PASSWORD_RESET_TOKEN_TTL_MINUTES:-30}

View File

@@ -12,19 +12,31 @@ services:
PORT: 3000 PORT: 3000
TZ: ${TZ:-Asia/Ho_Chi_Minh} TZ: ${TZ:-Asia/Ho_Chi_Minh}
APP_TIME_ZONE: ${APP_TIME_ZONE:-Asia/Ho_Chi_Minh} APP_TIME_ZONE: ${APP_TIME_ZONE:-Asia/Ho_Chi_Minh}
DB_SERVER: ${DB_SERVER:-172.20.235.176} DB_SERVER: ${DB_SERVER:?DB_SERVER is required}
DB_USER: ${DB_USER:-sa} DB_USER: ${DB_USER:?DB_USER is required}
DB_PASSWORD: ${DB_PASSWORD:-changeme} DB_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required}
DB_NAME: ${DB_NAME:-AccManager} DB_NAME: ${DB_NAME:-AccManager}
DB_ENCRYPT: ${DB_ENCRYPT:-false} DB_ENCRYPT: ${DB_ENCRYPT:-false}
DB_TRUST_CERTIFICATE: ${DB_TRUST_CERTIFICATE:-true} DB_TRUST_CERTIFICATE: ${DB_TRUST_CERTIFICATE:-true}
DB_CONNECT_TIMEOUT: ${DB_CONNECT_TIMEOUT:-30000} DB_CONNECT_TIMEOUT: ${DB_CONNECT_TIMEOUT:-30000}
BCRYPT_ROUNDS: ${BCRYPT_ROUNDS:-12} BCRYPT_ROUNDS: ${BCRYPT_ROUNDS:-12}
APP_BASE_URL: ${APP_BASE_URL:-http://localhost:3000} DATA_ENCRYPTION_SECRET: ${DATA_ENCRYPTION_SECRET:?DATA_ENCRYPTION_SECRET is required}
APP_BASE_URL: ${APP_BASE_URL:?APP_BASE_URL is required}
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-}
COOKIE_SECURE: ${COOKIE_SECURE:-true}
SESSION_TTL_HOURS: ${SESSION_TTL_HOURS:-12}
REMEMBER_SESSION_TTL_DAYS: ${REMEMBER_SESSION_TTL_DAYS:-14}
ALLOW_SELF_REGISTRATION: ${ALLOW_SELF_REGISTRATION:-false}
TRUST_PROXY_HOPS: ${TRUST_PROXY_HOPS:-1}
INITIAL_ADMIN_USERNAME: ${INITIAL_ADMIN_USERNAME:-admin}
INITIAL_ADMIN_PASSWORD: ${INITIAL_ADMIN_PASSWORD:-}
INITIAL_ADMIN_EMAIL: ${INITIAL_ADMIN_EMAIL:-admin@accmanager.local}
SMTP_HOST: ${SMTP_HOST:-} SMTP_HOST: ${SMTP_HOST:-}
SMTP_PORT: ${SMTP_PORT:-587} SMTP_PORT: ${SMTP_PORT:-587}
SMTP_SECURE: ${SMTP_SECURE:-false} SMTP_SECURE: ${SMTP_SECURE:-false}
SMTP_REQUIRE_TLS: ${SMTP_REQUIRE_TLS:-true}
SMTP_USER: ${SMTP_USER:-} SMTP_USER: ${SMTP_USER:-}
SMTP_PASS: ${SMTP_PASS:-} SMTP_PASS: ${SMTP_PASS:-}
SMTP_FROM: ${SMTP_FROM:-} SMTP_FROM: ${SMTP_FROM:-}
EMAIL_VERIFY_TOKEN_TTL_MINUTES: ${EMAIL_VERIFY_TOKEN_TTL_MINUTES:-30} EMAIL_VERIFY_TOKEN_TTL_MINUTES: ${EMAIL_VERIFY_TOKEN_TTL_MINUTES:-30}
PASSWORD_RESET_TOKEN_TTL_MINUTES: ${PASSWORD_RESET_TOKEN_TTL_MINUTES:-30}

2705
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,6 +6,8 @@
"scripts": { "scripts": {
"start": "node backend/server.js", "start": "node backend/server.js",
"dev": "nodemon backend/server.js", "dev": "nodemon backend/server.js",
"test": "node --test",
"check": "node --check backend/server.js && node --check public/js/app.js && npm audit",
"build:css": "tailwindcss -c tailwind.config.js -i ./public/css/tailwind.css -o ./public/css/main.css --minify", "build:css": "tailwindcss -c tailwind.config.js -i ./public/css/tailwind.css -o ./public/css/main.css --minify",
"watch:css": "tailwindcss -c tailwind.config.js -i ./public/css/tailwind.css -o ./public/css/main.css --watch" "watch:css": "tailwindcss -c tailwind.config.js -i ./public/css/tailwind.css -o ./public/css/main.css --watch"
}, },
@@ -17,15 +19,20 @@
], ],
"author": "", "author": "",
"license": "MIT", "license": "MIT",
"engines": {
"node": ">=22.0.0"
},
"dependencies": { "dependencies": {
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
"express": "^4.18.2", "express": "^5.2.1",
"mssql": "^9.1.1", "express-rate-limit": "^8.6.0",
"multer": "^2.1.1", "helmet": "^8.3.0",
"nodemailer": "^8.0.4", "mssql": "^12.7.0",
"xlsx": "^0.18.5" "multer": "^2.2.0",
"nodemailer": "^9.0.3",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/container-queries": "^0.1.1", "@tailwindcss/container-queries": "^0.1.1",

View File

@@ -150,6 +150,89 @@ textarea {
font-size: 1rem; font-size: 1rem;
} }
/* Keep the asset and consumable toolbars compact so the data remains
visible on tablets and phones. */
.asset-borrows-page .compact-page-actions,
.consumable-exports-page .compact-page-actions,
.assets-page .asset-header-actions,
.consumables-page .consumable-header-actions {
display: flex;
width: 100%;
max-width: 100%;
flex-flow: row nowrap;
gap: 0.5rem;
overflow-x: auto;
overscroll-behavior-x: contain;
padding: 0.125rem 0.125rem 0.375rem;
scrollbar-width: thin;
-webkit-overflow-scrolling: touch;
}
.asset-borrows-page .compact-page-actions > button,
.consumable-exports-page .compact-page-actions > button,
.assets-page .asset-header-actions > button,
.consumables-page .consumable-header-actions > button {
width: auto;
min-width: max-content;
min-height: 2.5rem;
flex: 0 0 auto;
padding-top: 0.5rem;
padding-bottom: 0.5rem;
white-space: nowrap;
}
.compact-page-filters {
display: grid !important;
align-items: end !important;
gap: 0.625rem !important;
padding: 0.625rem;
border: 1px solid #e2e8f0;
border-radius: 0.75rem;
background: rgb(248 250 252 / 0.82);
}
.compact-page-filters.asset-borrow-filter-bar {
grid-template-columns: minmax(10rem, 0.35fr) minmax(0, 1fr);
}
.compact-page-filters.asset-filter-bar {
grid-template-columns: minmax(9rem, 0.3fr) minmax(14rem, 1fr) max-content;
}
.compact-page-filters.consumable-filter-bar {
grid-template-columns: minmax(8rem, 0.25fr) minmax(8rem, 0.25fr) minmax(14rem, 1fr);
}
.compact-page-filters.consumable-export-filter-bar {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.compact-page-filters > div {
width: auto;
min-width: 0;
flex-direction: column;
align-items: stretch;
gap: 0.25rem;
}
.compact-page-filters input,
.compact-page-filters select {
width: 100%;
min-width: 0;
min-height: 2.5rem;
font-size: 0.875rem;
line-height: 1.25rem;
}
.compact-page-filters .compact-filter-action {
width: auto;
min-width: max-content;
min-height: 2.5rem;
align-self: end;
justify-content: center;
white-space: nowrap;
}
.dashboard-stats, .dashboard-stats,
.apps-stats { .apps-stats {
gap: 0.75rem; gap: 0.75rem;
@@ -374,6 +457,29 @@ textarea {
grid-template-columns: minmax(0, 1fr) !important; grid-template-columns: minmax(0, 1fr) !important;
} }
.compact-page-filters.asset-borrow-filter-bar {
grid-template-columns: minmax(0, 1fr);
}
.compact-page-filters.asset-filter-bar {
grid-template-columns: minmax(0, 1fr) max-content;
}
.compact-page-filters.asset-filter-bar .compact-filter-search {
grid-column: 1 / -1;
grid-row: 2;
}
.compact-page-filters.consumable-filter-bar,
.compact-page-filters.consumable-export-filter-bar {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.compact-page-filters.consumable-filter-bar .compact-filter-search,
.compact-page-filters.consumable-export-filter-bar .compact-filter-search {
grid-column: 1 / -1;
}
#mainContent table.mobile-card-table:not(.keep-table-mobile) > tbody { #mainContent table.mobile-card-table:not(.keep-table-mobile) > tbody {
padding: 0.625rem; padding: 0.625rem;
} }

View File

@@ -1,6 +1,27 @@
// VaultSentinel - Account Management Application // VaultSentinel - Account Management Application
// Main JavaScript functionality // Main JavaScript functionality
const authenticatedFetch = window.fetch.bind(window);
let authRedirectInProgress = false;
window.fetch = async (input, init = {}) => {
const response = await authenticatedFetch(input, {
...init,
credentials: init.credentials || 'same-origin'
});
const requestUrl = typeof input === 'string' ? input : String(input?.url || '');
const isAuthRequest = requestUrl.includes('/api/auth/login')
|| requestUrl.includes('/api/auth/session')
|| requestUrl.includes('/api/auth/logout');
if (response.status === 401 && !isAuthRequest && !authRedirectInProgress) {
authRedirectInProgress = true;
localStorage.removeItem('currentUser');
window.location.replace('../pages/login.html?reason=session-expired');
}
return response;
};
const APP_TIME_ZONE = 'Asia/Ho_Chi_Minh'; const APP_TIME_ZONE = 'Asia/Ho_Chi_Minh';
const APP_DATE_FORMATTER = new Intl.DateTimeFormat('vi-VN', { const APP_DATE_FORMATTER = new Intl.DateTimeFormat('vi-VN', {
timeZone: APP_TIME_ZONE, timeZone: APP_TIME_ZONE,
@@ -220,10 +241,7 @@ class AccountManager {
} }
getAuthHeaders(includeJson = false) { getAuthHeaders(includeJson = false) {
const headers = { const headers = {};
'x-user-id': String(this.getUserId()),
'x-user-role': this.getCurrentUserRole()
};
if (includeJson) { if (includeJson) {
headers['Content-Type'] = 'application/json'; headers['Content-Type'] = 'application/json';
@@ -463,6 +481,15 @@ class AccountManager {
} }
} }
async fetchAccountSecret(accountId) {
const response = await fetch(`${this.apiBase}/accounts/${accountId}/secret`, { cache: 'no-store' });
const data = await response.json();
if (!response.ok || !data.success) {
throw new Error(data.message || 'Unable to reveal stored credential');
}
return String(data.password || '');
}
async fetchUsers() { async fetchUsers() {
try { try {
const res = await fetch(`${this.apiBase}/users`); const res = await fetch(`${this.apiBase}/users`);
@@ -2502,11 +2529,17 @@ class AccountManager {
return `${value.slice(0, 3)}*****`; return `${value.slice(0, 3)}*****`;
} }
handleLogout() { async handleLogout() {
if (confirm('Are you sure you want to logout?')) { if (confirm('Are you sure you want to logout?')) {
try {
await fetch(`${this.apiBase}/auth/logout`, { method: 'POST' });
} catch (error) {
console.error('Logout request failed:', error);
} finally {
this.saveToStorage('currentUser', null); this.saveToStorage('currentUser', null);
localStorage.clear(); localStorage.removeItem('currentUser');
window.location.href = '../pages/login.html'; window.location.replace('../pages/login.html');
}
} }
} }
@@ -2643,13 +2676,14 @@ class AccountManager {
<tbody class="divide-y divide-slate-100 accounts-table-body"> <tbody class="divide-y divide-slate-100 accounts-table-body">
${pageInfo.data.map(acc => { ${pageInfo.data.map(acc => {
const isOwnAccount = acc.UserId == currentUserId; const isOwnAccount = acc.UserId == currentUserId;
const canAccessAccount = isOwnAccount || this.getCurrentUserRole() === 'admin';
const accountUsername = acc.AccountUsername || '-'; const accountUsername = acc.AccountUsername || '-';
const displayAccountUsername = isOwnAccount const displayAccountUsername = canAccessAccount
? accountUsername ? accountUsername
: this.maskForeignAccountUsername(accountUsername); : this.maskForeignAccountUsername(accountUsername);
const createdDate = this.formatDateTime(acc.CreatedDate); const createdDate = this.formatDateTime(acc.CreatedDate);
const updatedDate = this.formatDateTime(acc.UpdatedDate); const updatedDate = this.formatDateTime(acc.UpdatedDate);
const actionContent = isOwnAccount const actionContent = canAccessAccount
? `<button class="p-1.5 text-slate-400 transition-colors view-account hover:text-slate-600" data-account-id="${acc.AccountId}" title="View Details"> ? `<button class="p-1.5 text-slate-400 transition-colors view-account hover:text-slate-600" data-account-id="${acc.AccountId}" title="View Details">
<span class="material-symbols-outlined text-lg">info</span> <span class="material-symbols-outlined text-lg">info</span>
</button> </button>
@@ -4244,7 +4278,7 @@ class AccountManager {
<h1 class="text-2xl font-extrabold text-on-surface tracking-tight">Mượn/Trả tài sản</h1> <h1 class="text-2xl font-extrabold text-on-surface tracking-tight">Mượn/Trả tài sản</h1>
<p class="text-sm text-on-surface-variant">Theo dõi trạng thái đơn mượn và đơn trả tài sản.</p> <p class="text-sm text-on-surface-variant">Theo dõi trạng thái đơn mượn và đơn trả tài sản.</p>
</div> </div>
<div class="flex flex-wrap items-center gap-2"> <div class="compact-page-actions flex flex-wrap items-center gap-2">
<button <button
id="addAssetBorrowRequestBtn" id="addAssetBorrowRequestBtn"
class="bg-primary text-on-primary px-4 py-2 rounded-lg text-xs font-bold flex items-center gap-1.5 transition-all active:scale-95 hover:bg-primary-dim" class="bg-primary text-on-primary px-4 py-2 rounded-lg text-xs font-bold flex items-center gap-1.5 transition-all active:scale-95 hover:bg-primary-dim"
@@ -4272,7 +4306,7 @@ class AccountManager {
</div> </div>
</div> </div>
<div class="page-filters flex items-center gap-3 mb-4 shrink-0"> <div class="page-filters compact-page-filters asset-borrow-filter-bar flex items-center gap-3 mb-4 shrink-0">
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
<span class="text-[10px] font-bold uppercase text-on-surface-variant">Danh mục</span> <span class="text-[10px] font-bold uppercase text-on-surface-variant">Danh mục</span>
<select id="assetBorrowTypeFilter" class="bg-surface-container-low border-slate-200 rounded-md text-[11px] py-1 px-2 pr-6 focus:ring-1 focus:ring-primary shadow-sm"> <select id="assetBorrowTypeFilter" class="bg-surface-container-low border-slate-200 rounded-md text-[11px] py-1 px-2 pr-6 focus:ring-1 focus:ring-primary shadow-sm">
@@ -4281,7 +4315,7 @@ class AccountManager {
<option value="return" ${this.assetBorrowTypeFilter === 'return' ? 'selected' : ''}>Trả tài sản</option> <option value="return" ${this.assetBorrowTypeFilter === 'return' ? 'selected' : ''}>Trả tài sản</option>
</select> </select>
</div> </div>
<div class="flex items-center gap-1.5 flex-1"> <div class="compact-filter-search flex items-center gap-1.5 flex-1">
<span class="text-[10px] font-bold uppercase text-on-surface-variant">Tìm kiếm</span> <span class="text-[10px] font-bold uppercase text-on-surface-variant">Tìm kiếm</span>
<input <input
id="assetBorrowSearch" id="assetBorrowSearch"
@@ -5330,7 +5364,7 @@ class AccountManager {
</div> </div>
` : ''} ` : ''}
<div class="page-filters flex items-center gap-3 mb-4"> <div class="page-filters compact-page-filters consumable-filter-bar flex items-center gap-3 mb-4">
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
<span class="text-[10px] font-bold uppercase text-on-surface-variant">Tháng</span> <span class="text-[10px] font-bold uppercase text-on-surface-variant">Tháng</span>
<select id="consumableMonthFilter" class="bg-surface-container-low border-slate-200 rounded-md text-[11px] py-1 px-2 pr-6 focus:ring-1 focus:ring-primary shadow-sm"> <select id="consumableMonthFilter" class="bg-surface-container-low border-slate-200 rounded-md text-[11px] py-1 px-2 pr-6 focus:ring-1 focus:ring-primary shadow-sm">
@@ -5346,7 +5380,7 @@ class AccountManager {
<option value="out_of_stock" ${this.consumableStatusFilter === 'out_of_stock' ? 'selected' : ''}>Hết tồn</option> <option value="out_of_stock" ${this.consumableStatusFilter === 'out_of_stock' ? 'selected' : ''}>Hết tồn</option>
</select> </select>
</div> </div>
<div class="flex items-center gap-1.5 flex-1"> <div class="compact-filter-search flex items-center gap-1.5 flex-1">
<span class="text-[10px] font-bold uppercase text-on-surface-variant">Tìm kiếm</span> <span class="text-[10px] font-bold uppercase text-on-surface-variant">Tìm kiếm</span>
<input id="consumableSearch" class="flex-1 bg-surface-container-low border-slate-200 rounded-md text-[11px] py-1 px-2 focus:ring-1 focus:ring-primary shadow-sm" placeholder="Mã, tên, model, lý do xuất..." value="${this.escapeHtml(this.consumableSearchTerm)}"> <input id="consumableSearch" class="flex-1 bg-surface-container-low border-slate-200 rounded-md text-[11px] py-1 px-2 focus:ring-1 focus:ring-primary shadow-sm" placeholder="Mã, tên, model, lý do xuất..." value="${this.escapeHtml(this.consumableSearchTerm)}">
</div> </div>
@@ -5478,7 +5512,7 @@ class AccountManager {
<h1 class="text-2xl font-extrabold text-on-surface tracking-tight">Mượn / xuất / trả VTTH</h1> <h1 class="text-2xl font-extrabold text-on-surface tracking-tight">Mượn / xuất / trả VTTH</h1>
<p class="text-sm text-on-surface-variant">${pageDescription}</p> <p class="text-sm text-on-surface-variant">${pageDescription}</p>
</div> </div>
<div class="flex flex-wrap items-center gap-2"> <div class="compact-page-actions flex flex-wrap items-center gap-2">
<button id="exportConsumableHistoryBtn" class="border border-slate-300 text-slate-700 px-3 py-2 rounded-lg text-xs font-bold flex items-center gap-1.5 transition-all active:scale-95 ${canManageAssets ? 'hover:bg-slate-100' : 'opacity-50 cursor-not-allowed'}" ${canManageAssets ? '' : 'disabled'}> <button id="exportConsumableHistoryBtn" class="border border-slate-300 text-slate-700 px-3 py-2 rounded-lg text-xs font-bold flex items-center gap-1.5 transition-all active:scale-95 ${canManageAssets ? 'hover:bg-slate-100' : 'opacity-50 cursor-not-allowed'}" ${canManageAssets ? '' : 'disabled'}>
<span class="material-symbols-outlined text-base">download</span> <span class="material-symbols-outlined text-base">download</span>
Xuất Excel Xuất Excel
@@ -5490,7 +5524,7 @@ class AccountManager {
</div> </div>
</div> </div>
<div class="page-filters flex items-center gap-3 mb-4 shrink-0"> <div class="page-filters compact-page-filters consumable-export-filter-bar flex items-center gap-3 mb-4 shrink-0">
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
<span class="text-[10px] font-bold uppercase text-on-surface-variant">Người nhận</span> <span class="text-[10px] font-bold uppercase text-on-surface-variant">Người nhận</span>
<select id="consumableExportRecipientFilter" class="bg-surface-container-low border-slate-200 rounded-md text-[11px] py-1 px-2 pr-6 focus:ring-1 focus:ring-primary shadow-sm"> <select id="consumableExportRecipientFilter" class="bg-surface-container-low border-slate-200 rounded-md text-[11px] py-1 px-2 pr-6 focus:ring-1 focus:ring-primary shadow-sm">
@@ -5509,7 +5543,7 @@ class AccountManager {
<span class="text-[10px] font-bold uppercase text-on-surface-variant">Ngày</span> <span class="text-[10px] font-bold uppercase text-on-surface-variant">Ngày</span>
<input id="consumableExportDateFilter" type="date" value="${this.escapeHtml(this.consumableExportDateFilter)}" class="bg-surface-container-low border-slate-200 rounded-md text-[11px] py-1 px-2 focus:ring-1 focus:ring-primary shadow-sm"> <input id="consumableExportDateFilter" type="date" value="${this.escapeHtml(this.consumableExportDateFilter)}" class="bg-surface-container-low border-slate-200 rounded-md text-[11px] py-1 px-2 focus:ring-1 focus:ring-primary shadow-sm">
</div> </div>
<div class="flex items-center gap-1.5 flex-1"> <div class="compact-filter-search flex items-center gap-1.5 flex-1">
<span class="text-[10px] font-bold uppercase text-on-surface-variant">Tìm kiếm</span> <span class="text-[10px] font-bold uppercase text-on-surface-variant">Tìm kiếm</span>
<input <input
id="consumableExportSearch" id="consumableExportSearch"
@@ -5891,7 +5925,7 @@ class AccountManager {
</div> </div>
</div> </div>
<div class="page-filters flex items-center gap-3 mb-4"> <div class="page-filters compact-page-filters asset-filter-bar flex items-center gap-3 mb-4">
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
<span class="text-[10px] font-bold uppercase text-on-surface-variant">Trạng thái</span> <span class="text-[10px] font-bold uppercase text-on-surface-variant">Trạng thái</span>
<select id="assetStatusFilter" class="bg-surface-container-low border-slate-200 rounded-md text-[11px] py-1 px-2 pr-6 focus:ring-1 focus:ring-primary shadow-sm"> <select id="assetStatusFilter" class="bg-surface-container-low border-slate-200 rounded-md text-[11px] py-1 px-2 pr-6 focus:ring-1 focus:ring-primary shadow-sm">
@@ -5901,11 +5935,11 @@ class AccountManager {
<option value="exported">Đã xuất</option> <option value="exported">Đã xuất</option>
</select> </select>
</div> </div>
<div class="flex items-center gap-1.5 flex-1"> <div class="compact-filter-search flex items-center gap-1.5 flex-1">
<span class="text-[10px] font-bold uppercase text-on-surface-variant">Tìm kiếm</span> <span class="text-[10px] font-bold uppercase text-on-surface-variant">Tìm kiếm</span>
<input id="assetSearch" class="flex-1 bg-surface-container-low border-slate-200 rounded-md text-[11px] py-1 px-2 focus:ring-1 focus:ring-primary shadow-sm" placeholder="Mã, tên, model, serial, dự án, vị trí..."> <input id="assetSearch" class="flex-1 bg-surface-container-low border-slate-200 rounded-md text-[11px] py-1 px-2 focus:ring-1 focus:ring-primary shadow-sm" placeholder="Mã, tên, model, serial, dự án, vị trí...">
</div> </div>
<button id="bulkDeleteAssetsBtn" class="border border-red-200 text-red-600 px-3 py-1.5 rounded-md text-[11px] font-bold flex items-center gap-1.5 transition-colors ${(selectedCount === 0 || !canManageAssets) ? 'opacity-50 cursor-not-allowed' : 'hover:bg-red-50'}" ${(selectedCount === 0 || !canManageAssets) ? 'disabled' : ''}> <button id="bulkDeleteAssetsBtn" class="compact-filter-action border border-red-200 text-red-600 px-3 py-1.5 rounded-md text-[11px] font-bold flex items-center gap-1.5 transition-colors ${(selectedCount === 0 || !canManageAssets) ? 'opacity-50 cursor-not-allowed' : 'hover:bg-red-50'}" ${(selectedCount === 0 || !canManageAssets) ? 'disabled' : ''}>
<span class="material-symbols-outlined text-base">delete_sweep</span> <span class="material-symbols-outlined text-base">delete_sweep</span>
Xóa đã chọn (<span id="selectedAssetCount">${selectedCount}</span>) Xóa đã chọn (<span id="selectedAssetCount">${selectedCount}</span>)
</button> </button>
@@ -9181,13 +9215,14 @@ class AccountManager {
this.accountPage = pageInfo.current; this.accountPage = pageInfo.current;
tbody.innerHTML = pageInfo.data.map(acc => { tbody.innerHTML = pageInfo.data.map(acc => {
const isOwnAccount = acc.UserId == currentUserId; const isOwnAccount = acc.UserId == currentUserId;
const canAccessAccount = isOwnAccount || this.getCurrentUserRole() === 'admin';
const accountUsername = acc.AccountUsername || '-'; const accountUsername = acc.AccountUsername || '-';
const displayAccountUsername = isOwnAccount const displayAccountUsername = canAccessAccount
? accountUsername ? accountUsername
: this.maskForeignAccountUsername(accountUsername); : this.maskForeignAccountUsername(accountUsername);
const createdDate = this.formatDateTime(acc.CreatedDate); const createdDate = this.formatDateTime(acc.CreatedDate);
const updatedDate = this.formatDateTime(acc.UpdatedDate); const updatedDate = this.formatDateTime(acc.UpdatedDate);
const actionContent = isOwnAccount const actionContent = canAccessAccount
? `<button class="p-1.5 text-slate-400 transition-colors view-account hover:text-slate-600" data-account-id="${acc.AccountId}" title="View Details"> ? `<button class="p-1.5 text-slate-400 transition-colors view-account hover:text-slate-600" data-account-id="${acc.AccountId}" title="View Details">
<span class="material-symbols-outlined text-lg">info</span> <span class="material-symbols-outlined text-lg">info</span>
</button> </button>
@@ -9325,15 +9360,31 @@ class AccountManager {
const passwordEl = document.getElementById('viewAccountPassword'); const passwordEl = document.getElementById('viewAccountPassword');
const toggleBtn = document.querySelector('.toggle-password'); const toggleBtn = document.querySelector('.toggle-password');
const toggleIcon = document.getElementById('toggleIcon'); const toggleIcon = document.getElementById('toggleIcon');
const storedPwd = account?.AccountPassword || ''; passwordEl.dataset.password = '';
passwordEl.dataset.password = storedPwd; passwordEl.dataset.loaded = 'false';
passwordEl.textContent = storedPwd ? '********' : '(no password stored)'; passwordEl.textContent = account?.PasswordAvailable ? '********' : '(no password stored)';
passwordEl.dataset.visible = 'false'; passwordEl.dataset.visible = 'false';
if (toggleIcon) toggleIcon.textContent = 'visibility'; if (toggleIcon) toggleIcon.textContent = 'visibility';
// Rebind toggle each time modal opens to keep state fresh // Rebind toggle each time modal opens to keep state fresh
if (toggleBtn) { if (toggleBtn) {
toggleBtn.onclick = () => { toggleBtn.disabled = !account?.PasswordAvailable;
toggleBtn.onclick = async () => {
if (passwordEl.dataset.loaded !== 'true') {
try {
toggleBtn.disabled = true;
const password = await this.fetchAccountSecret(accountId);
passwordEl.dataset.password = password;
passwordEl.dataset.loaded = 'true';
this.currentViewAccount = { ...account, AccountPassword: password };
} catch (error) {
this.notifyFailure(error.message || 'Unable to reveal stored credential');
return;
} finally {
toggleBtn.disabled = false;
}
}
const currentPwd = passwordEl.dataset.password || ''; const currentPwd = passwordEl.dataset.password || '';
const isVisible = passwordEl.dataset.visible === 'true'; const isVisible = passwordEl.dataset.visible === 'true';
if (isVisible) { if (isVisible) {
@@ -9388,10 +9439,17 @@ class AccountManager {
// Edit Account listeners // Edit Account listeners
document.querySelectorAll('.edit-account').forEach(btn => { document.querySelectorAll('.edit-account').forEach(btn => {
btn.addEventListener('click', (e) => { btn.addEventListener('click', async (e) => {
if (btn.disabled) return; // Don't edit others' accounts if (btn.disabled) return; // Don't edit others' accounts
const accountId = Number(btn.dataset.accountId); const accountId = Number(btn.dataset.accountId);
const account = this.accounts.find(a => a.AccountId === accountId); const account = this.accounts.find(a => a.AccountId === accountId);
let accountPassword = '';
try {
accountPassword = account?.PasswordAvailable ? await this.fetchAccountSecret(accountId) : '';
} catch (error) {
this.notifyFailure(error.message || 'Unable to load stored credential');
return;
}
// Populate form with existing data // Populate form with existing data
const form = document.getElementById('accountForm'); const form = document.getElementById('accountForm');
if (form) { if (form) {
@@ -9400,7 +9458,7 @@ class AccountManager {
const ownerInput = form.querySelector('#accountOwner'); const ownerInput = form.querySelector('#accountOwner');
const serviceSelect = form.querySelector('#accountService'); const serviceSelect = form.querySelector('#accountService');
if (userInput) userInput.value = account?.AccountUsername || ''; if (userInput) userInput.value = account?.AccountUsername || '';
if (passInput) passInput.value = account?.AccountPassword || ''; if (passInput) passInput.value = accountPassword;
if (ownerInput) ownerInput.value = this.currentUser?.Username || this.currentUser?.username || ''; if (ownerInput) ownerInput.value = this.currentUser?.Username || this.currentUser?.username || '';
if (serviceSelect) serviceSelect.value = account?.AppId || ''; if (serviceSelect) serviceSelect.value = account?.AppId || '';
} }
@@ -9413,8 +9471,18 @@ class AccountManager {
// Edit from View modal // Edit from View modal
document.querySelectorAll('.edit-account-from-view').forEach(btn => { document.querySelectorAll('.edit-account-from-view').forEach(btn => {
btn.addEventListener('click', () => { btn.addEventListener('click', async () => {
const account = this.currentViewAccount; let account = this.currentViewAccount;
if (account?.PasswordAvailable && !account?.AccountPassword) {
try {
const password = await this.fetchAccountSecret(account.AccountId);
account = { ...account, AccountPassword: password };
this.currentViewAccount = account;
} catch (error) {
this.notifyFailure(error.message || 'Unable to load stored credential');
return;
}
}
const form = document.getElementById('accountForm'); const form = document.getElementById('accountForm');
if (form) { if (form) {
const userInput = form.querySelector('#accountUsername'); const userInput = form.querySelector('#accountUsername');
@@ -9451,7 +9519,21 @@ class AccountManager {
const urlVal = app?.Url || app?.url; const urlVal = app?.Url || app?.url;
if (urlEl) { if (urlEl) {
if (urlVal) { if (urlVal) {
urlEl.innerHTML = `<a href="${urlVal}" target="_blank" class="text-primary underline">${urlVal}</a>`; try {
const parsedUrl = new URL(urlVal);
if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
throw new Error('Unsupported URL protocol');
}
const link = document.createElement('a');
link.href = parsedUrl.toString();
link.target = '_blank';
link.rel = 'noopener noreferrer';
link.className = 'text-primary underline';
link.textContent = urlVal;
urlEl.replaceChildren(link);
} catch (error) {
urlEl.textContent = '-';
}
} else { } else {
urlEl.textContent = '-'; urlEl.textContent = '-';
} }
@@ -10044,7 +10126,7 @@ class AccountManager {
<div> <div>
<label class="block text-xs font-semibold text-slate-600 dark:text-slate-300 mb-1">New password</label> <label class="block text-xs font-semibold text-slate-600 dark:text-slate-300 mb-1">New password</label>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<input type="password" id="profileNewPassword" placeholder="Enter new password" class="flex-1 px-3 py-2 border border-outline-variant/30 rounded-lg bg-white dark:bg-slate-900"> <input type="password" id="profileNewPassword" placeholder="At least 12 characters" minlength="12" maxlength="256" class="flex-1 px-3 py-2 border border-outline-variant/30 rounded-lg bg-white dark:bg-slate-900">
<button type="button" data-password-toggle="profileNewPassword" class="shrink-0 px-2.5 py-2 border border-outline-variant/30 rounded-lg text-slate-500 hover:text-slate-700 hover:bg-slate-100 dark:hover:text-slate-200 dark:hover:bg-slate-800" aria-label="Show new password"> <button type="button" data-password-toggle="profileNewPassword" class="shrink-0 px-2.5 py-2 border border-outline-variant/30 rounded-lg text-slate-500 hover:text-slate-700 hover:bg-slate-100 dark:hover:text-slate-200 dark:hover:bg-slate-800" aria-label="Show new password">
<span class="material-symbols-outlined text-base" id="profileNewPasswordIcon">visibility</span> <span class="material-symbols-outlined text-base" id="profileNewPasswordIcon">visibility</span>
</button> </button>
@@ -10054,7 +10136,7 @@ class AccountManager {
<div> <div>
<label class="block text-xs font-semibold text-slate-600 dark:text-slate-300 mb-1">Confirm new password</label> <label class="block text-xs font-semibold text-slate-600 dark:text-slate-300 mb-1">Confirm new password</label>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<input type="password" id="profileConfirmPassword" placeholder="Confirm new password" class="flex-1 px-3 py-2 border border-outline-variant/30 rounded-lg bg-white dark:bg-slate-900"> <input type="password" id="profileConfirmPassword" placeholder="Confirm new password" minlength="12" maxlength="256" class="flex-1 px-3 py-2 border border-outline-variant/30 rounded-lg bg-white dark:bg-slate-900">
<button type="button" data-password-toggle="profileConfirmPassword" class="shrink-0 px-2.5 py-2 border border-outline-variant/30 rounded-lg text-slate-500 hover:text-slate-700 hover:bg-slate-100 dark:hover:text-slate-200 dark:hover:bg-slate-800" aria-label="Show confirm password"> <button type="button" data-password-toggle="profileConfirmPassword" class="shrink-0 px-2.5 py-2 border border-outline-variant/30 rounded-lg text-slate-500 hover:text-slate-700 hover:bg-slate-100 dark:hover:text-slate-200 dark:hover:bg-slate-800" aria-label="Show confirm password">
<span class="material-symbols-outlined text-base" id="profileConfirmPasswordIcon">visibility</span> <span class="material-symbols-outlined text-base" id="profileConfirmPasswordIcon">visibility</span>
</button> </button>
@@ -10144,8 +10226,14 @@ class AccountManager {
return; return;
} }
if (newPassword && !currentPassword) { if (newPassword && (newPassword.length < 12 || newPassword.length > 256)) {
this.notifyFailure('Current password is required to change password'); this.notifyFailure('New password must be between 12 and 256 characters');
return;
}
const emailChanged = email.toLowerCase() !== String(this.currentUser?.Email || '').toLowerCase();
if ((newPassword || emailChanged) && !currentPassword) {
this.notifyFailure('Current password is required to change password or email');
return; return;
} }
@@ -10167,6 +10255,13 @@ class AccountManager {
return; return;
} }
if (data.sessionEnded) {
localStorage.removeItem('currentUser');
this.notifySuccess(data.message || 'Profile updated. Please sign in again.');
setTimeout(() => window.location.replace('../pages/login.html'), 700);
return;
}
if (data.user) { if (data.user) {
this.currentUser = { this.currentUser = {
...this.currentUser, ...this.currentUser,
@@ -10563,7 +10658,7 @@ class AccountManager {
<div> <div>
<label class="block text-sm font-medium mb-1">${user ? 'New Password' : 'Password'}</label> <label class="block text-sm font-medium mb-1">${user ? 'New Password' : 'Password'}</label>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<input type="password" id="userPassword" placeholder="${user ? 'Leave blank to keep current password' : 'Password'}" class="flex-1 px-3 py-2 border border-outline-variant/30 rounded-lg bg-surface-container-low dark:bg-slate-800" ${user ? '' : 'required'}> <input type="password" id="userPassword" placeholder="${user ? 'Leave blank to keep current password' : 'At least 12 characters'}" minlength="12" maxlength="256" class="flex-1 px-3 py-2 border border-outline-variant/30 rounded-lg bg-surface-container-low dark:bg-slate-800" ${user ? '' : 'required'}>
<button type="button" id="userPasswordToggle" class="p-2 rounded-lg border border-outline-variant/30 hover:bg-slate-100 dark:hover:bg-slate-700" title="Show/Hide password"> <button type="button" id="userPasswordToggle" class="p-2 rounded-lg border border-outline-variant/30 hover:bg-slate-100 dark:hover:bg-slate-700" title="Show/Hide password">
<span class="material-symbols-outlined text-base" id="userPasswordToggleIcon">visibility</span> <span class="material-symbols-outlined text-base" id="userPasswordToggleIcon">visibility</span>
</button> </button>
@@ -10654,6 +10749,11 @@ class AccountManager {
return; return;
} }
if (password && (password.length < 12 || password.length > 256)) {
this.notifyFailure('Password must be between 12 and 256 characters');
return;
}
const method = userId ? 'PUT' : 'POST'; const method = userId ? 'PUT' : 'POST';
const url = userId ? `${this.apiBase}/users/${userId}` : `${this.apiBase}/users`; const url = userId ? `${this.apiBase}/users/${userId}` : `${this.apiBase}/users`;
@@ -10740,13 +10840,9 @@ class AccountManager {
</div> </div>
<div> <div>
<label class="block text-sm font-medium mb-1">Password</label> <label class="block text-sm font-medium mb-1">Password</label>
<div class="flex items-center gap-2"> <div class="px-3 py-2 border border-outline-variant/30 rounded-lg bg-surface-container-low dark:bg-slate-800 text-sm text-slate-500">
<div id="userDetailPassword" data-visible="false" class="flex-1 px-3 py-2 border border-outline-variant/30 rounded-lg bg-surface-container-low dark:bg-slate-800">********</div> Passwords are one-way hashed and cannot be displayed. Use Edit to reset the password.
<button type="button" id="userDetailPasswordToggle" class="p-2 rounded-lg border border-outline-variant/30 hover:bg-slate-100 dark:hover:bg-slate-700" title="Show/Hide password">
<span class="material-symbols-outlined text-base" id="userDetailPasswordToggleIcon">visibility</span>
</button>
</div> </div>
<p class="text-xs text-slate-500 mt-1">Mật khẩu text thường (không phải hash) nếu tài khoản đã được lưu theo chuẩn mới.</p>
</div> </div>
<div class="grid grid-cols-2 gap-3"> <div class="grid grid-cols-2 gap-3">
<div> <div>
@@ -10777,8 +10873,6 @@ class AccountManager {
document.body.appendChild(container); document.body.appendChild(container);
} }
const passwordValue = user?.Password || '';
const hasReadablePassword = user?.PasswordAvailable === true || Boolean(passwordValue);
const usernameEl = document.getElementById('userDetailUsername'); const usernameEl = document.getElementById('userDetailUsername');
const fullNameEl = document.getElementById('userDetailFullName'); const fullNameEl = document.getElementById('userDetailFullName');
const emailEl = document.getElementById('userDetailEmail'); const emailEl = document.getElementById('userDetailEmail');
@@ -10786,9 +10880,6 @@ class AccountManager {
const statusEl = document.getElementById('userDetailStatus'); const statusEl = document.getElementById('userDetailStatus');
const createdDateEl = document.getElementById('userDetailCreatedDate'); const createdDateEl = document.getElementById('userDetailCreatedDate');
const lastLoginEl = document.getElementById('userDetailLastLogin'); const lastLoginEl = document.getElementById('userDetailLastLogin');
const passwordEl = document.getElementById('userDetailPassword');
const passwordToggleBtn = document.getElementById('userDetailPasswordToggle');
const passwordToggleIcon = document.getElementById('userDetailPasswordToggleIcon');
const editBtn = document.getElementById('userDetailEditBtn'); const editBtn = document.getElementById('userDetailEditBtn');
const detailsModal = document.getElementById('userDetailsModal'); const detailsModal = document.getElementById('userDetailsModal');
@@ -10799,35 +10890,6 @@ class AccountManager {
if (statusEl) statusEl.textContent = user?.IsActive ? 'Active' : 'Inactive'; if (statusEl) statusEl.textContent = user?.IsActive ? 'Active' : 'Inactive';
if (createdDateEl) createdDateEl.textContent = this.formatDateTime(user?.CreatedDate); if (createdDateEl) createdDateEl.textContent = this.formatDateTime(user?.CreatedDate);
if (lastLoginEl) lastLoginEl.textContent = this.formatDateTime(user?.LastLogin); if (lastLoginEl) lastLoginEl.textContent = this.formatDateTime(user?.LastLogin);
if (passwordEl) {
passwordEl.dataset.password = passwordValue;
passwordEl.dataset.visible = 'false';
passwordEl.textContent = hasReadablePassword ? '********' : '(khong the hien thi - can reset password 1 lan)';
}
if (passwordToggleBtn && passwordEl) {
passwordToggleBtn.addEventListener('click', () => {
if (!hasReadablePassword) {
return;
}
const isVisible = passwordEl.dataset.visible === 'true';
if (isVisible) {
passwordEl.textContent = '********';
passwordEl.dataset.visible = 'false';
if (passwordToggleIcon) passwordToggleIcon.textContent = 'visibility';
} else {
passwordEl.textContent = passwordValue;
passwordEl.dataset.visible = 'true';
if (passwordToggleIcon) passwordToggleIcon.textContent = 'visibility_off';
}
});
}
if (passwordToggleBtn && !hasReadablePassword) {
passwordToggleBtn.disabled = true;
passwordToggleBtn.classList.add('opacity-50', 'cursor-not-allowed');
}
if (editBtn) { if (editBtn) {
editBtn.addEventListener('click', () => { editBtn.addEventListener('click', () => {
closeUserDetailsModal(); closeUserDetailsModal();
@@ -11148,6 +11210,18 @@ function closeProfileModal() {
// Initialize app when DOM is ready // Initialize app when DOM is ready
let app; let app;
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', async () => {
try {
const response = await fetch('/api/auth/session', { cache: 'no-store' });
const data = await response.json();
if (!response.ok || !data.success || !data.user) {
throw new Error('Session is not valid');
}
localStorage.setItem('currentUser', JSON.stringify(data.user));
app = new AccountManager(); app = new AccountManager();
} catch (error) {
localStorage.removeItem('currentUser');
window.location.replace('../pages/login.html?reason=session-expired');
}
}); });

View File

@@ -119,7 +119,7 @@
</div> </div>
<div> <div>
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">URL</label> <label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">URL</label>
<input type="text" id="appUrl" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3" placeholder="https://example.com or 172.20.235.176"> <input type="url" id="appUrl" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3" placeholder="https://app.example.internal">
</div> </div>
<div> <div>
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Status</label> <label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Status</label>

View File

@@ -12,7 +12,7 @@
<!-- Notiflix Notify --> <!-- Notiflix Notify -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/notiflix@3.2.7/dist/notiflix-3.2.7.min.css" /> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/notiflix@3.2.7/dist/notiflix-3.2.7.min.css" />
<script src="https://cdn.jsdelivr.net/npm/notiflix@3.2.7/dist/notiflix-aio-3.2.7.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/notiflix@3.2.7/dist/notiflix-aio-3.2.7.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js"></script> <script src="https://cdn.sheetjs.com/xlsx-0.20.3/package/dist/xlsx.full.min.js"></script>
<style> <style>
.material-symbols-outlined { .material-symbols-outlined {
font-family: 'Material Symbols Outlined'; font-family: 'Material Symbols Outlined';
@@ -267,7 +267,7 @@
} }
</style> </style>
<link rel="stylesheet" href="../css/responsive.css?v=20260717-1" /> <link rel="stylesheet" href="../css/responsive.css?v=20260717-2" />
</head> </head>
<body class="app-shell bg-background text-on-surface antialiased flex h-screen w-screen"> <body class="app-shell bg-background text-on-surface antialiased flex h-screen w-screen">
<!-- SideNavBar --> <!-- SideNavBar -->

View File

@@ -194,6 +194,8 @@
id="resetPassword" id="resetPassword"
name="resetPassword" name="resetPassword"
placeholder="Enter a new password" placeholder="Enter a new password"
minlength="12"
maxlength="256"
required required
class="w-full pl-10 pr-4 py-2.5 bg-surface-container-low border border-outline-variant/30 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent transition-all text-sm font-medium" class="w-full pl-10 pr-4 py-2.5 bg-surface-container-low border border-outline-variant/30 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent transition-all text-sm font-medium"
/> />
@@ -211,6 +213,8 @@
id="resetConfirmPassword" id="resetConfirmPassword"
name="resetConfirmPassword" name="resetConfirmPassword"
placeholder="Re-enter new password" placeholder="Re-enter new password"
minlength="12"
maxlength="256"
required required
class="w-full pl-10 pr-4 py-2.5 bg-surface-container-low border border-outline-variant/30 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent transition-all text-sm font-medium" class="w-full pl-10 pr-4 py-2.5 bg-surface-container-low border border-outline-variant/30 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent transition-all text-sm font-medium"
/> />
@@ -294,6 +298,8 @@
id="regPassword" id="regPassword"
name="password" name="password"
placeholder="Create a password" placeholder="Create a password"
minlength="12"
maxlength="256"
required required
class="w-full pl-10 pr-4 py-2.5 bg-surface-container-low border border-outline-variant/30 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent transition-all text-sm font-medium" class="w-full pl-10 pr-4 py-2.5 bg-surface-container-low border border-outline-variant/30 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent transition-all text-sm font-medium"
/> />
@@ -314,7 +320,7 @@
<!-- Footer --> <!-- Footer -->
<!-- <div class="mt-8 pt-6 border-t border-outline-variant/10 text-center"> <!-- <div class="mt-8 pt-6 border-t border-outline-variant/10 text-center">
<p class="text-[10px] text-on-surface-variant/60">Default credentials for demo: admin / admin</p> <p class="text-[10px] text-on-surface-variant/60">Use the account provided by your administrator.</p>
</div> --> </div> -->
</div> </div>
@@ -419,13 +425,37 @@
return 'login'; return 'login';
}; };
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', async () => {
const initialMode = getInitialMode(); let initialMode = getInitialMode();
const currentUser = localStorage.getItem('currentUser'); try {
if (currentUser && initialMode !== 'reset') { const configResponse = await fetch('/api/auth/config', { cache: 'no-store' });
window.location.href = './index.html'; const config = await configResponse.json();
if (configResponse.ok && config.allowSelfRegistration === false) {
registerTab.classList.add('hidden');
if (initialMode === 'register') {
initialMode = 'login';
}
}
} catch (error) {
registerTab.classList.add('hidden');
if (initialMode === 'register') {
initialMode = 'login';
}
}
if (initialMode !== 'reset') {
try {
const sessionResponse = await fetch('/api/auth/session', { cache: 'no-store' });
const sessionData = await sessionResponse.json();
if (sessionResponse.ok && sessionData.success && sessionData.user) {
localStorage.setItem('currentUser', JSON.stringify(sessionData.user));
window.location.replace('./index.html');
return; return;
} }
} catch (error) {
console.debug('No active session');
}
}
localStorage.removeItem('currentUser');
setMode(initialMode); setMode(initialMode);
@@ -490,7 +520,7 @@
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}, },
body: JSON.stringify({ username, password }) body: JSON.stringify({ username, password, remember: rememberCheckbox.checked })
}); });
const data = await response.json(); const data = await response.json();
@@ -579,8 +609,8 @@
return; return;
} }
if (!newPassword || newPassword.length < 6) { if (!newPassword || newPassword.length < 12 || newPassword.length > 256) {
resetPasswordErrorMessage.textContent = 'New password must be at least 6 characters.'; resetPasswordErrorMessage.textContent = 'New password must be between 12 and 256 characters.';
resetPasswordErrorMessage.classList.remove('hidden'); resetPasswordErrorMessage.classList.remove('hidden');
return; return;
} }

97
test/security.test.js Normal file
View File

@@ -0,0 +1,97 @@
const test = require('node:test');
const assert = require('node:assert/strict');
process.env.NODE_ENV = 'test';
process.env.DATA_ENCRYPTION_SECRET = 'test-only-encryption-secret-with-more-than-32-characters';
const {
app,
encryptSensitiveValue,
decryptSensitiveValue,
hashSessionToken,
normalizeOptionalHttpUrl
} = require('../backend/server');
async function withTestServer(run) {
const server = app.listen(0, '127.0.0.1');
await new Promise((resolve, reject) => {
server.once('listening', resolve);
server.once('error', reject);
});
try {
const address = server.address();
await run(`http://127.0.0.1:${address.port}`);
} finally {
await new Promise(resolve => server.close(resolve));
}
}
test('stored credentials use authenticated encryption and round-trip safely', () => {
const plainText = 'example-password-value';
const first = encryptSensitiveValue(plainText);
const second = encryptSensitiveValue(plainText);
assert.match(first, /^enc:v2:/);
assert.notEqual(first, second);
assert.equal(first.includes(plainText), false);
assert.equal(decryptSensitiveValue(first), plainText);
assert.equal(decryptSensitiveValue(`${first}tampered`), null);
});
test('session tokens are stored as deterministic SHA-256 hashes', () => {
const token = 'a-private-random-session-token';
const hash = hashSessionToken(token);
assert.match(hash, /^[a-f0-9]{64}$/);
assert.equal(hash, hashSessionToken(token));
assert.equal(hash.includes(token), false);
});
test('application URLs accept only HTTP and HTTPS protocols', () => {
assert.equal(normalizeOptionalHttpUrl(''), '');
assert.equal(normalizeOptionalHttpUrl('javascript:alert(1)'), null);
assert.equal(normalizeOptionalHttpUrl('file:///etc/passwd'), null);
assert.match(normalizeOptionalHttpUrl('https://example.com/path'), /^https:\/\/example\.com\/path/);
});
test('forged legacy identity headers cannot bypass protected APIs', async () => {
await withTestServer(async baseUrl => {
const response = await fetch(`${baseUrl}/api/users`, {
headers: {
'x-user-id': '1',
'x-user-role': 'admin'
}
});
const body = await response.json();
assert.equal(response.status, 401);
assert.equal(body.success, false);
});
});
test('security headers and no-store API caching are enabled', async () => {
await withTestServer(async baseUrl => {
const response = await fetch(`${baseUrl}/api/health`);
assert.equal(response.status, 200);
assert.match(response.headers.get('content-security-policy') || '', /default-src 'self'/);
assert.equal(response.headers.get('x-powered-by'), null);
assert.equal(response.headers.get('cache-control'), 'no-store');
});
});
test('state-changing requests from untrusted origins are rejected before route handling', async () => {
await withTestServer(async baseUrl => {
const response = await fetch(`${baseUrl}/api/auth/login`, {
method: 'POST',
headers: {
Origin: 'https://attacker.example',
'Content-Type': 'application/json'
},
body: JSON.stringify({ username: 'test', password: 'not-a-real-password' })
});
assert.equal(response.status, 403);
});
});