done ver1.0.0

This commit is contained in:
2026-04-02 11:16:18 +07:00
parent 58dbefa155
commit d09ba3d2ad
21 changed files with 3271 additions and 668 deletions

9
.dockerignore Normal file
View File

@@ -0,0 +1,9 @@
node_modules
npm-debug.log
.git
.gitignore
.vscode
.idea
.vs
.env
docker-compose.yml

18
.env
View File

@@ -1,6 +1,15 @@
# AccManager Backend Configuration
# Server Port
# Application
NODE_ENV=production
# Host port exposed by docker compose
APP_PORT=3000
# Image used for server pull deployment
DOCKER_IMAGE=toiiiiday/accmanager:1.0.2
# Container app port
PORT=3000
# SQL Server Configuration
@@ -8,8 +17,9 @@ DB_SERVER=172.20.235.176
DB_USER=sa
DB_PASSWORD=robotics@2022
DB_NAME=AccManager
DB_ENCRYPT=true
DB_ENCRYPT=false
DB_TRUST_CERTIFICATE=true
DB_CONNECT_TIMEOUT=30000
# Application
NODE_ENV=development
# Security
BCRYPT_ROUNDS=12

315
DEPLOYMENT_GUIDE.md Normal file
View File

@@ -0,0 +1,315 @@
# 📘 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:1.0.3 .
```
### Bước 3: Push image mới lên Docker Hub
```powershell
docker push toiiiiday/accmanager:1.0.3
```
### 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:1.0.3
```
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 .env lên server
Từ máy dev:
```powershell
scp .env robotics@172.20.235.176:~/accmanager/.env
```
---
## 🐧 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
# Test HTTP
curl -I http://127.0.0.1:3000
```
### 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:1.0.3 .
```
3. Push:
```powershell
docker push toiiiiday/accmanager:1.0.3
```
4. Cập nhật `.env`:
```env
DOCKER_IMAGE=toiiiiday/accmanager:1.0.3
```
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)
### Trong Nginx Proxy Manager
1. **Add Proxy Host**
- Domain Names: `pnkr.asia` (domain của bạn)
- Scheme: `http`
- Forward Hostname/IP: `172.20.235.176`
- Forward Port: `3000`
- Websocket Support: ON
- Block Common Exploits: ON
2. **SSL**
- Request a new SSL Certificate (Let's Encrypt)
- Force SSL: ON
- HTTP/2 Support: ON
- HSTS: ON (sau khi test ổn định)
3. **Access**
- http://pnkr.asia → auto redirect sang https
- https://pnkr.asia ✅
---
## 📝 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
```

15
Dockerfile Normal file
View File

@@ -0,0 +1,15 @@
FROM node:20-bookworm-slim
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY backend ./backend
COPY public ./public
EXPOSE 3000
CMD ["node", "backend/server.js"]

284
README.md
View File

@@ -1,166 +1,196 @@
# 🎯 AccManager - SQL Server Backend Setup Complete
# 🔐 AccManager
## ✅ Database Configuration Complete
Hệ thống quản lý tài khoản và ứng dụng tập trung, cho phép quản lý người dùng, danh mục ứng dụng, và gán tài khoản truy cập.
SQL Server database **AccManager** has been successfully configured with all necessary tables and initial data.
## ✨ Tính năng
### 📊 Database Information
- ✅ Xác thực người dùng (login/register)
- ✅ Quản lý người dùng và vai trò (admin/guest)
- ✅ Quản lý danh mục ứng dụng
- ✅ Gán tài khoản cho mỗi người dùng/ứng dụng
- ✅ Mã hóa mật khẩu bằng bcrypt (bcrypt hashing)
- ✅ Migration tự động: plain text → bcrypt
- ✅ Audit log truy cập
```
Server IP: 172.20.235.176
Database: AccManager
User: sa
Password: robotics@2020
Port: 1433 (default)
```
## 🛠️ Công nghệ
### 👤 Default Admin Account
| Thành phần | Công nghệ |
|-----------|----------|
| Backend | Node.js 20 + Express.js |
| Database | SQL Server 2022 |
| Frontend | HTML5 + Tailwind CSS + Vanilla JS |
| Container | Docker + Docker Compose |
| Reverse Proxy | Nginx Proxy Manager |
| Password Hashing | bcrypt |
```
Username: admin
Password: admin
Role: admin
Status: Active
```
## 📦 Cấu trúc dự án
## 📋 Database Tables Created
\\\
├── backend/
│ └── server.js # Main server (Express + mssql)
├── public/ # Static files (HTML, CSS, JS)
│ ├── index.html
│ ├── pages/ # Login, dashboard, etc.
│ ├── js/
│ │ └── app.js # Frontend logic
│ └── css/
│ ├── main.css # Tailwind compiled
│ └── tailwind.css # Tailwind source
├── database/
│ └── setup.sql # Database schema
├── Dockerfile # Docker image config
├── docker-compose.yml # Local development
├── docker-compose.image.yml # Production deployment
├── .env # Environment variables
├── deploy-dev.ps1 # Build & push script
├── deploy-server.sh # Pull & deploy script
└── DEPLOYMENT_GUIDE.md # Detailed deployment guide (Vietnamese)
\\\
### 1. **Users** - User Management
- Stores login credentials and user roles
- Default admin user: admin/admin
## 🚀 Khởi động nhanh
### 2. **Applications** - Service Management
- 4 sample applications pre-loaded:
- AWS (Cloud) - online
- GitHub (VCS) - online
- Google Workspace (Collaboration) - online
- Nginx Proxy (Infra) - offline
### Phát triển local
### 3. **Accounts** - Credential Storage
- Stores credentials for each user-application combination
- Linked to Users and Applications tables
### 4. **AuditLog** - Activity Tracking
- Logs all INSERT, UPDATE, DELETE operations
- User actions tracked for security
## 🚀 Backend Server Options
### Option 1⃣: Node.js + Express (Recommended)
**Files:**
- `server.js` - Main server file
- `package.json` - Dependencies
**Quick Start:**
```bash
# 1. Install Node.js from https://nodejs.org/
# 2. Install dependencies
\\\ash
npm install
# 3. Run server
npm start
\\\
# Server runs on: http://localhost:3000
```
Truy cập: http://localhost:3000
### Option 2⃣: Python + Flask
### Chạy bằng Docker
**Files:**
- `server_python.py` - Main server file
- `requirements.txt` - Dependencies
\\\ash
docker compose build
docker compose up -d
\\\
**Quick Start:**
```bash
# 1. Install Python 3.8+ from https://www.python.org/
# 2. Install dependencies
pip install -r requirements.txt
## 🐳 Triển khai với Docker
# 3. Run server
python server_python.py
### Máy DEV: Build & Push
# Server runs on: http://localhost:5000
```
\\\powershell
cd D:\RoboticsSource\AccManager
.\deploy-dev.ps1 -Tag "1.0.1"
\\\
## 📡 API Endpoints
### Máy Server: Deploy
### Health Check
```http
GET /api/health
```
\\\ash
ssh robotics@172.20.235.176
cd ~/accmanager
bash deploy-server.sh
\\\
**Xem chi tiết**: [DEPLOYMENT_GUIDE.md](DEPLOYMENT_GUIDE.md) (Tiếng Việt)
## 🔐 Bảo mật
- Mật khẩu được mã hóa bằng bcrypt (12 rounds)
- Migration tự động từ plain text sang bcrypt
- Không lưu credential trong source control
- Sử dụng .env để cấu hình biến sensitive
- SQL Server connection sử dụng SSL/TLS tùy chọn
**Lưu ý:**
- Thay đổi mật khẩu admin mặc định (\dmin\/\dmin\) ngay sau khi đăng nhập lần đầu
- Không commit \.env\ thực tế lên git
## 📚 API Endpoints
### Authentication
```http
POST /api/auth/login
```
- POST \/api/auth/login\ - Đăng nhập
- POST \/api/auth/register\ - Đăng ký
### Users Management
```http
GET /api/users
GET /api/users/:id
POST /api/users
```
### Users (Admin)
- GET \/api/users\ - Lấy danh sách người dùng
- GET \/api/users/:id\ - Lấy thông tin người dùng
- POST \/api/users\ - Tạo người dùng mới
- PUT \/api/users/:id\ - Cập nhật người dùng
- DELETE \/api/users/:id\ - Xóa người dùng
### Applications
```http
GET /api/applications
POST /api/applications
```
- GET \/api/applications\ - Danh sách ứng dụng
- POST \/api/applications\ - Tạo ứng dụng
### Accounts
```http
GET /api/accounts/user/:userId
POST /api/accounts
```
- GET \/api/accounts/user/:userId\ - Tài khoản của người dùng
- POST \/api/accounts\ - Tạo tài khoản
### Database Info
```http
GET /api/database/info
```
## 🔧 Cấu hình
## 📚 Documentation Files
### .env (Development)
- **README.md** (this file) - Overview
- **SETUP_GUIDE.md** - Detailed installation steps
- **DATABASE_SETUP.md** - Schema and API documentation
- **server.js** - Node.js backend source
- **server_python.py** - Python backend source
\\\env
NODE_ENV=production
APP_PORT=3000
DOCKER_IMAGE=toiiiiday/accmanager:1.0.1
## 🔐 Default Credentials
# Container
PORT=3000
```
Username: admin
Password: admin
Role: admin
```
# Database
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
## 🔧 Project Files
# Security
BCRYPT_ROUNDS=12
\\\
```
d:\RoboticsSource\AccManager\
├── server.js (Node.js backend)
├── server_python.py (Python backend)
├── package.json (Node.js dependencies)
├── requirements.txt (Python dependencies)
├── .env (Configuration)
├── database/
│ └── setup.sql (SQL setup script)
├── SETUP_GUIDE.md (Installation guide)
├── DATABASE_SETUP.md (Database documentation)
└── README.md (This file)
```
## 🌐 Triển khai Public với Domain
## ✅ Status
Xem: [DEPLOYMENT_GUIDE.md](DEPLOYMENT_GUIDE.md) - Mục "Public Domain qua Nginx Proxy Manager"
- ✓ Database created (AccManager)
- ✓ 4 tables created (Users, Applications, Accounts, AuditLog)
- ✓ Admin user created (admin/admin)
- ✓ Sample applications added
- ✓ Backend servers ready (Node.js + Python options)
- ✓ API endpoints documented
**Tóm tắt:**
1. Trỏ DNS A record về IP Nginx Proxy Manager
2. Tạo Proxy Host trong NPM: forward 3000 → domain
3. Enable Let's Encrypt SSL + Force SSL
## 📝 Tài liệu
- [DEPLOYMENT_GUIDE.md](DEPLOYMENT_GUIDE.md) - Hướng dẫn triển khai (Tiếng Việt)
- [package.json](package.json) - Dependencies
- [docker-compose.yml](docker-compose.yml) - Dev config
- [docker-compose.image.yml](docker-compose.image.yml) - Prod config
## 📦 Build & Run Scripts
**Windows PowerShell (DEV):**
\\\powershell
.\deploy-dev.ps1 -Tag "1.0.1"
\\\
**Linux/Mac (SERVER):**
\\\ash
bash deploy-server.sh
\\\
## 🆘 Troubleshoot
**Container không start?**
\\\ash
docker compose -f docker-compose.image.yml logs --tail=100 accmanager
\\\
**Image pull fail?**
- Kiểm tra Docker Hub credentials
- Kiểm tra image đã push: https://hub.docker.com/r/toiiiiday/accmanager
**Database connection fail?**
- Kiểm tra .env (DB_SERVER, DB_USER, DB_PASSWORD, DB_NAME)
- Ping server: \ping 172.20.235.176\
## 📄 License
MIT
---
**Version:** 2.0.0 (Backend Ready)
**Database:** SQL Server / AccManager
**Last Updated:** March 27, 2026
**Phiên bản:** 2.0.0
**Cập nhật:** Tháng 4 năm 2026
**Trạng thái:** Production-ready ✅

View File

@@ -4,8 +4,87 @@
const express = require('express');
const sql = require('mssql');
const cors = require('cors');
const bcrypt = require('bcrypt');
const crypto = require('crypto');
const dotenv = require('dotenv');
const app = express();
dotenv.config();
function envBool(name, defaultValue) {
const value = process.env[name];
if (value === undefined) {
return defaultValue;
}
return ['1', 'true', 'yes', 'on'].includes(String(value).toLowerCase());
}
const DB_SERVER = process.env.DB_SERVER || '172.20.235.176';
const DB_USER = process.env.DB_USER || 'sa';
const DB_PASSWORD = process.env.DB_PASSWORD || 'robotics@2022';
const DB_NAME = process.env.DB_NAME || 'AccManager';
const DB_ENCRYPT = envBool('DB_ENCRYPT', false);
const DB_TRUST_CERTIFICATE = envBool('DB_TRUST_CERTIFICATE', true);
const DB_CONNECT_TIMEOUT = Number(process.env.DB_CONNECT_TIMEOUT || 30000);
const BCRYPT_ROUNDS = Number(process.env.BCRYPT_ROUNDS || 12);
const PASSWORD_VIEW_SECRET = process.env.PASSWORD_VIEW_SECRET || 'change-this-password-view-secret';
const PASSWORD_VIEW_KEY = crypto.createHash('sha256').update(String(PASSWORD_VIEW_SECRET)).digest();
const PASSWORD_VIEW_PREFIX = 'enc:v1';
function isBcryptHash(value) {
return typeof value === 'string' && /^\$2[aby]\$\d{2}\$[./A-Za-z0-9]{53}$/.test(value);
}
async function hashPassword(plainPassword) {
return bcrypt.hash(String(plainPassword), BCRYPT_ROUNDS);
}
async function verifyPassword(plainPassword, storedPassword) {
if (isBcryptHash(storedPassword)) {
return bcrypt.compare(String(plainPassword), storedPassword);
}
// Legacy fallback for old plain-text records.
return String(plainPassword) === String(storedPassword || '');
}
function encryptPasswordForView(plainPassword) {
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', PASSWORD_VIEW_KEY, iv);
const encrypted = Buffer.concat([
cipher.update(String(plainPassword), 'utf8'),
cipher.final()
]);
const tag = cipher.getAuthTag();
return `${PASSWORD_VIEW_PREFIX}:${iv.toString('base64')}:${tag.toString('base64')}:${encrypted.toString('base64')}`;
}
function decryptPasswordForView(payload) {
try {
if (typeof payload !== 'string' || !payload.startsWith(`${PASSWORD_VIEW_PREFIX}:`)) {
return null;
}
const parts = payload.split(':');
if (parts.length !== 5) {
return null;
}
const iv = Buffer.from(parts[2], 'base64');
const tag = Buffer.from(parts[3], 'base64');
const encrypted = Buffer.from(parts[4], 'base64');
const decipher = crypto.createDecipheriv('aes-256-gcm', PASSWORD_VIEW_KEY, iv);
decipher.setAuthTag(tag);
const plain = Buffer.concat([decipher.update(encrypted), decipher.final()]);
return plain.toString('utf8');
} catch (err) {
return null;
}
}
// Middleware
app.use(cors());
app.use(express.json());
@@ -22,20 +101,20 @@ app.get('/', (req, res) => {
// SQL Server Configuration
const sqlConfig = {
server: '172.20.235.176',
server: DB_SERVER,
authentication: {
type: 'default',
options: {
userName: 'sa',
password: 'robotics@2022'
userName: DB_USER,
password: DB_PASSWORD
}
},
options: {
database: 'AccManager',
trustServerCertificate: true,
database: DB_NAME,
trustServerCertificate: DB_TRUST_CERTIFICATE,
enableKeepAlive: true,
connectTimeout: 30000,
encrypt: false
connectTimeout: DB_CONNECT_TIMEOUT,
encrypt: DB_ENCRYPT
}
};
@@ -50,9 +129,14 @@ async function initializeDatabase() {
// Check and create database if not exists
const masterConnection = new sql.ConnectionPool({
server: '172.20.235.176',
authentication: { type: 'default', options: { userName: 'sa', password: 'robotics@2022' } },
options: { connectTimeout: 30000, database: 'master', trustServerCertificate: true, encrypt: false }
server: DB_SERVER,
authentication: { type: 'default', options: { userName: DB_USER, password: DB_PASSWORD } },
options: {
connectTimeout: DB_CONNECT_TIMEOUT,
database: 'master',
trustServerCertificate: DB_TRUST_CERTIFICATE,
encrypt: DB_ENCRYPT
}
});
await masterConnection.connect();
@@ -65,6 +149,7 @@ async function initializeDatabase() {
// Now create tables in AccManager
await createTables();
await migrateLegacyPasswords();
console.log('✓ Database and tables created');
} catch (err) {
@@ -73,6 +158,47 @@ async function initializeDatabase() {
}
}
async function migrateLegacyPasswords() {
try {
const usersResult = await pool.request()
.query('SELECT UserId, Password, ViewPassword FROM Users WHERE Password IS NOT NULL');
let migratedCount = 0;
for (const row of usersResult.recordset) {
const request = pool.request()
.input('userId', sql.Int, row.UserId);
let hasUpdates = false;
const rawPassword = String(row.Password || '');
if (!row.ViewPassword && !isBcryptHash(rawPassword)) {
request.input('viewPassword', sql.NVarChar, encryptPasswordForView(rawPassword));
hasUpdates = true;
}
if (!isBcryptHash(row.Password)) {
const hashedPassword = await hashPassword(row.Password);
request.input('password', sql.NVarChar, hashedPassword);
hasUpdates = true;
migratedCount += 1;
}
if (hasUpdates) {
await request.query(`UPDATE Users
SET ${!isBcryptHash(rawPassword) ? 'Password = @password' : 'Password = Password'}
${(!row.ViewPassword && !isBcryptHash(rawPassword)) ? ', ViewPassword = @viewPassword' : ''}
WHERE UserId = @userId`);
}
}
if (migratedCount > 0) {
console.log(`✓ Migrated ${migratedCount} legacy plain-text password(s) to bcrypt`);
}
} catch (err) {
console.error('Password migration error:', err.message);
}
}
async function createTables() {
const queries = [
// Users Table
@@ -157,6 +283,7 @@ async function createTables() {
try {
await pool.request().query(`IF COL_LENGTH('dbo.Applications','Url') IS NULL ALTER TABLE Applications ADD Url NVARCHAR(255);`);
await pool.request().query(`IF COL_LENGTH('dbo.Applications','Description') IS NULL ALTER TABLE Applications ADD Description NVARCHAR(500);`);
await pool.request().query(`IF COL_LENGTH('dbo.Users','ViewPassword') IS NULL ALTER TABLE Users ADD ViewPassword NVARCHAR(1024);`);
// Backfill Url to empty string to avoid undefined in responses
await pool.request().query(`UPDATE Applications SET Url = '' WHERE Url IS NULL;`);
} catch (err) {
@@ -165,15 +292,18 @@ async function createTables() {
// Insert initial admin user
try {
const adminPasswordHash = await hashPassword('admin');
const adminViewPassword = encryptPasswordForView('admin');
await pool.request()
.input('username', sql.NVarChar, 'admin')
.input('password', sql.NVarChar, 'admin')
.input('password', sql.NVarChar, adminPasswordHash)
.input('viewPassword', sql.NVarChar, adminViewPassword)
.input('email', sql.NVarChar, 'admin@accmanager.local')
.input('fullname', sql.NVarChar, 'Administrator')
.input('role', sql.NVarChar, 'admin')
.query(`IF NOT EXISTS (SELECT * FROM Users WHERE Username = @username)
INSERT INTO Users (Username, Password, Email, FullName, Role, IsActive)
VALUES (@username, @password, @email, @fullname, @role, 1)`);
INSERT INTO Users (Username, Password, ViewPassword, Email, FullName, Role, IsActive)
VALUES (@username, @password, @viewPassword, @email, @fullname, @role, 1)`);
console.log('✓ Admin user created: admin / admin');
} catch (err) {
console.error('Admin user error:', err.message);
@@ -205,14 +335,41 @@ async function createTables() {
app.post('/api/auth/login', async (req, res) => {
try {
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({
success: false,
message: 'Username and password are required'
});
}
const result = await pool.request()
.input('username', sql.NVarChar, username)
.input('password', sql.NVarChar, password)
.query('SELECT UserId, Username, Email, FullName, Role, Status FROM Users WHERE Username = @username AND Password = @password AND IsActive = 1');
.query('SELECT UserId, Username, Email, FullName, Role, RoleId, Status, Password FROM Users WHERE Username = @username AND IsActive = 1');
if (result.recordset.length > 0) {
const user = result.recordset[0];
const dbUser = result.recordset[0];
const isValidPassword = await verifyPassword(password, dbUser.Password);
if (!isValidPassword) {
return res.status(401).json({
success: false,
message: 'Invalid username or password'
});
}
// Upgrade old plain-text passwords after successful legacy login.
if (!isBcryptHash(dbUser.Password)) {
const upgradedHash = await hashPassword(password);
await pool.request()
.input('userId', sql.Int, dbUser.UserId)
.input('password', sql.NVarChar, upgradedHash)
.input('viewPassword', sql.NVarChar, encryptPasswordForView(password))
.query('UPDATE Users SET Password = @password, ViewPassword = ISNULL(ViewPassword, @viewPassword) WHERE UserId = @userId');
}
const { Password: _, ...safeUser } = dbUser;
const user = { ...safeUser, role: safeUser.Role || safeUser.role || 'guest' };
// Update last login
await pool.request()
@@ -236,6 +393,217 @@ app.post('/api/auth/login', async (req, res) => {
}
});
// Public registration endpoint
app.post('/api/auth/register', async (req, res) => {
try {
const { username, password, email, fullname } = req.body;
if (!username || !password) {
return res.status(400).json({ success: false, message: 'Username and password are required' });
}
// Prevent duplicate usernames
const existing = await pool.request()
.input('username', sql.NVarChar, username)
.query('SELECT UserId FROM Users WHERE Username = @username');
if (existing.recordset.length > 0) {
return res.status(409).json({ success: false, message: 'Username already exists' });
}
const hashedPassword = await hashPassword(password);
const viewPassword = encryptPasswordForView(password);
const safeFullname = fullname && fullname.trim() ? fullname.trim() : username;
let guestRoleName = 'guest';
let guestRoleId = null;
const hasRoleIdColResult = await pool.request()
.query("SELECT CASE WHEN COL_LENGTH('dbo.Users','RoleId') IS NULL THEN 0 ELSE 1 END AS HasRoleId");
const hasRoleIdColumn = hasRoleIdColResult.recordset[0].HasRoleId === 1;
const hasRolesTableResult = await pool.request()
.query("SELECT CASE WHEN OBJECT_ID('dbo.Roles','U') IS NULL THEN 0 ELSE 1 END AS HasRolesTable");
const hasRolesTable = hasRolesTableResult.recordset[0].HasRolesTable === 1;
if (hasRolesTable) {
const guestRoleResult = await pool.request().query(`
IF NOT EXISTS (SELECT 1 FROM Roles WHERE LOWER(RoleName) = 'guest')
BEGIN
INSERT INTO Roles (RoleName, Description)
VALUES ('Guest', 'Default role for self-registered users');
END
SELECT TOP 1 RoleId, RoleName
FROM Roles
WHERE LOWER(RoleName) = 'guest';
`);
if (guestRoleResult.recordset.length > 0) {
guestRoleId = guestRoleResult.recordset[0].RoleId;
guestRoleName = guestRoleResult.recordset[0].RoleName || 'guest';
}
}
let result;
if (hasRoleIdColumn && guestRoleId !== null) {
result = await pool.request()
.input('username', sql.NVarChar, username)
.input('password', sql.NVarChar, hashedPassword)
.input('email', sql.NVarChar, email || null)
.input('fullname', sql.NVarChar, safeFullname)
.input('roleId', sql.Int, guestRoleId)
.input('role', sql.NVarChar, guestRoleName)
.input('viewPassword', sql.NVarChar, viewPassword)
.query(`INSERT INTO Users (Username, Password, ViewPassword, Email, FullName, RoleId, Role, Status, IsActive)
OUTPUT INSERTED.UserId, INSERTED.Username, INSERTED.Email, INSERTED.FullName, INSERTED.Role, INSERTED.RoleId
VALUES (@username, @password, @viewPassword, @email, @fullname, @roleId, @role, 'Active', 1)`);
} else {
result = await pool.request()
.input('username', sql.NVarChar, username)
.input('password', sql.NVarChar, hashedPassword)
.input('email', sql.NVarChar, email || null)
.input('fullname', sql.NVarChar, safeFullname)
.input('role', sql.NVarChar, guestRoleName)
.input('viewPassword', sql.NVarChar, viewPassword)
.query(`INSERT INTO Users (Username, Password, ViewPassword, Email, FullName, Role, Status, IsActive)
OUTPUT INSERTED.UserId, INSERTED.Username, INSERTED.Email, INSERTED.FullName, INSERTED.Role
VALUES (@username, @password, @viewPassword, @email, @fullname, @role, 'Active', 1)`);
}
const inserted = result.recordset[0];
const user = { ...inserted, role: inserted.Role || guestRoleName || 'guest' };
// Repair previous self-registered users that were wrongly assigned Admin RoleId.
if (hasRoleIdColumn && guestRoleId !== null) {
await pool.request()
.input('guestRoleId', sql.Int, guestRoleId)
.query(`UPDATE Users
SET RoleId = @guestRoleId,
Role = 'Guest'
WHERE LOWER(Role) = 'guest'
AND (RoleId IS NULL OR RoleId <> @guestRoleId)`);
}
res.json({ success: true, message: 'Registration successful', user });
} catch (err) {
console.error('Registration error:', err);
res.status(500).json({ success: false, message: 'Registration failed' });
}
});
// Middleware for role-based access control
const requireAdmin = (req, res, next) => {
const userRole = req.headers['x-user-role'] || req.query.userRole;
if (userRole !== 'admin') {
return res.status(403).json({ success: false, message: 'Admin access required' });
}
next();
};
// ==========================================
// API ROUTES - Roles
// ==========================================
// Get all roles
app.get('/api/roles', async (req, res) => {
try {
const result = await pool.request()
.query('SELECT RoleId, RoleName, Description, CreatedDate FROM Roles ORDER BY RoleName');
res.json({ success: true, data: result.recordset });
} catch (err) {
res.status(500).json({ success: false, message: err.message });
}
});
// Get role by ID
app.get('/api/roles/:id', async (req, res) => {
try {
const result = await pool.request()
.input('roleId', sql.Int, req.params.id)
.query('SELECT * FROM Roles WHERE RoleId = @roleId');
if (result.recordset.length > 0) {
res.json({ success: true, data: result.recordset[0] });
} else {
res.status(404).json({ success: false, message: 'Role not found' });
}
} catch (err) {
res.status(500).json({ success: false, message: err.message });
}
});
// Create new role (Admin only)
app.post('/api/roles', requireAdmin, async (req, res) => {
try {
const { roleName, description } = req.body;
const result = await pool.request()
.input('roleName', sql.NVarChar, roleName)
.input('description', sql.NVarChar, description)
.query(`IF NOT EXISTS (SELECT * FROM Roles WHERE RoleName = @roleName)
BEGIN
INSERT INTO Roles (RoleName, Description)
VALUES (@roleName, @description);
SELECT SCOPE_IDENTITY() as RoleId
END
ELSE
BEGIN
SELECT RoleId FROM Roles WHERE RoleName = @roleName
END`);
res.json({ success: true, message: 'Role created', roleId: result.recordset[0].RoleId });
} catch (err) {
res.status(500).json({ success: false, message: err.message });
}
});
// Update role (Admin only)
app.put('/api/roles/:id', requireAdmin, async (req, res) => {
try {
const { roleName, description } = req.body;
await pool.request()
.input('roleId', sql.Int, req.params.id)
.input('roleName', sql.NVarChar, roleName)
.input('description', sql.NVarChar, description)
.query(`UPDATE Roles
SET RoleName = @roleName,
Description = @description
WHERE RoleId = @roleId`);
res.json({ success: true, message: 'Role updated' });
} catch (err) {
res.status(500).json({ success: false, message: err.message });
}
});
// Delete role (Admin only)
app.delete('/api/roles/:id', requireAdmin, async (req, res) => {
try {
// Check if role is in use
const check = await pool.request()
.input('roleId', sql.Int, req.params.id)
.query('SELECT COUNT(*) as Count FROM Users WHERE RoleId = @roleId');
if (check.recordset[0].Count > 0) {
return res.status(400).json({
success: false,
message: 'Cannot delete role - it is assigned to users'
});
}
await pool.request()
.input('roleId', sql.Int, req.params.id)
.query('DELETE FROM Roles WHERE RoleId = @roleId');
res.json({ success: true, message: 'Role deleted' });
} catch (err) {
res.status(500).json({ success: false, message: err.message });
}
});
// ==========================================
// API ROUTES - Users
// ==========================================
@@ -244,7 +612,11 @@ app.post('/api/auth/login', async (req, res) => {
app.get('/api/users', async (req, res) => {
try {
const result = await pool.request()
.query('SELECT UserId, Username, Email, FullName, Role, Status, CreatedDate FROM Users ORDER BY CreatedDate DESC');
.query(`SELECT u.UserId, u.Username, u.Email, u.FullName, u.Role, u.RoleId, r.RoleName,
u.Status, u.CreatedDate, u.LastLogin, u.IsActive
FROM Users u
LEFT JOIN Roles r ON u.RoleId = r.RoleId
ORDER BY u.CreatedDate DESC`);
res.json({ success: true, data: result.recordset });
} catch (err) {
res.status(500).json({ success: false, message: err.message });
@@ -252,14 +624,27 @@ app.get('/api/users', async (req, res) => {
});
// Get user by ID
app.get('/api/users/:id', async (req, res) => {
app.get('/api/users/:id', requireAdmin, async (req, res) => {
try {
const result = await pool.request()
.input('userId', sql.Int, req.params.id)
.query('SELECT * FROM Users WHERE UserId = @userId');
.query(`SELECT u.*, r.RoleName FROM Users u
LEFT JOIN Roles r ON u.RoleId = r.RoleId
WHERE u.UserId = @userId`);
if (result.recordset.length > 0) {
res.json({ success: true, data: result.recordset[0] });
const record = result.recordset[0];
const viewPassword = decryptPasswordForView(record.ViewPassword || '');
const fallbackPlainPassword = !isBcryptHash(record.Password) ? String(record.Password || '') : '';
const plainPassword = viewPassword || fallbackPlainPassword;
const userDetails = {
...record,
Password: plainPassword,
PasswordAvailable: Boolean(plainPassword)
};
res.json({ success: true, data: userDetails });
} else {
res.status(404).json({ success: false, message: 'User not found' });
}
@@ -268,22 +653,125 @@ app.get('/api/users/:id', async (req, res) => {
}
});
// Create new user
app.post('/api/users', async (req, res) => {
// Create new user (Admin only)
app.post('/api/users', requireAdmin, async (req, res) => {
try {
const { username, password, email, fullname, role } = req.body;
const { username, password, email, fullname, roleId } = req.body;
if (!username || !password) {
return res.status(400).json({ success: false, message: 'Username and password are required' });
}
const hashedPassword = await hashPassword(password);
const viewPassword = encryptPasswordForView(password);
const finalRoleId = roleId || 2; // Default to Guest role
// Get role name from Roles table
const roleResult = await pool.request()
.input('roleId', sql.Int, finalRoleId)
.query('SELECT RoleName FROM Roles WHERE RoleId = @roleId');
const roleName = roleResult.recordset.length > 0 ? roleResult.recordset[0].RoleName : 'guest';
const result = await pool.request()
.input('username', sql.NVarChar, username)
.input('password', sql.NVarChar, password)
.input('email', sql.NVarChar, email)
.input('password', sql.NVarChar, hashedPassword)
.input('email', sql.NVarChar, email || null)
.input('fullname', sql.NVarChar, fullname)
.input('role', sql.NVarChar, role)
.query(`INSERT INTO Users (Username, Password, Email, FullName, Role, IsActive)
VALUES (@username, @password, @email, @fullname, @role, 1);
SELECT SCOPE_IDENTITY() as UserId`);
.input('roleId', sql.Int, finalRoleId)
.input('role', sql.NVarChar, roleName)
.input('viewPassword', sql.NVarChar, viewPassword)
.query(`IF NOT EXISTS (SELECT * FROM Users WHERE Username = @username)
BEGIN
INSERT INTO Users (Username, Password, ViewPassword, Email, FullName, RoleId, Role, IsActive)
VALUES (@username, @password, @viewPassword, @email, @fullname, @roleId, @role, 1);
SELECT SCOPE_IDENTITY() as UserId
END
ELSE
BEGIN
SELECT NULL as UserId
END`);
res.json({ success: true, message: 'User created', userId: result.recordset[0].UserId });
if (result.recordset[0].UserId) {
res.json({ success: true, message: 'User created', userId: result.recordset[0].UserId });
} else {
res.status(400).json({ success: false, message: 'Username already exists' });
}
} catch (err) {
res.status(500).json({ success: false, message: err.message });
}
});
// Update user (Admin only)
app.put('/api/users/:id', requireAdmin, async (req, res) => {
try {
const { email, fullname, roleId, status, isActive, password } = req.body;
const nextPassword = typeof password === 'string' ? password.trim() : '';
const shouldUpdatePassword = nextPassword.length > 0;
// Get role name from Roles table
let roleName = '';
if (roleId) {
const roleResult = await pool.request()
.input('roleId', sql.Int, roleId)
.query('SELECT RoleName FROM Roles WHERE RoleId = @roleId');
roleName = roleResult.recordset.length > 0 ? roleResult.recordset[0].RoleName : '';
}
const request = pool.request()
.input('userId', sql.Int, req.params.id)
.input('email', sql.NVarChar, email || null)
.input('fullname', sql.NVarChar, fullname)
.input('roleId', sql.Int, roleId)
.input('role', sql.NVarChar, roleName)
.input('status', sql.NVarChar, status)
.input('isActive', sql.Bit, isActive);
if (shouldUpdatePassword) {
const hashedPassword = await hashPassword(nextPassword);
const viewPassword = encryptPasswordForView(nextPassword);
request.input('password', sql.NVarChar, hashedPassword);
request.input('viewPassword', sql.NVarChar, viewPassword);
}
await request.query(`UPDATE Users
SET Email = @email,
FullName = @fullname,
RoleId = @roleId,
Role = @role,
Status = @status,
IsActive = @isActive
${shouldUpdatePassword ? ', Password = @password, ViewPassword = @viewPassword' : ''}
WHERE UserId = @userId`);
res.json({ success: true, message: shouldUpdatePassword ? 'User updated and password changed' : 'User updated' });
} catch (err) {
res.status(500).json({ success: false, message: err.message });
}
});
// Delete user (Admin only)
app.delete('/api/users/:id', requireAdmin, async (req, res) => {
try {
// Prevent deleting the current admin user (ID = 1)
if (req.params.id === '1') {
return res.status(400).json({
success: false,
message: 'Cannot delete the primary admin user'
});
}
// Delete associated accounts first
await pool.request()
.input('userId', sql.Int, req.params.id)
.query('DELETE FROM Accounts WHERE UserId = @userId');
// Then delete the user
await pool.request()
.input('userId', sql.Int, req.params.id)
.query('DELETE FROM Users WHERE UserId = @userId');
res.json({ success: true, message: 'User deleted' });
} catch (err) {
res.status(500).json({ success: false, message: err.message });
}
@@ -377,9 +865,10 @@ app.get('/api/accounts/user/:userId', async (req, res) => {
try {
const result = await pool.request()
.input('userId', sql.Int, req.params.userId)
.query(`SELECT a.*, app.Name as AppName, app.Type as AppType
.query(`SELECT a.*, app.Name as AppName, app.Type as AppType, u.Username
FROM Accounts a
JOIN Applications app ON a.AppId = app.AppId
JOIN Users u ON a.UserId = u.UserId
WHERE a.UserId = @userId
ORDER BY a.CreatedDate DESC`);
res.json({ success: true, data: result.recordset });
@@ -388,6 +877,21 @@ app.get('/api/accounts/user/:userId', async (req, res) => {
}
});
// Get all accounts (from all users)
app.get('/api/accounts/all', async (req, res) => {
try {
const result = await pool.request()
.query(`SELECT a.*, app.Name as AppName, app.Type as AppType, u.Username, u.FullName
FROM Accounts a
JOIN Applications app ON a.AppId = app.AppId
JOIN Users u ON a.UserId = u.UserId
ORDER BY a.CreatedDate DESC`);
res.json({ success: true, data: result.recordset });
} catch (err) {
res.status(500).json({ success: false, message: err.message });
}
});
// Create account
app.post('/api/accounts', async (req, res) => {
try {

18
docker-compose.image.yml Normal file
View File

@@ -0,0 +1,18 @@
services:
accmanager:
image: ${DOCKER_IMAGE:-yourdockerhub/accmanager:latest}
container_name: accmanager
restart: unless-stopped
ports:
- "${APP_PORT:-3000}:3000"
environment:
NODE_ENV: ${NODE_ENV:-production}
PORT: 3000
DB_SERVER: ${DB_SERVER:-172.20.235.176}
DB_USER: ${DB_USER:-sa}
DB_PASSWORD: ${DB_PASSWORD:-changeme}
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}

20
docker-compose.yml Normal file
View File

@@ -0,0 +1,20 @@
services:
accmanager:
build:
context: .
dockerfile: Dockerfile
container_name: accmanager
restart: unless-stopped
ports:
- "${APP_PORT:-3000}:3000"
environment:
NODE_ENV: production
PORT: 3000
DB_SERVER: ${DB_SERVER:-172.20.235.176}
DB_USER: ${DB_USER:-sa}
DB_PASSWORD: ${DB_PASSWORD:-changeme}
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}

View File

@@ -1,134 +0,0 @@
# Robot Account Manager - Project Structure
## Directory Organization
```
AccManager/
├── pages/ # HTML pages
│ ├── login.html # Login page (entry point)
│ ├── index.html # Dashboard
│ ├── accounts.html # Accounts management
│ └── applications.html # Applications management
├── js/ # JavaScript files
│ └── app.js # Main application logic
├── docs/ # Documentation
│ └── README.md # This file
├── index.html # Root redirect to pages/login.html
├── .git/ # Version control
├── .vscode/ # VS Code settings
└── README.md # Project root README
```
## Quick Start
1. **Access the application:**
- Open `index.html` in a browser
- You will be automatically redirected to `pages/login.html`
2. **Login Credentials (Demo):**
- Username: `admin`
- Password: `admin`
3. **Features:**
- Dashboard: Overview of accounts and applications
- Accounts: Manage user accounts for various services
- Applications: Manage connected applications/services
## File Structure Explanation
### /pages/
Contains all HTML pages with updated relative paths for script imports:
- `login.html` - Authentication page
- `index.html` - Main dashboard
- `accounts.html` - Accounts management interface
- `applications.html` - Applications management interface
All pages reference scripts using `../js/app.js` for correct path resolution.
### /js/
Application logic and state management:
- `app.js` - Main AccountManager class with all functionality
### /docs/
Documentation files for reference and development.
## Application Architecture
### AccountManager Class (app.js)
- **Storage**: Uses localStorage for data persistence
- **Pages**: Dynamically renders content based on user navigation
- **Authentication**: Checks for valid session on page load
- **Modals**: Manages adding/editing accounts and applications
### Data Storage
Application stores data in browser's localStorage:
- `currentUser` - Logged-in user information
- `accounts` - List of managed accounts
- `applications` - List of managed applications
- `rememberedUsername` - Optional saved username
## Development Notes
- **Framework**: Tailwind CSS for styling
- **Icons**: Material Design symbols
- **Storage**: Browser localStorage (client-side only)
- **Responsive**: Built with mobile-first approach
- **Modern**: ES6+ JavaScript features
## Navigation Flow
```
index.html (root)
pages/login.html
↓ (after login)
pages/index.html (dashboard)
├→ pages/accounts.html
└→ pages/applications.html
```
## Features
### Authentication
- Username/password login
- Session management
- Remember me functionality
- Logout with confirmation
### Dashboard
- Overview statistics
- Recent account activity
- Quick access to management pages
### Account Management
- Add new accounts
- Edit existing accounts
- Delete accounts
- Filter by service
- View account details
### Application Management
- Add new applications
- Edit application details
- Delete applications
- View application status (online/offline)
- Health monitoring
## Browser Compatibility
- Modern browsers (Chrome, Firefox, Safari, Edge)
- Requires JavaScript enabled
- Uses localStorage API
## Future Improvements
- Server-side authentication
- Database integration
- Advanced filtering and search
- User roles and permissions
- Audit logging
- Dark mode toggle persistence
---
Generated: March 27, 2026
Version: 1.0.0

1027
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,18 +5,31 @@
"main": "backend/server.js",
"scripts": {
"start": "node backend/server.js",
"dev": "nodemon backend/server.js"
"dev": "nodemon backend/server.js",
"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"
},
"keywords": ["accmanager", "backend", "express", "mssql"],
"keywords": [
"accmanager",
"backend",
"express",
"mssql"
],
"author": "",
"license": "MIT",
"dependencies": {
"express": "^4.18.2",
"mssql": "^9.1.1",
"bcrypt": "^6.0.0",
"cors": "^2.8.5",
"dotenv": "^16.3.1"
"dotenv": "^16.3.1",
"express": "^4.18.2",
"mssql": "^9.1.1"
},
"devDependencies": {
"nodemon": "^3.0.1"
"@tailwindcss/container-queries": "^0.1.1",
"@tailwindcss/forms": "^0.5.7",
"autoprefixer": "^10.4.19",
"nodemon": "^3.0.1",
"postcss": "^8.4.38",
"tailwindcss": "^3.4.13"
}
}

6
postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};

1
public/css/main.css Normal file

File diff suppressed because one or more lines are too long

3
public/css/tailwind.css Normal file
View File

@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

File diff suppressed because it is too large Load Diff

View File

@@ -8,78 +8,10 @@
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet"/>
<!-- Material Symbols -->
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap" rel="stylesheet"/>
<link rel="stylesheet" href="../css/main.css" />
<!-- 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.tailwindcss.com?plugins=forms,container-queries"></script>
<script src="https://cdn.jsdelivr.net/npm/notiflix@3.2.7/dist/notiflix-aio-3.2.7.min.js"></script>
<script id="tailwind-config">
tailwind.config = {
darkMode: "class",
theme: {
extend: {
colors: {
"on-secondary-fixed-variant": "#4e5c71",
"on-secondary": "#f8f8ff",
"secondary-fixed-dim": "#c7d5ed",
"surface-variant": "#d9e4ea",
"surface-tint": "#3755c3",
"primary-container": "#dde1ff",
"primary-dim": "#2848b7",
"on-background": "#2a3439",
"surface-container-lowest": "#ffffff",
"tertiary-fixed-dim": "#d4cdee",
"on-tertiary-container": "#514d68",
"error-container": "#fe8983",
"on-secondary-container": "#455367",
"outline": "#717c82",
"on-primary": "#f8f7ff",
"on-primary-container": "#2747b6",
"inverse-primary": "#6d89fa",
"on-surface": "#2a3439",
"primary-fixed": "#dde1ff",
"on-primary-fixed": "#0732a3",
"secondary-dim": "#465468",
"surface-container-high": "#e1e9ee",
"surface-container-highest": "#d9e4ea",
"on-primary-fixed-variant": "#3352c0",
"on-error-container": "#752121",
"secondary": "#526074",
"tertiary-fixed": "#e3dbfd",
"primary": "#3755c3",
"surface-dim": "#cfdce3",
"tertiary": "#605c78",
"on-error": "#fff7f6",
"secondary-fixed": "#d5e3fc",
"error-dim": "#4e0309",
"surface-bright": "#f7f9fb",
"on-surface-variant": "#566166",
"on-tertiary": "#fcf7ff",
"tertiary-container": "#e3dbfd",
"inverse-on-surface": "#9a9d9f",
"on-tertiary-fixed-variant": "#5b5672",
"tertiary-dim": "#54506b",
"outline-variant": "#a9b4b9",
"on-secondary-fixed": "#324053",
"inverse-surface": "#0b0f10",
"on-tertiary-fixed": "#3e3a54",
"primary-fixed-dim": "#cad2ff",
"surface-container": "#e8eff3",
"secondary-container": "#d5e3fc",
"surface-container-low": "#f0f4f7",
"background": "#f7f9fb",
"error": "#9f403d",
"surface": "#f7f9fb"
},
fontFamily: {
"headline": ["Manrope"],
"body": ["Inter"],
"label": ["Inter"]
},
borderRadius: {"DEFAULT": "0.125rem", "lg": "0.25rem", "xl": "0.5rem", "full": "0.75rem"},
},
},
}
</script>
<style>
.material-symbols-outlined { font-family: 'Material Symbols Outlined'; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20; font-size: 1.25rem; line-height: 1; letter-spacing: normal; text-transform: none; display: inline-flex; white-space: nowrap; word-wrap: normal; direction: ltr; }
body { font-family: 'Inter', sans-serif; height: 100vh; overflow: hidden; }

View File

@@ -8,78 +8,10 @@
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet"/>
<!-- Material Symbols -->
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap" rel="stylesheet"/>
<link rel="stylesheet" href="../css/main.css" />
<!-- 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.tailwindcss.com?plugins=forms,container-queries"></script>
<script src="https://cdn.jsdelivr.net/npm/notiflix@3.2.7/dist/notiflix-aio-3.2.7.min.js"></script>
<script id="tailwind-config">
tailwind.config = {
darkMode: "class",
theme: {
extend: {
colors: {
"on-secondary-fixed-variant": "#4e5c71",
"on-secondary": "#f8f8ff",
"secondary-fixed-dim": "#c7d5ed",
"surface-variant": "#d9e4ea",
"surface-tint": "#3755c3",
"primary-container": "#dde1ff",
"primary-dim": "#2848b7",
"on-background": "#2a3439",
"surface-container-lowest": "#ffffff",
"tertiary-fixed-dim": "#d4cdee",
"on-tertiary-container": "#514d68",
"error-container": "#fe8983",
"on-secondary-container": "#455367",
"outline": "#717c82",
"on-primary": "#f8f7ff",
"on-primary-container": "#2747b6",
"inverse-primary": "#6d89fa",
"on-surface": "#2a3439",
"primary-fixed": "#dde1ff",
"on-primary-fixed": "#0732a3",
"secondary-dim": "#465468",
"surface-container-high": "#e1e9ee",
"surface-container-highest": "#d9e4ea",
"on-primary-fixed-variant": "#3352c0",
"on-error-container": "#752121",
"secondary": "#526074",
"tertiary-fixed": "#e3dbfd",
"primary": "#3755c3",
"surface-dim": "#cfdce3",
"tertiary": "#605c78",
"on-error": "#fff7f6",
"secondary-fixed": "#d5e3fc",
"error-dim": "#4e0309",
"surface-bright": "#f7f9fb",
"on-surface-variant": "#566166",
"on-tertiary": "#fcf7ff",
"tertiary-container": "#e3dbfd",
"inverse-on-surface": "#9a9d9f",
"on-tertiary-fixed-variant": "#5b5672",
"tertiary-dim": "#54506b",
"outline-variant": "#a9b4b9",
"on-secondary-fixed": "#324053",
"inverse-surface": "#0b0f10",
"on-tertiary-fixed": "#3e3a54",
"primary-fixed-dim": "#cad2ff",
"surface-container": "#e8eff3",
"secondary-container": "#d5e3fc",
"surface-container-low": "#f0f4f7",
"background": "#f7f9fb",
"error": "#9f403d",
"surface": "#f7f9fb"
},
fontFamily: {
"headline": ["Manrope"],
"body": ["Inter"],
"label": ["Inter"]
},
borderRadius: {"DEFAULT": "0.125rem", "lg": "0.25rem", "xl": "0.5rem", "full": "0.75rem"},
},
},
}
</script>
<style>
.material-symbols-outlined { font-family: 'Material Symbols Outlined'; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20; font-size: 1.25rem; line-height: 1; letter-spacing: normal; text-transform: none; display: inline-flex; white-space: nowrap; word-wrap: normal; direction: ltr; }
body { font-family: 'Inter', sans-serif; height: 100vh; overflow: hidden; }

View File

@@ -8,78 +8,10 @@
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet"/>
<!-- Material Symbols -->
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap" rel="stylesheet"/>
<link rel="stylesheet" href="../css/main.css" />
<!-- 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.tailwindcss.com?plugins=forms,container-queries"></script>
<script src="https://cdn.jsdelivr.net/npm/notiflix@3.2.7/dist/notiflix-aio-3.2.7.min.js"></script>
<script id="tailwind-config">
tailwind.config = {
darkMode: "class",
theme: {
extend: {
colors: {
"on-secondary-fixed-variant": "#4e5c71",
"on-secondary": "#f8f8ff",
"secondary-fixed-dim": "#c7d5ed",
"surface-variant": "#d9e4ea",
"surface-tint": "#3755c3",
"primary-container": "#dde1ff",
"primary-dim": "#2848b7",
"on-background": "#2a3439",
"surface-container-lowest": "#ffffff",
"tertiary-fixed-dim": "#d4cdee",
"on-tertiary-container": "#514d68",
"error-container": "#fe8983",
"on-secondary-container": "#455367",
"outline": "#717c82",
"on-primary": "#f8f7ff",
"on-primary-container": "#2747b6",
"inverse-primary": "#6d89fa",
"on-surface": "#2a3439",
"primary-fixed": "#dde1ff",
"on-primary-fixed": "#0732a3",
"secondary-dim": "#465468",
"surface-container-high": "#e1e9ee",
"surface-container-highest": "#d9e4ea",
"on-primary-fixed-variant": "#3352c0",
"on-error-container": "#752121",
"secondary": "#526074",
"tertiary-fixed": "#e3dbfd",
"primary": "#3755c3",
"surface-dim": "#cfdce3",
"tertiary": "#605c78",
"on-error": "#fff7f6",
"secondary-fixed": "#d5e3fc",
"error-dim": "#4e0309",
"surface-bright": "#f7f9fb",
"on-surface-variant": "#566166",
"on-tertiary": "#fcf7ff",
"tertiary-container": "#e3dbfd",
"inverse-on-surface": "#9a9d9f",
"on-tertiary-fixed-variant": "#5b5672",
"tertiary-dim": "#54506b",
"outline-variant": "#a9b4b9",
"on-secondary-fixed": "#324053",
"inverse-surface": "#0b0f10",
"on-tertiary-fixed": "#3e3a54",
"primary-fixed-dim": "#cad2ff",
"surface-container": "#e8eff3",
"secondary-container": "#d5e3fc",
"surface-container-low": "#f0f4f7",
"background": "#f7f9fb",
"error": "#9f403d",
"surface": "#f7f9fb"
},
fontFamily: {
"headline": ["Manrope"],
"body": ["Inter"],
"label": ["Inter"]
},
borderRadius: {"DEFAULT": "0.125rem", "lg": "0.25rem", "xl": "0.5rem", "full": "0.75rem"},
},
},
}
</script>
<style>
.material-symbols-outlined {
font-family: 'Material Symbols Outlined';
@@ -136,6 +68,10 @@
<span class="material-symbols-outlined">manage_accounts</span>
<span>Accounts</span>
</a>
<a id="usersNav" href="#users" data-nav="users" class="flex items-center gap-3 px-3 py-2 text-slate-600 dark:text-slate-400 hover:text-slate-900 hover:bg-slate-200/50 transition-all group cursor-pointer" style="display: none;">
<span class="material-symbols-outlined">people</span>
<span>Users</span>
</a>
</nav>
<!-- Footer -->
<div class="px-6 pt-4 border-t border-outline-variant/10">

View File

@@ -8,75 +8,7 @@
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet"/>
<!-- Material Symbols -->
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap" rel="stylesheet"/>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<script id="tailwind-config">
tailwind.config = {
darkMode: "class",
theme: {
extend: {
colors: {
"on-secondary-fixed-variant": "#4e5c71",
"on-secondary": "#f8f8ff",
"secondary-fixed-dim": "#c7d5ed",
"surface-variant": "#d9e4ea",
"surface-tint": "#3755c3",
"primary-container": "#dde1ff",
"primary-dim": "#2848b7",
"on-background": "#2a3439",
"surface-container-lowest": "#ffffff",
"tertiary-fixed-dim": "#d4cdee",
"on-tertiary-container": "#514d68",
"error-container": "#fe8983",
"on-secondary-container": "#455367",
"outline": "#717c82",
"on-primary": "#f8f7ff",
"on-primary-container": "#2747b6",
"inverse-primary": "#6d89fa",
"on-surface": "#2a3439",
"primary-fixed": "#dde1ff",
"on-primary-fixed": "#0732a3",
"secondary-dim": "#465468",
"surface-container-high": "#e1e9ee",
"surface-container-highest": "#d9e4ea",
"on-primary-fixed-variant": "#3352c0",
"on-error-container": "#752121",
"secondary": "#526074",
"tertiary-fixed": "#e3dbfd",
"primary": "#3755c3",
"surface-dim": "#cfdce3",
"tertiary": "#605c78",
"on-error": "#fff7f6",
"secondary-fixed": "#d5e3fc",
"error-dim": "#4e0309",
"surface-bright": "#f7f9fb",
"on-surface-variant": "#566166",
"on-tertiary": "#fcf7ff",
"tertiary-container": "#e3dbfd",
"inverse-on-surface": "#9a9d9f",
"on-tertiary-fixed-variant": "#5b5672",
"tertiary-dim": "#54506b",
"outline-variant": "#a9b4b9",
"on-secondary-fixed": "#324053",
"inverse-surface": "#0b0f10",
"on-tertiary-fixed": "#3e3a54",
"primary-fixed-dim": "#cad2ff",
"surface-container": "#e8eff3",
"secondary-container": "#d5e3fc",
"surface-container-low": "#f0f4f7",
"background": "#f7f9fb",
"error": "#9f403d",
"surface": "#f7f9fb"
},
fontFamily: {
"headline": ["Manrope"],
"body": ["Inter"],
"label": ["Inter"]
},
borderRadius: {"DEFAULT": "0.125rem", "lg": "0.25rem", "xl": "0.5rem", "full": "0.75rem"},
},
},
}
</script>
<link rel="stylesheet" href="../css/main.css" />
<style>
.material-symbols-outlined {
font-family: 'Material Symbols Outlined';
@@ -110,6 +42,11 @@
<p class="text-xs uppercase tracking-widest text-on-surface-variant font-bold mt-2">Account Management System</p>
</div>
<div class="flex gap-2 mb-6" role="tablist" aria-label="Auth switcher">
<button id="loginTab" type="button" class="flex-1 py-2.5 rounded-lg text-xs font-bold uppercase tracking-wider border border-outline-variant/30 bg-primary text-on-primary shadow-sm">Đăng nhập</button>
<button id="registerTab" type="button" class="flex-1 py-2.5 rounded-lg text-xs font-bold uppercase tracking-wider border border-outline-variant/30 bg-surface-container-low text-on-surface-variant">Đăng ký</button>
</div>
<!-- Login Form -->
<form id="loginForm" class="space-y-5">
<!-- Username/Email Input -->
@@ -172,6 +109,85 @@
<div id="errorMessage" class="hidden bg-error-container/20 text-error/80 border border-error/30 rounded-lg px-4 py-3 text-xs font-medium"></div>
</form>
<!-- Register Form -->
<form id="registerForm" class="space-y-5 hidden">
<div>
<label for="regFullname" class="block text-xs font-bold text-on-surface-variant uppercase tracking-wider mb-2">Full Name</label>
<div class="relative">
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-on-surface-variant/60">
<span class="material-symbols-outlined text-base">badge</span>
</span>
<input
type="text"
id="regFullname"
name="fullname"
placeholder="Enter your full name"
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"
/>
</div>
</div>
<div>
<label for="regEmail" class="block text-xs font-bold text-on-surface-variant uppercase tracking-wider mb-2">Email</label>
<div class="relative">
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-on-surface-variant/60">
<span class="material-symbols-outlined text-base">mail</span>
</span>
<input
type="email"
id="regEmail"
name="email"
placeholder="Enter your email"
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"
/>
</div>
</div>
<div>
<label for="regUsername" class="block text-xs font-bold text-on-surface-variant uppercase tracking-wider mb-2">Username</label>
<div class="relative">
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-on-surface-variant/60">
<span class="material-symbols-outlined text-base">person_add</span>
</span>
<input
type="text"
id="regUsername"
name="username"
placeholder="Choose a username"
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"
/>
</div>
</div>
<div>
<label for="regPassword" class="block text-xs font-bold text-on-surface-variant uppercase tracking-wider mb-2">Password</label>
<div class="relative">
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-on-surface-variant/60">
<span class="material-symbols-outlined text-base">lock</span>
</span>
<input
type="password"
id="regPassword"
name="password"
placeholder="Create a password"
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"
/>
</div>
</div>
<button
type="submit"
class="w-full bg-primary hover:bg-primary-dim text-on-primary font-bold py-2.5 px-4 rounded-lg transition-all active:scale-95 duration-100 flex items-center justify-center gap-2 mt-4"
>
<span class="material-symbols-outlined text-sm">how_to_reg</span>
<span>Tạo tài khoản</span>
</button>
<div id="registerErrorMessage" class="hidden bg-error-container/20 text-error/80 border border-error/30 rounded-lg px-4 py-3 text-xs font-medium"></div>
</form>
<!-- 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>
@@ -192,11 +208,35 @@
const usernameInput = document.getElementById('username');
const passwordInput = document.getElementById('password');
const rememberCheckbox = document.getElementById('remember');
const registerForm = document.getElementById('registerForm');
const registerErrorMessage = document.getElementById('registerErrorMessage');
const regFullnameInput = document.getElementById('regFullname');
const regEmailInput = document.getElementById('regEmail');
const regUsernameInput = document.getElementById('regUsername');
const regPasswordInput = document.getElementById('regPassword');
const loginTab = document.getElementById('loginTab');
const registerTab = document.getElementById('registerTab');
let currentMode = 'login';
// Demo credentials
const validCredentials = {
username: 'admin',
password: 'admin'
const setMode = (mode) => {
currentMode = mode;
const isLogin = mode === 'login';
loginForm.classList.toggle('hidden', !isLogin);
registerForm.classList.toggle('hidden', isLogin);
errorMessage.classList.add('hidden');
registerErrorMessage.classList.add('hidden');
const activate = (btn, active) => {
btn.classList.toggle('bg-primary', active);
btn.classList.toggle('text-on-primary', active);
btn.classList.toggle('shadow-sm', active);
btn.classList.toggle('bg-surface-container-low', !active);
btn.classList.toggle('text-on-surface-variant', !active);
};
activate(loginTab, isLogin);
activate(registerTab, !isLogin);
};
// Check if already logged in
@@ -206,6 +246,8 @@
window.location.href = './index.html';
}
setMode('login');
// Restore remembered username
const rememberedUsername = localStorage.getItem('rememberedUsername');
if (rememberedUsername) {
@@ -214,39 +256,96 @@
}
});
loginForm.addEventListener('submit', (e) => {
loginTab.addEventListener('click', () => setMode('login'));
registerTab.addEventListener('click', () => setMode('register'));
loginForm.addEventListener('submit', async (e) => {
e.preventDefault();
errorMessage.classList.add('hidden');
const username = usernameInput.value.trim();
const password = passwordInput.value;
// Validate credentials
if (username === validCredentials.username && password === validCredentials.password) {
// Store user info
const userData = {
username: username,
role: 'Administrator',
loginTime: new Date().toISOString()
};
try {
// Call backend login API
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ username, password })
});
localStorage.setItem('currentUser', JSON.stringify(userData));
const data = await response.json();
// Handle remember me
if (rememberCheckbox.checked) {
localStorage.setItem('rememberedUsername', username);
if (data.success && data.user) {
// Store user info from backend
localStorage.setItem('currentUser', JSON.stringify(data.user));
// Handle remember me
if (rememberCheckbox.checked) {
localStorage.setItem('rememberedUsername', username);
} else {
localStorage.removeItem('rememberedUsername');
}
// Redirect to dashboard
window.location.href = './index.html';
} else {
localStorage.removeItem('rememberedUsername');
// Show error
errorMessage.textContent = data.message || 'Invalid username or password';
errorMessage.classList.remove('hidden');
passwordInput.value = '';
passwordInput.focus();
}
// Redirect to dashboard
window.location.href = './index.html';
} else {
// Show error
errorMessage.textContent = 'Invalid username or password. Try admin / admin';
} catch (error) {
errorMessage.textContent = 'Connection error. Try admin / admin';
errorMessage.classList.remove('hidden');
passwordInput.value = '';
passwordInput.focus();
console.error('Login error:', error);
}
});
registerForm.addEventListener('submit', async (e) => {
e.preventDefault();
registerErrorMessage.classList.add('hidden');
const payload = {
fullname: regFullnameInput.value.trim(),
email: regEmailInput.value.trim(),
username: regUsernameInput.value.trim(),
password: regPasswordInput.value
};
if (!payload.username || !payload.password) {
registerErrorMessage.textContent = 'Username and password are required';
registerErrorMessage.classList.remove('hidden');
return;
}
try {
const response = await fetch('/api/auth/register', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
});
const isJson = response.headers.get('content-type')?.includes('application/json');
const data = isJson ? await response.json() : null;
if (response.ok && data?.success && data.user) {
localStorage.setItem('currentUser', JSON.stringify(data.user));
localStorage.setItem('rememberedUsername', payload.username);
window.location.href = './index.html';
} else {
const fallback = isJson ? (data?.message || 'Registration failed') : 'Server error (HTML response)';
registerErrorMessage.textContent = fallback;
registerErrorMessage.classList.remove('hidden');
}
} catch (error) {
registerErrorMessage.textContent = 'Connection error. Please try again.';
registerErrorMessage.classList.remove('hidden');
console.error('Register error:', error);
}
});
</script>

46
public/pages/users.html Normal file
View File

@@ -0,0 +1,46 @@
<!DOCTYPE html>
<html class="light" lang="en">
<head>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>Robot Manager Account - Users Management</title>
<!-- Fonts -->
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet"/>
<!-- Material Symbols -->
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap" rel="stylesheet"/>
<link rel="stylesheet" href="../css/main.css" />
<!-- 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>
<style>
.material-symbols-outlined { font-family: 'Material Symbols Outlined'; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20; font-size: 1.25rem; line-height: 1; letter-spacing: normal; text-transform: none; display: inline-flex; white-space: nowrap; word-wrap: normal; direction: ltr; }
body { font-family: 'Inter', sans-serif; height: 100vh; overflow: hidden; }
h1, h2, h3, .brand-logo { font-family: 'Manrope', sans-serif; }
.modal-backdrop { opacity: 0; transition: opacity 0.2s ease-in-out; pointer-events: none; }
.modal-backdrop.open { opacity: 1; pointer-events: auto; }
.modal-content { transform: scale(0.95); transition: transform 0.2s ease-in-out; }
.modal-backdrop.open .modal-content { transform: scale(1); }
</style>
</head>
<body>
<script>
// Check if user has admin role, if not redirect to dashboard
const currentUser = localStorage.getItem('currentUser');
if (currentUser) {
try {
const user = JSON.parse(currentUser);
if (user.Role !== 'admin') {
window.location.href = './dashboard.html';
}
} catch (e) {
window.location.href = './login.html';
}
} else {
window.location.href = './login.html';
}
</script>
<script>
window.location.href = './index.html#users';
</script>
</body>
</html>

79
tailwind.config.js Normal file
View File

@@ -0,0 +1,79 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./public/**/*.{html,js}"
],
darkMode: "class",
theme: {
extend: {
colors: {
"on-secondary-fixed-variant": "#4e5c71",
"on-secondary": "#f8f8ff",
"secondary-fixed-dim": "#c7d5ed",
"surface-variant": "#d9e4ea",
"surface-tint": "#3755c3",
"primary-container": "#dde1ff",
"primary-dim": "#2848b7",
"on-background": "#2a3439",
"surface-container-lowest": "#ffffff",
"tertiary-fixed-dim": "#d4cdee",
"on-tertiary-container": "#514d68",
"error-container": "#fe8983",
"on-secondary-container": "#455367",
"outline": "#717c82",
"on-primary": "#f8f7ff",
"on-primary-container": "#2747b6",
"inverse-primary": "#6d89fa",
"on-surface": "#2a3439",
"primary-fixed": "#dde1ff",
"on-primary-fixed": "#0732a3",
"secondary-dim": "#465468",
"surface-container-high": "#e1e9ee",
"surface-container-highest": "#d9e4ea",
"on-primary-fixed-variant": "#3352c0",
"on-error-container": "#752121",
"secondary": "#526074",
"tertiary-fixed": "#e3dbfd",
"primary": "#3755c3",
"surface-dim": "#cfdce3",
"tertiary": "#605c78",
"on-error": "#fff7f6",
"secondary-fixed": "#d5e3fc",
"error-dim": "#4e0309",
"surface-bright": "#f7f9fb",
"on-surface-variant": "#566166",
"on-tertiary": "#fcf7ff",
"tertiary-container": "#e3dbfd",
"inverse-on-surface": "#9a9d9f",
"on-tertiary-fixed-variant": "#5b5672",
"tertiary-dim": "#54506b",
"outline-variant": "#a9b4b9",
"on-secondary-fixed": "#324053",
"inverse-surface": "#0b0f10",
"on-tertiary-fixed": "#3e3a54",
"primary-fixed-dim": "#cad2ff",
"surface-container": "#e8eff3",
"secondary-container": "#d5e3fc",
"surface-container-low": "#f0f4f7",
"background": "#f7f9fb",
"error": "#9f403d",
"surface": "#f7f9fb"
},
fontFamily: {
headline: ["Manrope", "sans-serif"],
body: ["Inter", "sans-serif"],
label: ["Inter", "sans-serif"]
},
borderRadius: {
DEFAULT: "0.125rem",
lg: "0.25rem",
xl: "0.5rem",
full: "0.75rem"
}
}
},
plugins: [
require("@tailwindcss/forms"),
require("@tailwindcss/container-queries")
]
};