security + reponsive
This commit is contained in:
45
.env.example
Normal file
45
.env.example
Normal 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
3
.gitignore
vendored
@@ -1,5 +1,8 @@
|
||||
node_modules
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
DEPLOYMENT_GUIDE.md
|
||||
docs-output
|
||||
scripts
|
||||
.codex-server.err.log
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:20-bookworm-slim
|
||||
FROM node:22-bookworm-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
1091
backend/server.js
1091
backend/server.js
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,6 @@
|
||||
-- ===========================================
|
||||
-- SQL Server Setup Script for AccManager
|
||||
-- Database: AccManager
|
||||
-- Server: 172.20.235.176
|
||||
-- ===========================================
|
||||
|
||||
-- Create Database
|
||||
@@ -39,6 +38,24 @@ BEGIN
|
||||
PRINT 'Table Users created successfully.';
|
||||
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
|
||||
-- ===========================================
|
||||
@@ -67,7 +84,7 @@ BEGIN
|
||||
UserId INT NOT NULL,
|
||||
AppId INT NOT NULL,
|
||||
AccountUsername NVARCHAR(100),
|
||||
AccountPassword NVARCHAR(255),
|
||||
AccountPassword NVARCHAR(2048),
|
||||
Email NVARCHAR(100),
|
||||
AccessLevel NVARCHAR(50),
|
||||
Status NVARCHAR(20) DEFAULT 'Active',
|
||||
@@ -80,6 +97,11 @@ BEGIN
|
||||
PRINT 'Table Accounts created successfully.';
|
||||
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
|
||||
-- ===========================================
|
||||
|
||||
@@ -10,19 +10,31 @@ services:
|
||||
PORT: 3000
|
||||
TZ: ${TZ:-Asia/Ho_Chi_Minh}
|
||||
APP_TIME_ZONE: ${APP_TIME_ZONE:-Asia/Ho_Chi_Minh}
|
||||
DB_SERVER: ${DB_SERVER:-172.20.235.176}
|
||||
DB_USER: ${DB_USER:-sa}
|
||||
DB_PASSWORD: ${DB_PASSWORD:-changeme}
|
||||
DB_SERVER: ${DB_SERVER:?DB_SERVER is required}
|
||||
DB_USER: ${DB_USER:?DB_USER is required}
|
||||
DB_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required}
|
||||
DB_NAME: ${DB_NAME:-AccManager}
|
||||
DB_ENCRYPT: ${DB_ENCRYPT:-false}
|
||||
DB_TRUST_CERTIFICATE: ${DB_TRUST_CERTIFICATE:-true}
|
||||
DB_CONNECT_TIMEOUT: ${DB_CONNECT_TIMEOUT:-30000}
|
||||
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_PORT: ${SMTP_PORT:-587}
|
||||
SMTP_SECURE: ${SMTP_SECURE:-false}
|
||||
SMTP_REQUIRE_TLS: ${SMTP_REQUIRE_TLS:-true}
|
||||
SMTP_USER: ${SMTP_USER:-}
|
||||
SMTP_PASS: ${SMTP_PASS:-}
|
||||
SMTP_FROM: ${SMTP_FROM:-}
|
||||
EMAIL_VERIFY_TOKEN_TTL_MINUTES: ${EMAIL_VERIFY_TOKEN_TTL_MINUTES:-30}
|
||||
PASSWORD_RESET_TOKEN_TTL_MINUTES: ${PASSWORD_RESET_TOKEN_TTL_MINUTES:-30}
|
||||
|
||||
@@ -12,19 +12,31 @@ services:
|
||||
PORT: 3000
|
||||
TZ: ${TZ:-Asia/Ho_Chi_Minh}
|
||||
APP_TIME_ZONE: ${APP_TIME_ZONE:-Asia/Ho_Chi_Minh}
|
||||
DB_SERVER: ${DB_SERVER:-172.20.235.176}
|
||||
DB_USER: ${DB_USER:-sa}
|
||||
DB_PASSWORD: ${DB_PASSWORD:-changeme}
|
||||
DB_SERVER: ${DB_SERVER:?DB_SERVER is required}
|
||||
DB_USER: ${DB_USER:?DB_USER is required}
|
||||
DB_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required}
|
||||
DB_NAME: ${DB_NAME:-AccManager}
|
||||
DB_ENCRYPT: ${DB_ENCRYPT:-false}
|
||||
DB_TRUST_CERTIFICATE: ${DB_TRUST_CERTIFICATE:-true}
|
||||
DB_CONNECT_TIMEOUT: ${DB_CONNECT_TIMEOUT:-30000}
|
||||
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_PORT: ${SMTP_PORT:-587}
|
||||
SMTP_SECURE: ${SMTP_SECURE:-false}
|
||||
SMTP_REQUIRE_TLS: ${SMTP_REQUIRE_TLS:-true}
|
||||
SMTP_USER: ${SMTP_USER:-}
|
||||
SMTP_PASS: ${SMTP_PASS:-}
|
||||
SMTP_FROM: ${SMTP_FROM:-}
|
||||
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
2705
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
17
package.json
17
package.json
@@ -6,6 +6,8 @@
|
||||
"scripts": {
|
||||
"start": "node 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",
|
||||
"watch:css": "tailwindcss -c tailwind.config.js -i ./public/css/tailwind.css -o ./public/css/main.css --watch"
|
||||
},
|
||||
@@ -17,15 +19,20 @@
|
||||
],
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"bcrypt": "^6.0.0",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"mssql": "^9.1.1",
|
||||
"multer": "^2.1.1",
|
||||
"nodemailer": "^8.0.4",
|
||||
"xlsx": "^0.18.5"
|
||||
"express": "^5.2.1",
|
||||
"express-rate-limit": "^8.6.0",
|
||||
"helmet": "^8.3.0",
|
||||
"mssql": "^12.7.0",
|
||||
"multer": "^2.2.0",
|
||||
"nodemailer": "^9.0.3",
|
||||
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/container-queries": "^0.1.1",
|
||||
|
||||
@@ -150,6 +150,89 @@ textarea {
|
||||
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,
|
||||
.apps-stats {
|
||||
gap: 0.75rem;
|
||||
@@ -374,6 +457,29 @@ textarea {
|
||||
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 {
|
||||
padding: 0.625rem;
|
||||
}
|
||||
|
||||
228
public/js/app.js
228
public/js/app.js
@@ -1,6 +1,27 @@
|
||||
// VaultSentinel - Account Management Application
|
||||
// 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_DATE_FORMATTER = new Intl.DateTimeFormat('vi-VN', {
|
||||
timeZone: APP_TIME_ZONE,
|
||||
@@ -220,10 +241,7 @@ class AccountManager {
|
||||
}
|
||||
|
||||
getAuthHeaders(includeJson = false) {
|
||||
const headers = {
|
||||
'x-user-id': String(this.getUserId()),
|
||||
'x-user-role': this.getCurrentUserRole()
|
||||
};
|
||||
const headers = {};
|
||||
|
||||
if (includeJson) {
|
||||
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() {
|
||||
try {
|
||||
const res = await fetch(`${this.apiBase}/users`);
|
||||
@@ -2502,11 +2529,17 @@ class AccountManager {
|
||||
return `${value.slice(0, 3)}*****`;
|
||||
}
|
||||
|
||||
handleLogout() {
|
||||
async handleLogout() {
|
||||
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);
|
||||
localStorage.clear();
|
||||
window.location.href = '../pages/login.html';
|
||||
localStorage.removeItem('currentUser');
|
||||
window.location.replace('../pages/login.html');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2643,13 +2676,14 @@ class AccountManager {
|
||||
<tbody class="divide-y divide-slate-100 accounts-table-body">
|
||||
${pageInfo.data.map(acc => {
|
||||
const isOwnAccount = acc.UserId == currentUserId;
|
||||
const canAccessAccount = isOwnAccount || this.getCurrentUserRole() === 'admin';
|
||||
const accountUsername = acc.AccountUsername || '-';
|
||||
const displayAccountUsername = isOwnAccount
|
||||
const displayAccountUsername = canAccessAccount
|
||||
? accountUsername
|
||||
: this.maskForeignAccountUsername(accountUsername);
|
||||
const createdDate = this.formatDateTime(acc.CreatedDate);
|
||||
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">
|
||||
<span class="material-symbols-outlined text-lg">info</span>
|
||||
</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>
|
||||
<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 class="flex flex-wrap items-center gap-2">
|
||||
<div class="compact-page-actions flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
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"
|
||||
@@ -4272,7 +4306,7 @@ class AccountManager {
|
||||
</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">
|
||||
<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">
|
||||
@@ -4281,7 +4315,7 @@ class AccountManager {
|
||||
<option value="return" ${this.assetBorrowTypeFilter === 'return' ? 'selected' : ''}>Trả tài sản</option>
|
||||
</select>
|
||||
</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>
|
||||
<input
|
||||
id="assetBorrowSearch"
|
||||
@@ -5330,7 +5364,7 @@ class AccountManager {
|
||||
</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">
|
||||
<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">
|
||||
@@ -5346,7 +5380,7 @@ class AccountManager {
|
||||
<option value="out_of_stock" ${this.consumableStatusFilter === 'out_of_stock' ? 'selected' : ''}>Hết tồn</option>
|
||||
</select>
|
||||
</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>
|
||||
<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>
|
||||
@@ -5478,7 +5512,7 @@ class AccountManager {
|
||||
<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>
|
||||
</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'}>
|
||||
<span class="material-symbols-outlined text-base">download</span>
|
||||
Xuất Excel
|
||||
@@ -5490,7 +5524,7 @@ class AccountManager {
|
||||
</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">
|
||||
<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">
|
||||
@@ -5509,7 +5543,7 @@ class AccountManager {
|
||||
<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">
|
||||
</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>
|
||||
<input
|
||||
id="consumableExportSearch"
|
||||
@@ -5891,7 +5925,7 @@ class AccountManager {
|
||||
</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">
|
||||
<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">
|
||||
@@ -5901,11 +5935,11 @@ class AccountManager {
|
||||
<option value="exported">Đã xuất</option>
|
||||
</select>
|
||||
</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>
|
||||
<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>
|
||||
<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>
|
||||
Xóa đã chọn (<span id="selectedAssetCount">${selectedCount}</span>)
|
||||
</button>
|
||||
@@ -9181,13 +9215,14 @@ class AccountManager {
|
||||
this.accountPage = pageInfo.current;
|
||||
tbody.innerHTML = pageInfo.data.map(acc => {
|
||||
const isOwnAccount = acc.UserId == currentUserId;
|
||||
const canAccessAccount = isOwnAccount || this.getCurrentUserRole() === 'admin';
|
||||
const accountUsername = acc.AccountUsername || '-';
|
||||
const displayAccountUsername = isOwnAccount
|
||||
const displayAccountUsername = canAccessAccount
|
||||
? accountUsername
|
||||
: this.maskForeignAccountUsername(accountUsername);
|
||||
const createdDate = this.formatDateTime(acc.CreatedDate);
|
||||
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">
|
||||
<span class="material-symbols-outlined text-lg">info</span>
|
||||
</button>
|
||||
@@ -9325,15 +9360,31 @@ class AccountManager {
|
||||
const passwordEl = document.getElementById('viewAccountPassword');
|
||||
const toggleBtn = document.querySelector('.toggle-password');
|
||||
const toggleIcon = document.getElementById('toggleIcon');
|
||||
const storedPwd = account?.AccountPassword || '';
|
||||
passwordEl.dataset.password = storedPwd;
|
||||
passwordEl.textContent = storedPwd ? '********' : '(no password stored)';
|
||||
passwordEl.dataset.password = '';
|
||||
passwordEl.dataset.loaded = 'false';
|
||||
passwordEl.textContent = account?.PasswordAvailable ? '********' : '(no password stored)';
|
||||
passwordEl.dataset.visible = 'false';
|
||||
if (toggleIcon) toggleIcon.textContent = 'visibility';
|
||||
|
||||
// Rebind toggle each time modal opens to keep state fresh
|
||||
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 isVisible = passwordEl.dataset.visible === 'true';
|
||||
if (isVisible) {
|
||||
@@ -9388,10 +9439,17 @@ class AccountManager {
|
||||
|
||||
// Edit Account listeners
|
||||
document.querySelectorAll('.edit-account').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
btn.addEventListener('click', async (e) => {
|
||||
if (btn.disabled) return; // Don't edit others' accounts
|
||||
const accountId = Number(btn.dataset.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
|
||||
const form = document.getElementById('accountForm');
|
||||
if (form) {
|
||||
@@ -9400,7 +9458,7 @@ class AccountManager {
|
||||
const ownerInput = form.querySelector('#accountOwner');
|
||||
const serviceSelect = form.querySelector('#accountService');
|
||||
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 (serviceSelect) serviceSelect.value = account?.AppId || '';
|
||||
}
|
||||
@@ -9413,8 +9471,18 @@ class AccountManager {
|
||||
|
||||
// Edit from View modal
|
||||
document.querySelectorAll('.edit-account-from-view').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const account = this.currentViewAccount;
|
||||
btn.addEventListener('click', async () => {
|
||||
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');
|
||||
if (form) {
|
||||
const userInput = form.querySelector('#accountUsername');
|
||||
@@ -9451,7 +9519,21 @@ class AccountManager {
|
||||
const urlVal = app?.Url || app?.url;
|
||||
if (urlEl) {
|
||||
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 {
|
||||
urlEl.textContent = '-';
|
||||
}
|
||||
@@ -10044,7 +10126,7 @@ class AccountManager {
|
||||
<div>
|
||||
<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">
|
||||
<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">
|
||||
<span class="material-symbols-outlined text-base" id="profileNewPasswordIcon">visibility</span>
|
||||
</button>
|
||||
@@ -10054,7 +10136,7 @@ class AccountManager {
|
||||
<div>
|
||||
<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">
|
||||
<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">
|
||||
<span class="material-symbols-outlined text-base" id="profileConfirmPasswordIcon">visibility</span>
|
||||
</button>
|
||||
@@ -10144,8 +10226,14 @@ class AccountManager {
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword && !currentPassword) {
|
||||
this.notifyFailure('Current password is required to change password');
|
||||
if (newPassword && (newPassword.length < 12 || newPassword.length > 256)) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -10167,6 +10255,13 @@ class AccountManager {
|
||||
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) {
|
||||
this.currentUser = {
|
||||
...this.currentUser,
|
||||
@@ -10563,7 +10658,7 @@ class AccountManager {
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">${user ? 'New Password' : 'Password'}</label>
|
||||
<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">
|
||||
<span class="material-symbols-outlined text-base" id="userPasswordToggleIcon">visibility</span>
|
||||
</button>
|
||||
@@ -10654,6 +10749,11 @@ class AccountManager {
|
||||
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 url = userId ? `${this.apiBase}/users/${userId}` : `${this.apiBase}/users`;
|
||||
|
||||
@@ -10740,13 +10840,9 @@ class AccountManager {
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">Password</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<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>
|
||||
<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 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">
|
||||
Passwords are one-way hashed and cannot be displayed. Use Edit to reset the password.
|
||||
</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 class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
@@ -10777,8 +10873,6 @@ class AccountManager {
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
|
||||
const passwordValue = user?.Password || '';
|
||||
const hasReadablePassword = user?.PasswordAvailable === true || Boolean(passwordValue);
|
||||
const usernameEl = document.getElementById('userDetailUsername');
|
||||
const fullNameEl = document.getElementById('userDetailFullName');
|
||||
const emailEl = document.getElementById('userDetailEmail');
|
||||
@@ -10786,9 +10880,6 @@ class AccountManager {
|
||||
const statusEl = document.getElementById('userDetailStatus');
|
||||
const createdDateEl = document.getElementById('userDetailCreatedDate');
|
||||
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 detailsModal = document.getElementById('userDetailsModal');
|
||||
|
||||
@@ -10799,35 +10890,6 @@ class AccountManager {
|
||||
if (statusEl) statusEl.textContent = user?.IsActive ? 'Active' : 'Inactive';
|
||||
if (createdDateEl) createdDateEl.textContent = this.formatDateTime(user?.CreatedDate);
|
||||
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) {
|
||||
editBtn.addEventListener('click', () => {
|
||||
closeUserDetailsModal();
|
||||
@@ -11148,6 +11210,18 @@ function closeProfileModal() {
|
||||
|
||||
// Initialize app when DOM is ready
|
||||
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();
|
||||
} catch (error) {
|
||||
localStorage.removeItem('currentUser');
|
||||
window.location.replace('../pages/login.html?reason=session-expired');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -119,7 +119,7 @@
|
||||
</div>
|
||||
<div>
|
||||
<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>
|
||||
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Status</label>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<!-- Notiflix Notify -->
|
||||
<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/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>
|
||||
.material-symbols-outlined {
|
||||
font-family: 'Material Symbols Outlined';
|
||||
@@ -267,7 +267,7 @@
|
||||
}
|
||||
|
||||
</style>
|
||||
<link rel="stylesheet" href="../css/responsive.css?v=20260717-1" />
|
||||
<link rel="stylesheet" href="../css/responsive.css?v=20260717-2" />
|
||||
</head>
|
||||
<body class="app-shell bg-background text-on-surface antialiased flex h-screen w-screen">
|
||||
<!-- SideNavBar -->
|
||||
|
||||
@@ -194,6 +194,8 @@
|
||||
id="resetPassword"
|
||||
name="resetPassword"
|
||||
placeholder="Enter a new password"
|
||||
minlength="12"
|
||||
maxlength="256"
|
||||
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"
|
||||
/>
|
||||
@@ -211,6 +213,8 @@
|
||||
id="resetConfirmPassword"
|
||||
name="resetConfirmPassword"
|
||||
placeholder="Re-enter new password"
|
||||
minlength="12"
|
||||
maxlength="256"
|
||||
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"
|
||||
/>
|
||||
@@ -294,6 +298,8 @@
|
||||
id="regPassword"
|
||||
name="password"
|
||||
placeholder="Create a password"
|
||||
minlength="12"
|
||||
maxlength="256"
|
||||
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"
|
||||
/>
|
||||
@@ -314,7 +320,7 @@
|
||||
|
||||
<!-- Footer -->
|
||||
<!-- <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>
|
||||
|
||||
@@ -419,13 +425,37 @@
|
||||
return 'login';
|
||||
};
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const initialMode = getInitialMode();
|
||||
const currentUser = localStorage.getItem('currentUser');
|
||||
if (currentUser && initialMode !== 'reset') {
|
||||
window.location.href = './index.html';
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
let initialMode = getInitialMode();
|
||||
try {
|
||||
const configResponse = await fetch('/api/auth/config', { cache: 'no-store' });
|
||||
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;
|
||||
}
|
||||
} catch (error) {
|
||||
console.debug('No active session');
|
||||
}
|
||||
}
|
||||
localStorage.removeItem('currentUser');
|
||||
|
||||
setMode(initialMode);
|
||||
|
||||
@@ -490,7 +520,7 @@
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ username, password })
|
||||
body: JSON.stringify({ username, password, remember: rememberCheckbox.checked })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
@@ -579,8 +609,8 @@
|
||||
return;
|
||||
}
|
||||
|
||||
if (!newPassword || newPassword.length < 6) {
|
||||
resetPasswordErrorMessage.textContent = 'New password must be at least 6 characters.';
|
||||
if (!newPassword || newPassword.length < 12 || newPassword.length > 256) {
|
||||
resetPasswordErrorMessage.textContent = 'New password must be between 12 and 256 characters.';
|
||||
resetPasswordErrorMessage.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
97
test/security.test.js
Normal file
97
test/security.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user