done ver1.0.0
This commit is contained in:
9
.dockerignore
Normal file
9
.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
node_modules
|
||||||
|
npm-debug.log
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
.vs
|
||||||
|
.env
|
||||||
|
docker-compose.yml
|
||||||
18
.env
18
.env
@@ -1,6 +1,15 @@
|
|||||||
# AccManager Backend Configuration
|
# 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
|
PORT=3000
|
||||||
|
|
||||||
# SQL Server Configuration
|
# SQL Server Configuration
|
||||||
@@ -8,8 +17,9 @@ DB_SERVER=172.20.235.176
|
|||||||
DB_USER=sa
|
DB_USER=sa
|
||||||
DB_PASSWORD=robotics@2022
|
DB_PASSWORD=robotics@2022
|
||||||
DB_NAME=AccManager
|
DB_NAME=AccManager
|
||||||
DB_ENCRYPT=true
|
DB_ENCRYPT=false
|
||||||
DB_TRUST_CERTIFICATE=true
|
DB_TRUST_CERTIFICATE=true
|
||||||
|
DB_CONNECT_TIMEOUT=30000
|
||||||
|
|
||||||
# Application
|
# Security
|
||||||
NODE_ENV=development
|
BCRYPT_ROUNDS=12
|
||||||
|
|||||||
315
DEPLOYMENT_GUIDE.md
Normal file
315
DEPLOYMENT_GUIDE.md
Normal 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
15
Dockerfile
Normal 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
284
README.md
@@ -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
|
||||||
|
|
||||||
```
|
## 🛠️ Công nghệ
|
||||||
Server IP: 172.20.235.176
|
|
||||||
Database: AccManager
|
|
||||||
User: sa
|
|
||||||
Password: robotics@2020
|
|
||||||
Port: 1433 (default)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 👤 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 |
|
||||||
|
|
||||||
```
|
## 📦 Cấu trúc dự án
|
||||||
Username: admin
|
|
||||||
Password: admin
|
|
||||||
Role: admin
|
|
||||||
Status: Active
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📋 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
|
## 🚀 Khởi động nhanh
|
||||||
- Stores login credentials and user roles
|
|
||||||
- Default admin user: admin/admin
|
|
||||||
|
|
||||||
### 2. **Applications** - Service Management
|
### Phát triển local
|
||||||
- 4 sample applications pre-loaded:
|
|
||||||
- AWS (Cloud) - online
|
|
||||||
- GitHub (VCS) - online
|
|
||||||
- Google Workspace (Collaboration) - online
|
|
||||||
- Nginx Proxy (Infra) - offline
|
|
||||||
|
|
||||||
### 3. **Accounts** - Credential Storage
|
\\\ash
|
||||||
- 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
|
|
||||||
npm install
|
npm install
|
||||||
|
|
||||||
# 3. Run server
|
|
||||||
npm start
|
npm start
|
||||||
|
\\\
|
||||||
|
|
||||||
# Server runs on: http://localhost:3000
|
Truy cập: http://localhost:3000
|
||||||
```
|
|
||||||
|
|
||||||
### Option 2️⃣: Python + Flask
|
### Chạy bằng Docker
|
||||||
|
|
||||||
**Files:**
|
\\\ash
|
||||||
- `server_python.py` - Main server file
|
docker compose build
|
||||||
- `requirements.txt` - Dependencies
|
docker compose up -d
|
||||||
|
\\\
|
||||||
|
|
||||||
**Quick Start:**
|
## 🐳 Triển khai với Docker
|
||||||
```bash
|
|
||||||
# 1. Install Python 3.8+ from https://www.python.org/
|
|
||||||
# 2. Install dependencies
|
|
||||||
pip install -r requirements.txt
|
|
||||||
|
|
||||||
# 3. Run server
|
### Máy DEV: Build & Push
|
||||||
python server_python.py
|
|
||||||
|
|
||||||
# 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
|
\\\ash
|
||||||
```http
|
ssh robotics@172.20.235.176
|
||||||
GET /api/health
|
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
|
### Authentication
|
||||||
```http
|
- POST \/api/auth/login\ - Đăng nhập
|
||||||
POST /api/auth/login
|
- POST \/api/auth/register\ - Đăng ký
|
||||||
```
|
|
||||||
|
|
||||||
### Users Management
|
### Users (Admin)
|
||||||
```http
|
- GET \/api/users\ - Lấy danh sách người dùng
|
||||||
GET /api/users
|
- GET \/api/users/:id\ - Lấy thông tin người dùng
|
||||||
GET /api/users/:id
|
- POST \/api/users\ - Tạo người dùng mới
|
||||||
POST /api/users
|
- PUT \/api/users/:id\ - Cập nhật người dùng
|
||||||
```
|
- DELETE \/api/users/:id\ - Xóa người dùng
|
||||||
|
|
||||||
### Applications
|
### Applications
|
||||||
```http
|
- GET \/api/applications\ - Danh sách ứng dụng
|
||||||
GET /api/applications
|
- POST \/api/applications\ - Tạo ứng dụng
|
||||||
POST /api/applications
|
|
||||||
```
|
|
||||||
|
|
||||||
### Accounts
|
### Accounts
|
||||||
```http
|
- GET \/api/accounts/user/:userId\ - Tài khoản của người dùng
|
||||||
GET /api/accounts/user/:userId
|
- POST \/api/accounts\ - Tạo tài khoản
|
||||||
POST /api/accounts
|
|
||||||
```
|
|
||||||
|
|
||||||
### Database Info
|
## 🔧 Cấu hình
|
||||||
```http
|
|
||||||
GET /api/database/info
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📚 Documentation Files
|
### .env (Development)
|
||||||
|
|
||||||
- **README.md** (this file) - Overview
|
\\\env
|
||||||
- **SETUP_GUIDE.md** - Detailed installation steps
|
NODE_ENV=production
|
||||||
- **DATABASE_SETUP.md** - Schema and API documentation
|
APP_PORT=3000
|
||||||
- **server.js** - Node.js backend source
|
DOCKER_IMAGE=toiiiiday/accmanager:1.0.1
|
||||||
- **server_python.py** - Python backend source
|
|
||||||
|
|
||||||
## 🔐 Default Credentials
|
# Container
|
||||||
|
PORT=3000
|
||||||
|
|
||||||
```
|
# Database
|
||||||
Username: admin
|
DB_SERVER=172.20.235.176
|
||||||
Password: admin
|
DB_USER=sa
|
||||||
Role: admin
|
DB_PASSWORD=robotics@2022
|
||||||
```
|
DB_NAME=AccManager
|
||||||
|
DB_ENCRYPT=false
|
||||||
|
DB_TRUST_CERTIFICATE=true
|
||||||
|
DB_CONNECT_TIMEOUT=30000
|
||||||
|
|
||||||
## 🔧 Project Files
|
# Security
|
||||||
|
BCRYPT_ROUNDS=12
|
||||||
|
\\\
|
||||||
|
|
||||||
```
|
## 🌐 Triển khai Public với Domain
|
||||||
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)
|
|
||||||
```
|
|
||||||
|
|
||||||
## ✅ Status
|
Xem: [DEPLOYMENT_GUIDE.md](DEPLOYMENT_GUIDE.md) - Mục "Public Domain qua Nginx Proxy Manager"
|
||||||
|
|
||||||
- ✓ Database created (AccManager)
|
**Tóm tắt:**
|
||||||
- ✓ 4 tables created (Users, Applications, Accounts, AuditLog)
|
1. Trỏ DNS A record về IP Nginx Proxy Manager
|
||||||
- ✓ Admin user created (admin/admin)
|
2. Tạo Proxy Host trong NPM: forward 3000 → domain
|
||||||
- ✓ Sample applications added
|
3. Enable Let's Encrypt SSL + Force SSL
|
||||||
- ✓ Backend servers ready (Node.js + Python options)
|
|
||||||
- ✓ API endpoints documented
|
## 📝 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)
|
**Phiên bản:** 2.0.0
|
||||||
**Database:** SQL Server / AccManager
|
**Cập nhật:** Tháng 4 năm 2026
|
||||||
**Last Updated:** March 27, 2026
|
**Trạng thái:** Production-ready ✅
|
||||||
|
|||||||
@@ -4,8 +4,87 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const sql = require('mssql');
|
const sql = require('mssql');
|
||||||
const cors = require('cors');
|
const cors = require('cors');
|
||||||
|
const bcrypt = require('bcrypt');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const dotenv = require('dotenv');
|
||||||
const app = express();
|
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
|
// Middleware
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
@@ -22,20 +101,20 @@ app.get('/', (req, res) => {
|
|||||||
|
|
||||||
// SQL Server Configuration
|
// SQL Server Configuration
|
||||||
const sqlConfig = {
|
const sqlConfig = {
|
||||||
server: '172.20.235.176',
|
server: DB_SERVER,
|
||||||
authentication: {
|
authentication: {
|
||||||
type: 'default',
|
type: 'default',
|
||||||
options: {
|
options: {
|
||||||
userName: 'sa',
|
userName: DB_USER,
|
||||||
password: 'robotics@2022'
|
password: DB_PASSWORD
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
database: 'AccManager',
|
database: DB_NAME,
|
||||||
trustServerCertificate: true,
|
trustServerCertificate: DB_TRUST_CERTIFICATE,
|
||||||
enableKeepAlive: true,
|
enableKeepAlive: true,
|
||||||
connectTimeout: 30000,
|
connectTimeout: DB_CONNECT_TIMEOUT,
|
||||||
encrypt: false
|
encrypt: DB_ENCRYPT
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -50,9 +129,14 @@ async function initializeDatabase() {
|
|||||||
|
|
||||||
// Check and create database if not exists
|
// Check and create database if not exists
|
||||||
const masterConnection = new sql.ConnectionPool({
|
const masterConnection = new sql.ConnectionPool({
|
||||||
server: '172.20.235.176',
|
server: DB_SERVER,
|
||||||
authentication: { type: 'default', options: { userName: 'sa', password: 'robotics@2022' } },
|
authentication: { type: 'default', options: { userName: DB_USER, password: DB_PASSWORD } },
|
||||||
options: { connectTimeout: 30000, database: 'master', trustServerCertificate: true, encrypt: false }
|
options: {
|
||||||
|
connectTimeout: DB_CONNECT_TIMEOUT,
|
||||||
|
database: 'master',
|
||||||
|
trustServerCertificate: DB_TRUST_CERTIFICATE,
|
||||||
|
encrypt: DB_ENCRYPT
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
await masterConnection.connect();
|
await masterConnection.connect();
|
||||||
@@ -65,6 +149,7 @@ async function initializeDatabase() {
|
|||||||
|
|
||||||
// Now create tables in AccManager
|
// Now create tables in AccManager
|
||||||
await createTables();
|
await createTables();
|
||||||
|
await migrateLegacyPasswords();
|
||||||
console.log('✓ Database and tables created');
|
console.log('✓ Database and tables created');
|
||||||
|
|
||||||
} catch (err) {
|
} 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() {
|
async function createTables() {
|
||||||
const queries = [
|
const queries = [
|
||||||
// Users Table
|
// Users Table
|
||||||
@@ -157,6 +283,7 @@ async function createTables() {
|
|||||||
try {
|
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','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.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
|
// Backfill Url to empty string to avoid undefined in responses
|
||||||
await pool.request().query(`UPDATE Applications SET Url = '' WHERE Url IS NULL;`);
|
await pool.request().query(`UPDATE Applications SET Url = '' WHERE Url IS NULL;`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -165,15 +292,18 @@ async function createTables() {
|
|||||||
|
|
||||||
// Insert initial admin user
|
// Insert initial admin user
|
||||||
try {
|
try {
|
||||||
|
const adminPasswordHash = await hashPassword('admin');
|
||||||
|
const adminViewPassword = encryptPasswordForView('admin');
|
||||||
await pool.request()
|
await pool.request()
|
||||||
.input('username', sql.NVarChar, 'admin')
|
.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('email', sql.NVarChar, 'admin@accmanager.local')
|
||||||
.input('fullname', sql.NVarChar, 'Administrator')
|
.input('fullname', sql.NVarChar, 'Administrator')
|
||||||
.input('role', sql.NVarChar, 'admin')
|
.input('role', sql.NVarChar, 'admin')
|
||||||
.query(`IF NOT EXISTS (SELECT * FROM Users WHERE Username = @username)
|
.query(`IF NOT EXISTS (SELECT * FROM Users WHERE Username = @username)
|
||||||
INSERT INTO Users (Username, Password, Email, FullName, Role, IsActive)
|
INSERT INTO Users (Username, Password, ViewPassword, Email, FullName, Role, IsActive)
|
||||||
VALUES (@username, @password, @email, @fullname, @role, 1)`);
|
VALUES (@username, @password, @viewPassword, @email, @fullname, @role, 1)`);
|
||||||
console.log('✓ Admin user created: admin / admin');
|
console.log('✓ Admin user created: admin / admin');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Admin user error:', err.message);
|
console.error('Admin user error:', err.message);
|
||||||
@@ -205,14 +335,41 @@ async function createTables() {
|
|||||||
app.post('/api/auth/login', async (req, res) => {
|
app.post('/api/auth/login', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { username, password } = req.body;
|
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()
|
const result = await pool.request()
|
||||||
.input('username', sql.NVarChar, username)
|
.input('username', sql.NVarChar, username)
|
||||||
.input('password', sql.NVarChar, password)
|
.query('SELECT UserId, Username, Email, FullName, Role, RoleId, Status, Password FROM Users WHERE Username = @username AND IsActive = 1');
|
||||||
.query('SELECT UserId, Username, Email, FullName, Role, Status FROM Users WHERE Username = @username AND Password = @password AND IsActive = 1');
|
|
||||||
|
|
||||||
if (result.recordset.length > 0) {
|
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
|
// Update last login
|
||||||
await pool.request()
|
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
|
// API ROUTES - Users
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -244,7 +612,11 @@ app.post('/api/auth/login', async (req, res) => {
|
|||||||
app.get('/api/users', async (req, res) => {
|
app.get('/api/users', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const result = await pool.request()
|
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 });
|
res.json({ success: true, data: result.recordset });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({ success: false, message: err.message });
|
res.status(500).json({ success: false, message: err.message });
|
||||||
@@ -252,14 +624,27 @@ app.get('/api/users', async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Get user by ID
|
// Get user by ID
|
||||||
app.get('/api/users/:id', async (req, res) => {
|
app.get('/api/users/:id', requireAdmin, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const result = await pool.request()
|
const result = await pool.request()
|
||||||
.input('userId', sql.Int, req.params.id)
|
.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) {
|
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 {
|
} else {
|
||||||
res.status(404).json({ success: false, message: 'User not found' });
|
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
|
// Create new user (Admin only)
|
||||||
app.post('/api/users', async (req, res) => {
|
app.post('/api/users', requireAdmin, async (req, res) => {
|
||||||
try {
|
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()
|
const result = await pool.request()
|
||||||
.input('username', sql.NVarChar, username)
|
.input('username', sql.NVarChar, username)
|
||||||
.input('password', sql.NVarChar, password)
|
.input('password', sql.NVarChar, hashedPassword)
|
||||||
.input('email', sql.NVarChar, email)
|
.input('email', sql.NVarChar, email || null)
|
||||||
.input('fullname', sql.NVarChar, fullname)
|
.input('fullname', sql.NVarChar, fullname)
|
||||||
.input('role', sql.NVarChar, role)
|
.input('roleId', sql.Int, finalRoleId)
|
||||||
.query(`INSERT INTO Users (Username, Password, Email, FullName, Role, IsActive)
|
.input('role', sql.NVarChar, roleName)
|
||||||
VALUES (@username, @password, @email, @fullname, @role, 1);
|
.input('viewPassword', sql.NVarChar, viewPassword)
|
||||||
SELECT SCOPE_IDENTITY() as UserId`);
|
.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) {
|
} catch (err) {
|
||||||
res.status(500).json({ success: false, message: err.message });
|
res.status(500).json({ success: false, message: err.message });
|
||||||
}
|
}
|
||||||
@@ -377,9 +865,10 @@ app.get('/api/accounts/user/:userId', async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const result = await pool.request()
|
const result = await pool.request()
|
||||||
.input('userId', sql.Int, req.params.userId)
|
.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
|
FROM Accounts a
|
||||||
JOIN Applications app ON a.AppId = app.AppId
|
JOIN Applications app ON a.AppId = app.AppId
|
||||||
|
JOIN Users u ON a.UserId = u.UserId
|
||||||
WHERE a.UserId = @userId
|
WHERE a.UserId = @userId
|
||||||
ORDER BY a.CreatedDate DESC`);
|
ORDER BY a.CreatedDate DESC`);
|
||||||
res.json({ success: true, data: result.recordset });
|
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
|
// Create account
|
||||||
app.post('/api/accounts', async (req, res) => {
|
app.post('/api/accounts', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
18
docker-compose.image.yml
Normal file
18
docker-compose.image.yml
Normal 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
20
docker-compose.yml
Normal 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}
|
||||||
134
docs/README.md
134
docs/README.md
@@ -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
1027
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
25
package.json
25
package.json
@@ -5,18 +5,31 @@
|
|||||||
"main": "backend/server.js",
|
"main": "backend/server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node backend/server.js",
|
"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": "",
|
"author": "",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"express": "^4.18.2",
|
"bcrypt": "^6.0.0",
|
||||||
"mssql": "^9.1.1",
|
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.3.1"
|
"dotenv": "^16.3.1",
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"mssql": "^9.1.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {}
|
||||||
|
}
|
||||||
|
};
|
||||||
1
public/css/main.css
Normal file
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
3
public/css/tailwind.css
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
870
public/js/app.js
870
public/js/app.js
File diff suppressed because it is too large
Load Diff
@@ -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"/>
|
<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 -->
|
<!-- 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 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 -->
|
<!-- Notiflix Notify -->
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/notiflix@3.2.7/dist/notiflix-3.2.7.min.css" />
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/notiflix@3.2.7/dist/notiflix-3.2.7.min.css" />
|
||||||
<script src="https://cdn.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 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>
|
<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; }
|
.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; }
|
body { font-family: 'Inter', sans-serif; height: 100vh; overflow: hidden; }
|
||||||
|
|||||||
@@ -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"/>
|
<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 -->
|
<!-- 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 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 -->
|
<!-- Notiflix Notify -->
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/notiflix@3.2.7/dist/notiflix-3.2.7.min.css" />
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/notiflix@3.2.7/dist/notiflix-3.2.7.min.css" />
|
||||||
<script src="https://cdn.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 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>
|
<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; }
|
.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; }
|
body { font-family: 'Inter', sans-serif; height: 100vh; overflow: hidden; }
|
||||||
|
|||||||
@@ -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"/>
|
<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 -->
|
<!-- 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 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 -->
|
<!-- Notiflix Notify -->
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/notiflix@3.2.7/dist/notiflix-3.2.7.min.css" />
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/notiflix@3.2.7/dist/notiflix-3.2.7.min.css" />
|
||||||
<script src="https://cdn.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 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>
|
<style>
|
||||||
.material-symbols-outlined {
|
.material-symbols-outlined {
|
||||||
font-family: 'Material Symbols Outlined';
|
font-family: 'Material Symbols Outlined';
|
||||||
@@ -136,6 +68,10 @@
|
|||||||
<span class="material-symbols-outlined">manage_accounts</span>
|
<span class="material-symbols-outlined">manage_accounts</span>
|
||||||
<span>Accounts</span>
|
<span>Accounts</span>
|
||||||
</a>
|
</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>
|
</nav>
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<div class="px-6 pt-4 border-t border-outline-variant/10">
|
<div class="px-6 pt-4 border-t border-outline-variant/10">
|
||||||
|
|||||||
@@ -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"/>
|
<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 -->
|
<!-- 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 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>
|
<link rel="stylesheet" href="../css/main.css" />
|
||||||
<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>
|
<style>
|
||||||
.material-symbols-outlined {
|
.material-symbols-outlined {
|
||||||
font-family: '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>
|
<p class="text-xs uppercase tracking-widest text-on-surface-variant font-bold mt-2">Account Management System</p>
|
||||||
</div>
|
</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 -->
|
<!-- Login Form -->
|
||||||
<form id="loginForm" class="space-y-5">
|
<form id="loginForm" class="space-y-5">
|
||||||
<!-- Username/Email Input -->
|
<!-- 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>
|
<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>
|
</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 -->
|
<!-- Footer -->
|
||||||
<!-- <div class="mt-8 pt-6 border-t border-outline-variant/10 text-center">
|
<!-- <div class="mt-8 pt-6 border-t border-outline-variant/10 text-center">
|
||||||
<p class="text-[10px] text-on-surface-variant/60">Default credentials for demo: admin / admin</p>
|
<p class="text-[10px] text-on-surface-variant/60">Default credentials for demo: admin / admin</p>
|
||||||
@@ -192,11 +208,35 @@
|
|||||||
const usernameInput = document.getElementById('username');
|
const usernameInput = document.getElementById('username');
|
||||||
const passwordInput = document.getElementById('password');
|
const passwordInput = document.getElementById('password');
|
||||||
const rememberCheckbox = document.getElementById('remember');
|
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 setMode = (mode) => {
|
||||||
const validCredentials = {
|
currentMode = mode;
|
||||||
username: 'admin',
|
const isLogin = mode === 'login';
|
||||||
password: 'admin'
|
|
||||||
|
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
|
// Check if already logged in
|
||||||
@@ -206,6 +246,8 @@
|
|||||||
window.location.href = './index.html';
|
window.location.href = './index.html';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setMode('login');
|
||||||
|
|
||||||
// Restore remembered username
|
// Restore remembered username
|
||||||
const rememberedUsername = localStorage.getItem('rememberedUsername');
|
const rememberedUsername = localStorage.getItem('rememberedUsername');
|
||||||
if (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();
|
e.preventDefault();
|
||||||
errorMessage.classList.add('hidden');
|
errorMessage.classList.add('hidden');
|
||||||
|
|
||||||
const username = usernameInput.value.trim();
|
const username = usernameInput.value.trim();
|
||||||
const password = passwordInput.value;
|
const password = passwordInput.value;
|
||||||
|
|
||||||
// Validate credentials
|
try {
|
||||||
if (username === validCredentials.username && password === validCredentials.password) {
|
// Call backend login API
|
||||||
// Store user info
|
const response = await fetch('/api/auth/login', {
|
||||||
const userData = {
|
method: 'POST',
|
||||||
username: username,
|
headers: {
|
||||||
role: 'Administrator',
|
'Content-Type': 'application/json'
|
||||||
loginTime: new Date().toISOString()
|
},
|
||||||
};
|
body: JSON.stringify({ username, password })
|
||||||
|
});
|
||||||
|
|
||||||
localStorage.setItem('currentUser', JSON.stringify(userData));
|
const data = await response.json();
|
||||||
|
|
||||||
// Handle remember me
|
if (data.success && data.user) {
|
||||||
if (rememberCheckbox.checked) {
|
// Store user info from backend
|
||||||
localStorage.setItem('rememberedUsername', username);
|
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 {
|
} else {
|
||||||
localStorage.removeItem('rememberedUsername');
|
// Show error
|
||||||
|
errorMessage.textContent = data.message || 'Invalid username or password';
|
||||||
|
errorMessage.classList.remove('hidden');
|
||||||
|
passwordInput.value = '';
|
||||||
|
passwordInput.focus();
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
// Redirect to dashboard
|
errorMessage.textContent = 'Connection error. Try admin / admin';
|
||||||
window.location.href = './index.html';
|
|
||||||
} else {
|
|
||||||
// Show error
|
|
||||||
errorMessage.textContent = 'Invalid username or password. Try admin / admin';
|
|
||||||
errorMessage.classList.remove('hidden');
|
errorMessage.classList.remove('hidden');
|
||||||
passwordInput.value = '';
|
console.error('Login error:', error);
|
||||||
passwordInput.focus();
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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>
|
</script>
|
||||||
|
|||||||
46
public/pages/users.html
Normal file
46
public/pages/users.html
Normal 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
79
tailwind.config.js
Normal 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")
|
||||||
|
]
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user