diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c9cee71 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +node_modules +npm-debug.log +.git +.gitignore +.vscode +.idea +.vs +.env +docker-compose.yml \ No newline at end of file diff --git a/.env b/.env index b2add71..cbb018a 100644 --- a/.env +++ b/.env @@ -1,6 +1,15 @@ # AccManager Backend Configuration -# Server Port +# Application +NODE_ENV=production + +# Host port exposed by docker compose +APP_PORT=3000 + +# Image used for server pull deployment +DOCKER_IMAGE=toiiiiday/accmanager:1.0.2 + +# Container app port PORT=3000 # SQL Server Configuration @@ -8,8 +17,9 @@ DB_SERVER=172.20.235.176 DB_USER=sa DB_PASSWORD=robotics@2022 DB_NAME=AccManager -DB_ENCRYPT=true +DB_ENCRYPT=false DB_TRUST_CERTIFICATE=true +DB_CONNECT_TIMEOUT=30000 -# Application -NODE_ENV=development +# Security +BCRYPT_ROUNDS=12 diff --git a/DEPLOYMENT_GUIDE.md b/DEPLOYMENT_GUIDE.md new file mode 100644 index 0000000..d8b7e53 --- /dev/null +++ b/DEPLOYMENT_GUIDE.md @@ -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 +``` diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ce7b8fe --- /dev/null +++ b/Dockerfile @@ -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"] \ No newline at end of file diff --git a/README.md b/README.md index 9c71efb..ce2050b 100644 --- a/README.md +++ b/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 -``` -Server IP: 172.20.235.176 -Database: AccManager -User: sa -Password: robotics@2020 -Port: 1433 (default) -``` +## 🛠️ Công nghệ -### 👤 Default Admin Account +| Thành phần | Công nghệ | +|-----------|----------| +| Backend | Node.js 20 + Express.js | +| Database | SQL Server 2022 | +| Frontend | HTML5 + Tailwind CSS + Vanilla JS | +| Container | Docker + Docker Compose | +| Reverse Proxy | Nginx Proxy Manager | +| Password Hashing | bcrypt | -``` -Username: admin -Password: admin -Role: admin -Status: Active -``` +## 📦 Cấu trúc dự án -## 📋 Database Tables Created +\\\ +├── backend/ +│ └── server.js # Main server (Express + mssql) +├── public/ # Static files (HTML, CSS, JS) +│ ├── index.html +│ ├── pages/ # Login, dashboard, etc. +│ ├── js/ +│ │ └── app.js # Frontend logic +│ └── css/ +│ ├── main.css # Tailwind compiled +│ └── tailwind.css # Tailwind source +├── database/ +│ └── setup.sql # Database schema +├── Dockerfile # Docker image config +├── docker-compose.yml # Local development +├── docker-compose.image.yml # Production deployment +├── .env # Environment variables +├── deploy-dev.ps1 # Build & push script +├── deploy-server.sh # Pull & deploy script +└── DEPLOYMENT_GUIDE.md # Detailed deployment guide (Vietnamese) +\\\ -### 1. **Users** - User Management -- Stores login credentials and user roles -- Default admin user: admin/admin +## 🚀 Khởi động nhanh -### 2. **Applications** - Service Management -- 4 sample applications pre-loaded: - - AWS (Cloud) - online - - GitHub (VCS) - online - - Google Workspace (Collaboration) - online - - Nginx Proxy (Infra) - offline +### Phát triển local -### 3. **Accounts** - Credential Storage -- Stores credentials for each user-application combination -- Linked to Users and Applications tables - -### 4. **AuditLog** - Activity Tracking -- Logs all INSERT, UPDATE, DELETE operations -- User actions tracked for security - -## 🚀 Backend Server Options - -### Option 1️⃣: Node.js + Express (Recommended) - -**Files:** -- `server.js` - Main server file -- `package.json` - Dependencies - -**Quick Start:** -```bash -# 1. Install Node.js from https://nodejs.org/ -# 2. Install dependencies +\\\ash npm install - -# 3. Run server npm start +\\\ -# Server runs on: http://localhost:3000 -``` +Truy cập: http://localhost:3000 -### Option 2️⃣: Python + Flask +### Chạy bằng Docker -**Files:** -- `server_python.py` - Main server file -- `requirements.txt` - Dependencies +\\\ash +docker compose build +docker compose up -d +\\\ -**Quick Start:** -```bash -# 1. Install Python 3.8+ from https://www.python.org/ -# 2. Install dependencies -pip install -r requirements.txt +## 🐳 Triển khai với Docker -# 3. Run server -python server_python.py +### Máy DEV: Build & Push -# Server runs on: http://localhost:5000 -``` +\\\powershell +cd D:\RoboticsSource\AccManager +.\deploy-dev.ps1 -Tag "1.0.1" +\\\ -## 📡 API Endpoints +### Máy Server: Deploy -### Health Check -```http -GET /api/health -``` +\\\ash +ssh robotics@172.20.235.176 +cd ~/accmanager +bash deploy-server.sh +\\\ + +**Xem chi tiết**: [DEPLOYMENT_GUIDE.md](DEPLOYMENT_GUIDE.md) (Tiếng Việt) + +## 🔐 Bảo mật + +- Mật khẩu được mã hóa bằng bcrypt (12 rounds) +- Migration tự động từ plain text sang bcrypt +- Không lưu credential trong source control +- Sử dụng .env để cấu hình biến sensitive +- SQL Server connection sử dụng SSL/TLS tùy chọn + +**Lưu ý:** +- Thay đổi mật khẩu admin mặc định (\dmin\/\dmin\) ngay sau khi đăng nhập lần đầu +- Không commit \.env\ thực tế lên git + +## 📚 API Endpoints ### Authentication -```http -POST /api/auth/login -``` +- POST \/api/auth/login\ - Đăng nhập +- POST \/api/auth/register\ - Đăng ký -### Users Management -```http -GET /api/users -GET /api/users/:id -POST /api/users -``` +### Users (Admin) +- GET \/api/users\ - Lấy danh sách người dùng +- GET \/api/users/:id\ - Lấy thông tin người dùng +- POST \/api/users\ - Tạo người dùng mới +- PUT \/api/users/:id\ - Cập nhật người dùng +- DELETE \/api/users/:id\ - Xóa người dùng ### Applications -```http -GET /api/applications -POST /api/applications -``` +- GET \/api/applications\ - Danh sách ứng dụng +- POST \/api/applications\ - Tạo ứng dụng ### Accounts -```http -GET /api/accounts/user/:userId -POST /api/accounts -``` +- GET \/api/accounts/user/:userId\ - Tài khoản của người dùng +- POST \/api/accounts\ - Tạo tài khoản -### Database Info -```http -GET /api/database/info -``` +## 🔧 Cấu hình -## 📚 Documentation Files +### .env (Development) -- **README.md** (this file) - Overview -- **SETUP_GUIDE.md** - Detailed installation steps -- **DATABASE_SETUP.md** - Schema and API documentation -- **server.js** - Node.js backend source -- **server_python.py** - Python backend source +\\\env +NODE_ENV=production +APP_PORT=3000 +DOCKER_IMAGE=toiiiiday/accmanager:1.0.1 -## 🔐 Default Credentials +# Container +PORT=3000 -``` -Username: admin -Password: admin -Role: admin -``` +# Database +DB_SERVER=172.20.235.176 +DB_USER=sa +DB_PASSWORD=robotics@2022 +DB_NAME=AccManager +DB_ENCRYPT=false +DB_TRUST_CERTIFICATE=true +DB_CONNECT_TIMEOUT=30000 -## 🔧 Project Files +# Security +BCRYPT_ROUNDS=12 +\\\ -``` -d:\RoboticsSource\AccManager\ -├── server.js (Node.js backend) -├── server_python.py (Python backend) -├── package.json (Node.js dependencies) -├── requirements.txt (Python dependencies) -├── .env (Configuration) -├── database/ -│ └── setup.sql (SQL setup script) -├── SETUP_GUIDE.md (Installation guide) -├── DATABASE_SETUP.md (Database documentation) -└── README.md (This file) -``` +## 🌐 Triển khai Public với Domain -## ✅ Status +Xem: [DEPLOYMENT_GUIDE.md](DEPLOYMENT_GUIDE.md) - Mục "Public Domain qua Nginx Proxy Manager" -- ✓ Database created (AccManager) -- ✓ 4 tables created (Users, Applications, Accounts, AuditLog) -- ✓ Admin user created (admin/admin) -- ✓ Sample applications added -- ✓ Backend servers ready (Node.js + Python options) -- ✓ API endpoints documented +**Tóm tắt:** +1. Trỏ DNS A record về IP Nginx Proxy Manager +2. Tạo Proxy Host trong NPM: forward 3000 → domain +3. Enable Let's Encrypt SSL + Force SSL + +## 📝 Tài liệu + +- [DEPLOYMENT_GUIDE.md](DEPLOYMENT_GUIDE.md) - Hướng dẫn triển khai (Tiếng Việt) +- [package.json](package.json) - Dependencies +- [docker-compose.yml](docker-compose.yml) - Dev config +- [docker-compose.image.yml](docker-compose.image.yml) - Prod config + +## 📦 Build & Run Scripts + +**Windows PowerShell (DEV):** +\\\powershell +.\deploy-dev.ps1 -Tag "1.0.1" +\\\ + +**Linux/Mac (SERVER):** +\\\ash +bash deploy-server.sh +\\\ + +## 🆘 Troubleshoot + +**Container không start?** +\\\ash +docker compose -f docker-compose.image.yml logs --tail=100 accmanager +\\\ + +**Image pull fail?** +- Kiểm tra Docker Hub credentials +- Kiểm tra image đã push: https://hub.docker.com/r/toiiiiday/accmanager + +**Database connection fail?** +- Kiểm tra .env (DB_SERVER, DB_USER, DB_PASSWORD, DB_NAME) +- Ping server: \ping 172.20.235.176\ + +## 📄 License + +MIT --- -**Version:** 2.0.0 (Backend Ready) -**Database:** SQL Server / AccManager -**Last Updated:** March 27, 2026 +**Phiên bản:** 2.0.0 +**Cập nhật:** Tháng 4 năm 2026 +**Trạng thái:** Production-ready ✅ diff --git a/backend/server.js b/backend/server.js index a503383..f15dec2 100644 --- a/backend/server.js +++ b/backend/server.js @@ -4,8 +4,87 @@ const express = require('express'); const sql = require('mssql'); const cors = require('cors'); +const bcrypt = require('bcrypt'); +const crypto = require('crypto'); +const dotenv = require('dotenv'); const app = express(); +dotenv.config(); + +function envBool(name, defaultValue) { + const value = process.env[name]; + if (value === undefined) { + return defaultValue; + } + + return ['1', 'true', 'yes', 'on'].includes(String(value).toLowerCase()); +} + +const DB_SERVER = process.env.DB_SERVER || '172.20.235.176'; +const DB_USER = process.env.DB_USER || 'sa'; +const DB_PASSWORD = process.env.DB_PASSWORD || 'robotics@2022'; +const DB_NAME = process.env.DB_NAME || 'AccManager'; +const DB_ENCRYPT = envBool('DB_ENCRYPT', false); +const DB_TRUST_CERTIFICATE = envBool('DB_TRUST_CERTIFICATE', true); +const DB_CONNECT_TIMEOUT = Number(process.env.DB_CONNECT_TIMEOUT || 30000); + +const BCRYPT_ROUNDS = Number(process.env.BCRYPT_ROUNDS || 12); +const PASSWORD_VIEW_SECRET = process.env.PASSWORD_VIEW_SECRET || 'change-this-password-view-secret'; +const PASSWORD_VIEW_KEY = crypto.createHash('sha256').update(String(PASSWORD_VIEW_SECRET)).digest(); +const PASSWORD_VIEW_PREFIX = 'enc:v1'; + +function isBcryptHash(value) { + return typeof value === 'string' && /^\$2[aby]\$\d{2}\$[./A-Za-z0-9]{53}$/.test(value); +} + +async function hashPassword(plainPassword) { + return bcrypt.hash(String(plainPassword), BCRYPT_ROUNDS); +} + +async function verifyPassword(plainPassword, storedPassword) { + if (isBcryptHash(storedPassword)) { + return bcrypt.compare(String(plainPassword), storedPassword); + } + + // Legacy fallback for old plain-text records. + return String(plainPassword) === String(storedPassword || ''); +} + +function encryptPasswordForView(plainPassword) { + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv('aes-256-gcm', PASSWORD_VIEW_KEY, iv); + const encrypted = Buffer.concat([ + cipher.update(String(plainPassword), 'utf8'), + cipher.final() + ]); + const tag = cipher.getAuthTag(); + + return `${PASSWORD_VIEW_PREFIX}:${iv.toString('base64')}:${tag.toString('base64')}:${encrypted.toString('base64')}`; +} + +function decryptPasswordForView(payload) { + try { + if (typeof payload !== 'string' || !payload.startsWith(`${PASSWORD_VIEW_PREFIX}:`)) { + return null; + } + + const parts = payload.split(':'); + if (parts.length !== 5) { + return null; + } + + const iv = Buffer.from(parts[2], 'base64'); + const tag = Buffer.from(parts[3], 'base64'); + const encrypted = Buffer.from(parts[4], 'base64'); + const decipher = crypto.createDecipheriv('aes-256-gcm', PASSWORD_VIEW_KEY, iv); + decipher.setAuthTag(tag); + const plain = Buffer.concat([decipher.update(encrypted), decipher.final()]); + return plain.toString('utf8'); + } catch (err) { + return null; + } +} + // Middleware app.use(cors()); app.use(express.json()); @@ -22,20 +101,20 @@ app.get('/', (req, res) => { // SQL Server Configuration const sqlConfig = { - server: '172.20.235.176', + server: DB_SERVER, authentication: { type: 'default', options: { - userName: 'sa', - password: 'robotics@2022' + userName: DB_USER, + password: DB_PASSWORD } }, options: { - database: 'AccManager', - trustServerCertificate: true, + database: DB_NAME, + trustServerCertificate: DB_TRUST_CERTIFICATE, enableKeepAlive: true, - connectTimeout: 30000, - encrypt: false + connectTimeout: DB_CONNECT_TIMEOUT, + encrypt: DB_ENCRYPT } }; @@ -50,9 +129,14 @@ async function initializeDatabase() { // Check and create database if not exists const masterConnection = new sql.ConnectionPool({ - server: '172.20.235.176', - authentication: { type: 'default', options: { userName: 'sa', password: 'robotics@2022' } }, - options: { connectTimeout: 30000, database: 'master', trustServerCertificate: true, encrypt: false } + server: DB_SERVER, + authentication: { type: 'default', options: { userName: DB_USER, password: DB_PASSWORD } }, + options: { + connectTimeout: DB_CONNECT_TIMEOUT, + database: 'master', + trustServerCertificate: DB_TRUST_CERTIFICATE, + encrypt: DB_ENCRYPT + } }); await masterConnection.connect(); @@ -65,6 +149,7 @@ async function initializeDatabase() { // Now create tables in AccManager await createTables(); + await migrateLegacyPasswords(); console.log('✓ Database and tables created'); } catch (err) { @@ -73,6 +158,47 @@ async function initializeDatabase() { } } +async function migrateLegacyPasswords() { + try { + const usersResult = await pool.request() + .query('SELECT UserId, Password, ViewPassword FROM Users WHERE Password IS NOT NULL'); + + let migratedCount = 0; + for (const row of usersResult.recordset) { + const request = pool.request() + .input('userId', sql.Int, row.UserId); + + let hasUpdates = false; + const rawPassword = String(row.Password || ''); + + if (!row.ViewPassword && !isBcryptHash(rawPassword)) { + request.input('viewPassword', sql.NVarChar, encryptPasswordForView(rawPassword)); + hasUpdates = true; + } + + if (!isBcryptHash(row.Password)) { + const hashedPassword = await hashPassword(row.Password); + request.input('password', sql.NVarChar, hashedPassword); + hasUpdates = true; + migratedCount += 1; + } + + if (hasUpdates) { + await request.query(`UPDATE Users + SET ${!isBcryptHash(rawPassword) ? 'Password = @password' : 'Password = Password'} + ${(!row.ViewPassword && !isBcryptHash(rawPassword)) ? ', ViewPassword = @viewPassword' : ''} + WHERE UserId = @userId`); + } + } + + if (migratedCount > 0) { + console.log(`✓ Migrated ${migratedCount} legacy plain-text password(s) to bcrypt`); + } + } catch (err) { + console.error('Password migration error:', err.message); + } +} + async function createTables() { const queries = [ // Users Table @@ -157,6 +283,7 @@ async function createTables() { try { await pool.request().query(`IF COL_LENGTH('dbo.Applications','Url') IS NULL ALTER TABLE Applications ADD Url NVARCHAR(255);`); await pool.request().query(`IF COL_LENGTH('dbo.Applications','Description') IS NULL ALTER TABLE Applications ADD Description NVARCHAR(500);`); + await pool.request().query(`IF COL_LENGTH('dbo.Users','ViewPassword') IS NULL ALTER TABLE Users ADD ViewPassword NVARCHAR(1024);`); // Backfill Url to empty string to avoid undefined in responses await pool.request().query(`UPDATE Applications SET Url = '' WHERE Url IS NULL;`); } catch (err) { @@ -165,15 +292,18 @@ async function createTables() { // Insert initial admin user try { + const adminPasswordHash = await hashPassword('admin'); + const adminViewPassword = encryptPasswordForView('admin'); await pool.request() .input('username', sql.NVarChar, 'admin') - .input('password', sql.NVarChar, 'admin') + .input('password', sql.NVarChar, adminPasswordHash) + .input('viewPassword', sql.NVarChar, adminViewPassword) .input('email', sql.NVarChar, 'admin@accmanager.local') .input('fullname', sql.NVarChar, 'Administrator') .input('role', sql.NVarChar, 'admin') .query(`IF NOT EXISTS (SELECT * FROM Users WHERE Username = @username) - INSERT INTO Users (Username, Password, Email, FullName, Role, IsActive) - VALUES (@username, @password, @email, @fullname, @role, 1)`); + INSERT INTO Users (Username, Password, ViewPassword, Email, FullName, Role, IsActive) + VALUES (@username, @password, @viewPassword, @email, @fullname, @role, 1)`); console.log('✓ Admin user created: admin / admin'); } catch (err) { console.error('Admin user error:', err.message); @@ -205,14 +335,41 @@ async function createTables() { app.post('/api/auth/login', async (req, res) => { try { const { username, password } = req.body; + + if (!username || !password) { + return res.status(400).json({ + success: false, + message: 'Username and password are required' + }); + } const result = await pool.request() .input('username', sql.NVarChar, username) - .input('password', sql.NVarChar, password) - .query('SELECT UserId, Username, Email, FullName, Role, Status FROM Users WHERE Username = @username AND Password = @password AND IsActive = 1'); + .query('SELECT UserId, Username, Email, FullName, Role, RoleId, Status, Password FROM Users WHERE Username = @username AND IsActive = 1'); if (result.recordset.length > 0) { - const user = result.recordset[0]; + const dbUser = result.recordset[0]; + const isValidPassword = await verifyPassword(password, dbUser.Password); + + if (!isValidPassword) { + return res.status(401).json({ + success: false, + message: 'Invalid username or password' + }); + } + + // Upgrade old plain-text passwords after successful legacy login. + if (!isBcryptHash(dbUser.Password)) { + const upgradedHash = await hashPassword(password); + await pool.request() + .input('userId', sql.Int, dbUser.UserId) + .input('password', sql.NVarChar, upgradedHash) + .input('viewPassword', sql.NVarChar, encryptPasswordForView(password)) + .query('UPDATE Users SET Password = @password, ViewPassword = ISNULL(ViewPassword, @viewPassword) WHERE UserId = @userId'); + } + + const { Password: _, ...safeUser } = dbUser; + const user = { ...safeUser, role: safeUser.Role || safeUser.role || 'guest' }; // Update last login await pool.request() @@ -236,6 +393,217 @@ app.post('/api/auth/login', async (req, res) => { } }); +// Public registration endpoint +app.post('/api/auth/register', async (req, res) => { + try { + const { username, password, email, fullname } = req.body; + + if (!username || !password) { + return res.status(400).json({ success: false, message: 'Username and password are required' }); + } + + // Prevent duplicate usernames + const existing = await pool.request() + .input('username', sql.NVarChar, username) + .query('SELECT UserId FROM Users WHERE Username = @username'); + + if (existing.recordset.length > 0) { + return res.status(409).json({ success: false, message: 'Username already exists' }); + } + + const hashedPassword = await hashPassword(password); + const viewPassword = encryptPasswordForView(password); + + const safeFullname = fullname && fullname.trim() ? fullname.trim() : username; + let guestRoleName = 'guest'; + let guestRoleId = null; + + const hasRoleIdColResult = await pool.request() + .query("SELECT CASE WHEN COL_LENGTH('dbo.Users','RoleId') IS NULL THEN 0 ELSE 1 END AS HasRoleId"); + const hasRoleIdColumn = hasRoleIdColResult.recordset[0].HasRoleId === 1; + + const hasRolesTableResult = await pool.request() + .query("SELECT CASE WHEN OBJECT_ID('dbo.Roles','U') IS NULL THEN 0 ELSE 1 END AS HasRolesTable"); + const hasRolesTable = hasRolesTableResult.recordset[0].HasRolesTable === 1; + + if (hasRolesTable) { + const guestRoleResult = await pool.request().query(` + IF NOT EXISTS (SELECT 1 FROM Roles WHERE LOWER(RoleName) = 'guest') + BEGIN + INSERT INTO Roles (RoleName, Description) + VALUES ('Guest', 'Default role for self-registered users'); + END + + SELECT TOP 1 RoleId, RoleName + FROM Roles + WHERE LOWER(RoleName) = 'guest'; + `); + + if (guestRoleResult.recordset.length > 0) { + guestRoleId = guestRoleResult.recordset[0].RoleId; + guestRoleName = guestRoleResult.recordset[0].RoleName || 'guest'; + } + } + + let result; + + if (hasRoleIdColumn && guestRoleId !== null) { + result = await pool.request() + .input('username', sql.NVarChar, username) + .input('password', sql.NVarChar, hashedPassword) + .input('email', sql.NVarChar, email || null) + .input('fullname', sql.NVarChar, safeFullname) + .input('roleId', sql.Int, guestRoleId) + .input('role', sql.NVarChar, guestRoleName) + .input('viewPassword', sql.NVarChar, viewPassword) + .query(`INSERT INTO Users (Username, Password, ViewPassword, Email, FullName, RoleId, Role, Status, IsActive) + OUTPUT INSERTED.UserId, INSERTED.Username, INSERTED.Email, INSERTED.FullName, INSERTED.Role, INSERTED.RoleId + VALUES (@username, @password, @viewPassword, @email, @fullname, @roleId, @role, 'Active', 1)`); + } else { + result = await pool.request() + .input('username', sql.NVarChar, username) + .input('password', sql.NVarChar, hashedPassword) + .input('email', sql.NVarChar, email || null) + .input('fullname', sql.NVarChar, safeFullname) + .input('role', sql.NVarChar, guestRoleName) + .input('viewPassword', sql.NVarChar, viewPassword) + .query(`INSERT INTO Users (Username, Password, ViewPassword, Email, FullName, Role, Status, IsActive) + OUTPUT INSERTED.UserId, INSERTED.Username, INSERTED.Email, INSERTED.FullName, INSERTED.Role + VALUES (@username, @password, @viewPassword, @email, @fullname, @role, 'Active', 1)`); + } + + const inserted = result.recordset[0]; + const user = { ...inserted, role: inserted.Role || guestRoleName || 'guest' }; + + // Repair previous self-registered users that were wrongly assigned Admin RoleId. + if (hasRoleIdColumn && guestRoleId !== null) { + await pool.request() + .input('guestRoleId', sql.Int, guestRoleId) + .query(`UPDATE Users + SET RoleId = @guestRoleId, + Role = 'Guest' + WHERE LOWER(Role) = 'guest' + AND (RoleId IS NULL OR RoleId <> @guestRoleId)`); + } + + res.json({ success: true, message: 'Registration successful', user }); + } catch (err) { + console.error('Registration error:', err); + res.status(500).json({ success: false, message: 'Registration failed' }); + } +}); + +// Middleware for role-based access control +const requireAdmin = (req, res, next) => { + const userRole = req.headers['x-user-role'] || req.query.userRole; + if (userRole !== 'admin') { + return res.status(403).json({ success: false, message: 'Admin access required' }); + } + next(); +}; + +// ========================================== +// API ROUTES - Roles +// ========================================== + +// Get all roles +app.get('/api/roles', async (req, res) => { + try { + const result = await pool.request() + .query('SELECT RoleId, RoleName, Description, CreatedDate FROM Roles ORDER BY RoleName'); + res.json({ success: true, data: result.recordset }); + } catch (err) { + res.status(500).json({ success: false, message: err.message }); + } +}); + +// Get role by ID +app.get('/api/roles/:id', async (req, res) => { + try { + const result = await pool.request() + .input('roleId', sql.Int, req.params.id) + .query('SELECT * FROM Roles WHERE RoleId = @roleId'); + + if (result.recordset.length > 0) { + res.json({ success: true, data: result.recordset[0] }); + } else { + res.status(404).json({ success: false, message: 'Role not found' }); + } + } catch (err) { + res.status(500).json({ success: false, message: err.message }); + } +}); + +// Create new role (Admin only) +app.post('/api/roles', requireAdmin, async (req, res) => { + try { + const { roleName, description } = req.body; + + const result = await pool.request() + .input('roleName', sql.NVarChar, roleName) + .input('description', sql.NVarChar, description) + .query(`IF NOT EXISTS (SELECT * FROM Roles WHERE RoleName = @roleName) + BEGIN + INSERT INTO Roles (RoleName, Description) + VALUES (@roleName, @description); + SELECT SCOPE_IDENTITY() as RoleId + END + ELSE + BEGIN + SELECT RoleId FROM Roles WHERE RoleName = @roleName + END`); + + res.json({ success: true, message: 'Role created', roleId: result.recordset[0].RoleId }); + } catch (err) { + res.status(500).json({ success: false, message: err.message }); + } +}); + +// Update role (Admin only) +app.put('/api/roles/:id', requireAdmin, async (req, res) => { + try { + const { roleName, description } = req.body; + + await pool.request() + .input('roleId', sql.Int, req.params.id) + .input('roleName', sql.NVarChar, roleName) + .input('description', sql.NVarChar, description) + .query(`UPDATE Roles + SET RoleName = @roleName, + Description = @description + WHERE RoleId = @roleId`); + + res.json({ success: true, message: 'Role updated' }); + } catch (err) { + res.status(500).json({ success: false, message: err.message }); + } +}); + +// Delete role (Admin only) +app.delete('/api/roles/:id', requireAdmin, async (req, res) => { + try { + // Check if role is in use + const check = await pool.request() + .input('roleId', sql.Int, req.params.id) + .query('SELECT COUNT(*) as Count FROM Users WHERE RoleId = @roleId'); + + if (check.recordset[0].Count > 0) { + return res.status(400).json({ + success: false, + message: 'Cannot delete role - it is assigned to users' + }); + } + + await pool.request() + .input('roleId', sql.Int, req.params.id) + .query('DELETE FROM Roles WHERE RoleId = @roleId'); + + res.json({ success: true, message: 'Role deleted' }); + } catch (err) { + res.status(500).json({ success: false, message: err.message }); + } +}); + // ========================================== // API ROUTES - Users // ========================================== @@ -244,7 +612,11 @@ app.post('/api/auth/login', async (req, res) => { app.get('/api/users', async (req, res) => { try { const result = await pool.request() - .query('SELECT UserId, Username, Email, FullName, Role, Status, CreatedDate FROM Users ORDER BY CreatedDate DESC'); + .query(`SELECT u.UserId, u.Username, u.Email, u.FullName, u.Role, u.RoleId, r.RoleName, + u.Status, u.CreatedDate, u.LastLogin, u.IsActive + FROM Users u + LEFT JOIN Roles r ON u.RoleId = r.RoleId + ORDER BY u.CreatedDate DESC`); res.json({ success: true, data: result.recordset }); } catch (err) { res.status(500).json({ success: false, message: err.message }); @@ -252,14 +624,27 @@ app.get('/api/users', async (req, res) => { }); // Get user by ID -app.get('/api/users/:id', async (req, res) => { +app.get('/api/users/:id', requireAdmin, async (req, res) => { try { const result = await pool.request() .input('userId', sql.Int, req.params.id) - .query('SELECT * FROM Users WHERE UserId = @userId'); + .query(`SELECT u.*, r.RoleName FROM Users u + LEFT JOIN Roles r ON u.RoleId = r.RoleId + WHERE u.UserId = @userId`); if (result.recordset.length > 0) { - res.json({ success: true, data: result.recordset[0] }); + const record = result.recordset[0]; + const viewPassword = decryptPasswordForView(record.ViewPassword || ''); + const fallbackPlainPassword = !isBcryptHash(record.Password) ? String(record.Password || '') : ''; + const plainPassword = viewPassword || fallbackPlainPassword; + + const userDetails = { + ...record, + Password: plainPassword, + PasswordAvailable: Boolean(plainPassword) + }; + + res.json({ success: true, data: userDetails }); } else { res.status(404).json({ success: false, message: 'User not found' }); } @@ -268,22 +653,125 @@ app.get('/api/users/:id', async (req, res) => { } }); -// Create new user -app.post('/api/users', async (req, res) => { +// Create new user (Admin only) +app.post('/api/users', requireAdmin, async (req, res) => { try { - const { username, password, email, fullname, role } = req.body; + const { username, password, email, fullname, roleId } = req.body; + + if (!username || !password) { + return res.status(400).json({ success: false, message: 'Username and password are required' }); + } + + const hashedPassword = await hashPassword(password); + const viewPassword = encryptPasswordForView(password); + const finalRoleId = roleId || 2; // Default to Guest role + + // Get role name from Roles table + const roleResult = await pool.request() + .input('roleId', sql.Int, finalRoleId) + .query('SELECT RoleName FROM Roles WHERE RoleId = @roleId'); + + const roleName = roleResult.recordset.length > 0 ? roleResult.recordset[0].RoleName : 'guest'; const result = await pool.request() .input('username', sql.NVarChar, username) - .input('password', sql.NVarChar, password) - .input('email', sql.NVarChar, email) + .input('password', sql.NVarChar, hashedPassword) + .input('email', sql.NVarChar, email || null) .input('fullname', sql.NVarChar, fullname) - .input('role', sql.NVarChar, role) - .query(`INSERT INTO Users (Username, Password, Email, FullName, Role, IsActive) - VALUES (@username, @password, @email, @fullname, @role, 1); - SELECT SCOPE_IDENTITY() as UserId`); + .input('roleId', sql.Int, finalRoleId) + .input('role', sql.NVarChar, roleName) + .input('viewPassword', sql.NVarChar, viewPassword) + .query(`IF NOT EXISTS (SELECT * FROM Users WHERE Username = @username) + BEGIN + INSERT INTO Users (Username, Password, ViewPassword, Email, FullName, RoleId, Role, IsActive) + VALUES (@username, @password, @viewPassword, @email, @fullname, @roleId, @role, 1); + SELECT SCOPE_IDENTITY() as UserId + END + ELSE + BEGIN + SELECT NULL as UserId + END`); - res.json({ success: true, message: 'User created', userId: result.recordset[0].UserId }); + if (result.recordset[0].UserId) { + res.json({ success: true, message: 'User created', userId: result.recordset[0].UserId }); + } else { + res.status(400).json({ success: false, message: 'Username already exists' }); + } + } catch (err) { + res.status(500).json({ success: false, message: err.message }); + } +}); + +// Update user (Admin only) +app.put('/api/users/:id', requireAdmin, async (req, res) => { + try { + const { email, fullname, roleId, status, isActive, password } = req.body; + const nextPassword = typeof password === 'string' ? password.trim() : ''; + const shouldUpdatePassword = nextPassword.length > 0; + + // Get role name from Roles table + let roleName = ''; + if (roleId) { + const roleResult = await pool.request() + .input('roleId', sql.Int, roleId) + .query('SELECT RoleName FROM Roles WHERE RoleId = @roleId'); + roleName = roleResult.recordset.length > 0 ? roleResult.recordset[0].RoleName : ''; + } + + const request = pool.request() + .input('userId', sql.Int, req.params.id) + .input('email', sql.NVarChar, email || null) + .input('fullname', sql.NVarChar, fullname) + .input('roleId', sql.Int, roleId) + .input('role', sql.NVarChar, roleName) + .input('status', sql.NVarChar, status) + .input('isActive', sql.Bit, isActive); + + if (shouldUpdatePassword) { + const hashedPassword = await hashPassword(nextPassword); + const viewPassword = encryptPasswordForView(nextPassword); + request.input('password', sql.NVarChar, hashedPassword); + request.input('viewPassword', sql.NVarChar, viewPassword); + } + + await request.query(`UPDATE Users + SET Email = @email, + FullName = @fullname, + RoleId = @roleId, + Role = @role, + Status = @status, + IsActive = @isActive + ${shouldUpdatePassword ? ', Password = @password, ViewPassword = @viewPassword' : ''} + WHERE UserId = @userId`); + + res.json({ success: true, message: shouldUpdatePassword ? 'User updated and password changed' : 'User updated' }); + } catch (err) { + res.status(500).json({ success: false, message: err.message }); + } +}); + +// Delete user (Admin only) +app.delete('/api/users/:id', requireAdmin, async (req, res) => { + try { + // Prevent deleting the current admin user (ID = 1) + if (req.params.id === '1') { + return res.status(400).json({ + success: false, + message: 'Cannot delete the primary admin user' + }); + } + + // Delete associated accounts first + await pool.request() + .input('userId', sql.Int, req.params.id) + .query('DELETE FROM Accounts WHERE UserId = @userId'); + + // Then delete the user + await pool.request() + .input('userId', sql.Int, req.params.id) + .query('DELETE FROM Users WHERE UserId = @userId'); + + res.json({ success: true, message: 'User deleted' }); } catch (err) { res.status(500).json({ success: false, message: err.message }); } @@ -377,9 +865,10 @@ app.get('/api/accounts/user/:userId', async (req, res) => { try { const result = await pool.request() .input('userId', sql.Int, req.params.userId) - .query(`SELECT a.*, app.Name as AppName, app.Type as AppType + .query(`SELECT a.*, app.Name as AppName, app.Type as AppType, u.Username FROM Accounts a JOIN Applications app ON a.AppId = app.AppId + JOIN Users u ON a.UserId = u.UserId WHERE a.UserId = @userId ORDER BY a.CreatedDate DESC`); res.json({ success: true, data: result.recordset }); @@ -388,6 +877,21 @@ app.get('/api/accounts/user/:userId', async (req, res) => { } }); +// Get all accounts (from all users) +app.get('/api/accounts/all', async (req, res) => { + try { + const result = await pool.request() + .query(`SELECT a.*, app.Name as AppName, app.Type as AppType, u.Username, u.FullName + FROM Accounts a + JOIN Applications app ON a.AppId = app.AppId + JOIN Users u ON a.UserId = u.UserId + ORDER BY a.CreatedDate DESC`); + res.json({ success: true, data: result.recordset }); + } catch (err) { + res.status(500).json({ success: false, message: err.message }); + } +}); + // Create account app.post('/api/accounts', async (req, res) => { try { diff --git a/docker-compose.image.yml b/docker-compose.image.yml new file mode 100644 index 0000000..c3b4e7d --- /dev/null +++ b/docker-compose.image.yml @@ -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} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..002c3b6 --- /dev/null +++ b/docker-compose.yml @@ -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} \ No newline at end of file diff --git a/docs/README.md b/docs/README.md deleted file mode 100644 index 13c7d08..0000000 --- a/docs/README.md +++ /dev/null @@ -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 diff --git a/package-lock.json b/package-lock.json index d94325c..1bdd988 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,13 +9,32 @@ "version": "1.0.0", "license": "MIT", "dependencies": { + "bcrypt": "^6.0.0", "cors": "^2.8.5", "dotenv": "^16.3.1", "express": "^4.18.2", "mssql": "^9.1.1" }, "devDependencies": { - "nodemon": "^3.0.1" + "@tailwindcss/container-queries": "^0.1.1", + "@tailwindcss/forms": "^0.5.7", + "autoprefixer": "^10.4.19", + "nodemon": "^3.0.1", + "postcss": "^8.4.38", + "tailwindcss": "^3.4.13" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/@azure-rest/core-client": { @@ -410,12 +429,112 @@ "node": ">=0.8.0" } }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/@js-joda/core": { "version": "5.7.0", "resolved": "https://registry.npmjs.org/@js-joda/core/-/core-5.7.0.tgz", "integrity": "sha512-WBu4ULVVxySLLzK1Ppq+OdfP+adRS4ntmDQT915rzDJ++i95gc2jZkM5B6LWEAwN3lGXpfie3yPABozdD3K3Vg==", "license": "BSD-3-Clause" }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@tailwindcss/container-queries": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/container-queries/-/container-queries-0.1.1.tgz", + "integrity": "sha512-p18dswChx6WnTSaJCSGx6lTmrGzNNvm2FtXmiO6AuA1V4U5REyoqwmT6kgAsIMdjo07QdAfYXHJ4hnMtfHzWgA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "tailwindcss": ">=3.2.0" + } + }, + "node_modules/@tailwindcss/forms": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.7.tgz", + "integrity": "sha512-QE7X69iQI+ZXwldE+rzasvbJiyV/ju1FGHH0Qn2W3FKbuYtqp8LKcy6iSw79fVUT5/Vvf+0XgLCeYVG+UV6hOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mini-svg-data-uri": "^1.2.3" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1" + } + }, "node_modules/@tediousjs/connection-string": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/@tediousjs/connection-string/-/connection-string-0.5.0.tgz", @@ -458,6 +577,13 @@ "node": ">= 14" } }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", @@ -472,6 +598,13 @@ "node": ">= 8" } }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, "node_modules/array-buffer-byte-length": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", @@ -524,6 +657,44 @@ "node": ">= 0.4" } }, + "node_modules/autoprefixer": { + "version": "10.4.19", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.19.tgz", + "integrity": "sha512-BaENR2+zBZ8xXhM4pUaKUxlVdxZ0EZhjvbopwnXmxRUfqDmwSpC2lAi/QXvx7NRdPCo1WKEcEF6mV64si1z4Ew==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.23.0", + "caniuse-lite": "^1.0.30001599", + "fraction.js": "^4.3.7", + "normalize-range": "^0.1.2", + "picocolors": "^1.0.0", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -569,6 +740,33 @@ ], "license": "MIT" }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.12", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.12.tgz", + "integrity": "sha512-qyq26DxfY4awP2gIRXhhLWfwzwI+N5Nxk6iQi8EFizIaWIjqicQTE4sLnZZVdeKPRcVNoJOkkpfzoIYuvCKaIQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bcrypt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz", + "integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.3.0", + "node-gyp-build": "^4.8.4" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -643,6 +841,40 @@ "node": ">=8" } }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, "node_modules/buffer": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", @@ -729,6 +961,37 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001782", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001782.tgz", + "integrity": "sha512-dZcaJLJeDMh4rELYFw1tvSn1bhZWYFOt468FcbHHxx/Z/dFidd1I6ciyFdi3iwfQCyOjqo9upF6lGQYtMiJWxw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -816,6 +1079,19 @@ "url": "https://opencollective.com/express" } }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -938,6 +1214,20 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, "node_modules/dotenv": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", @@ -979,6 +1269,13 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "license": "MIT" }, + "node_modules/electron-to-chromium": { + "version": "1.5.329", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.329.tgz", + "integrity": "sha512-/4t+AS1l4S3ZC0Ja7PHFIWeBIxGA3QGqV8/yKsP36v7NcyUCl+bIcmw6s5zVuMIECWwBrAK/6QLzTmbJChBboQ==", + "dev": true, + "license": "ISC" + }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -1140,6 +1437,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -1210,6 +1517,33 @@ "url": "https://opencollective.com/express" } }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -1265,6 +1599,20 @@ "node": ">= 0.6" } }, + "node_modules/fraction.js": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", + "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "patreon", + "url": "https://github.com/sponsors/rawify" + } + }, "node_modules/fresh": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", @@ -1771,6 +2119,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-data-view": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", @@ -2080,6 +2444,16 @@ "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", "license": "MIT" }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, "node_modules/js-md4": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/js-md4/-/js-md4-0.3.2.tgz", @@ -2141,6 +2515,23 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/lilconfig": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", + "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -2210,6 +2601,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/methods": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", @@ -2219,6 +2620,20 @@ "node": ">= 0.6" } }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, "node_modules/mime": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", @@ -2252,6 +2667,16 @@ "node": ">= 0.6" } }, + "node_modules/mini-svg-data-uri": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz", + "integrity": "sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==", + "dev": true, + "license": "MIT", + "bin": { + "mini-svg-data-uri": "cli.js" + } + }, "node_modules/minimatch": { "version": "10.2.4", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", @@ -2317,6 +2742,37 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/native-duplexpair": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/native-duplexpair/-/native-duplexpair-1.0.0.tgz", @@ -2338,6 +2794,33 @@ "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==", "license": "MIT" }, + "node_modules/node-addon-api": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz", + "integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/node-releases": { + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "dev": true, + "license": "MIT" + }, "node_modules/nodemon": { "version": "3.1.14", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", @@ -2402,6 +2885,16 @@ "node": ">=0.10.0" } }, + "node_modules/normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -2411,6 +2904,16 @@ "node": ">=0.10.0" } }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -2507,12 +3010,26 @@ "node": ">= 0.8" } }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, "node_modules/path-to-regexp": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", "license": "MIT" }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, "node_modules/picomatch": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", @@ -2526,6 +3043,26 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -2535,6 +3072,175 @@ "node": ">= 0.4" } }, + "node_modules/postcss": { + "version": "8.4.38", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz", + "integrity": "sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.7", + "picocolors": "^1.0.0", + "source-map-js": "^1.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", + "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.0.0", + "yaml": "^2.3.4" + }, + "engines": { + "node": ">= 14" + }, + "peerDependencies": { + "postcss": ">=8.0.9", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "postcss": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/postcss-load-config/node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -2579,6 +3285,27 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -2603,6 +3330,16 @@ "node": ">= 0.8" } }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -2672,12 +3409,68 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, "node_modules/rfdc": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", "license": "MIT" }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, "node_modules/safe-array-concat": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", @@ -2950,6 +3743,16 @@ "node": ">=10" } }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/sprintf-js": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", @@ -3053,6 +3856,39 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/sucrase/node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -3066,6 +3902,70 @@ "node": ">=4" } }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.13", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.13.tgz", + "integrity": "sha512-KqjHOJKogOUt5Bs752ykCeiwvi0fKVkr5oqsFNt/8px/tA8scFPIlkygsf6jXrfCqGHz7VflA6+yytWuM+XhFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.5.3", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.0", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.0", + "lilconfig": "^2.1.0", + "micromatch": "^4.0.5", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.0.0", + "postcss": "^8.4.23", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.1", + "postcss-nested": "^6.0.1", + "postcss-selector-parser": "^6.0.11", + "resolve": "^1.22.2", + "sucrase": "^3.32.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tailwindcss/node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/tarn": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/tarn/-/tarn-3.0.2.tgz", @@ -3110,6 +4010,77 @@ "node": ">=0.10.0" } }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -3142,6 +4113,13 @@ "nodetouch": "bin/nodetouch.js" } }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -3269,6 +4247,37 @@ "node": ">= 0.8" } }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -3386,6 +4395,22 @@ "funding": { "url": "https://github.com/sponsors/ljharb" } + }, + "node_modules/yaml": { + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } } } } diff --git a/package.json b/package.json index 3ac53ea..6c226a2 100644 --- a/package.json +++ b/package.json @@ -5,18 +5,31 @@ "main": "backend/server.js", "scripts": { "start": "node backend/server.js", - "dev": "nodemon backend/server.js" + "dev": "nodemon backend/server.js", + "build:css": "tailwindcss -c tailwind.config.js -i ./public/css/tailwind.css -o ./public/css/main.css --minify", + "watch:css": "tailwindcss -c tailwind.config.js -i ./public/css/tailwind.css -o ./public/css/main.css --watch" }, - "keywords": ["accmanager", "backend", "express", "mssql"], + "keywords": [ + "accmanager", + "backend", + "express", + "mssql" + ], "author": "", "license": "MIT", "dependencies": { - "express": "^4.18.2", - "mssql": "^9.1.1", + "bcrypt": "^6.0.0", "cors": "^2.8.5", - "dotenv": "^16.3.1" + "dotenv": "^16.3.1", + "express": "^4.18.2", + "mssql": "^9.1.1" }, "devDependencies": { - "nodemon": "^3.0.1" + "@tailwindcss/container-queries": "^0.1.1", + "@tailwindcss/forms": "^0.5.7", + "autoprefixer": "^10.4.19", + "nodemon": "^3.0.1", + "postcss": "^8.4.38", + "tailwindcss": "^3.4.13" } } diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..5cbc2c7 --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {} + } +}; diff --git a/public/css/main.css b/public/css/main.css new file mode 100644 index 0000000..9b27d9b --- /dev/null +++ b/public/css/main.css @@ -0,0 +1 @@ +*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.13 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]{display:none}[multiple],[type=date],[type=datetime-local],[type=email],[type=month],[type=number],[type=password],[type=search],[type=tel],[type=text],[type=time],[type=url],[type=week],input:where(:not([type])),select,textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-width:1px;border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem;--tw-shadow:0 0 #0000}[multiple]:focus,[type=date]:focus,[type=datetime-local]:focus,[type=email]:focus,[type=month]:focus,[type=number]:focus,[type=password]:focus,[type=search]:focus,[type=tel]:focus,[type=text]:focus,[type=time]:focus,[type=url]:focus,[type=week]:focus,input:where(:not([type])):focus,select:focus,textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);border-color:#2563eb}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-top:0;padding-bottom:0}select{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3E%3C/svg%3E");background-position:right .5rem center;background-repeat:no-repeat;background-size:1.5em 1.5em;padding-right:2.5rem;-webkit-print-color-adjust:exact;print-color-adjust:exact}[multiple],[size]:where(select:not([size="1"])){background-image:none;background-position:0 0;background-repeat:unset;background-size:initial;padding-right:.75rem;-webkit-print-color-adjust:unset;print-color-adjust:unset}[type=checkbox],[type=radio]{-webkit-appearance:none;-moz-appearance:none;appearance:none;padding:0;-webkit-print-color-adjust:exact;print-color-adjust:exact;display:inline-block;vertical-align:middle;background-origin:border-box;-webkit-user-select:none;-moz-user-select:none;user-select:none;flex-shrink:0;height:1rem;width:1rem;color:#2563eb;background-color:#fff;border-color:#6b7280;border-width:1px;--tw-shadow:0 0 #0000}[type=checkbox]{border-radius:0}[type=radio]{border-radius:100%}[type=checkbox]:focus,[type=radio]:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:2px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}[type=checkbox]:checked,[type=radio]:checked{border-color:transparent;background-color:currentColor;background-size:100% 100%;background-position:50%;background-repeat:no-repeat}[type=checkbox]:checked{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 16 16'%3E%3Cpath d='M12.207 4.793a1 1 0 0 1 0 1.414l-5 5a1 1 0 0 1-1.414 0l-2-2a1 1 0 0 1 1.414-1.414L6.5 9.086l4.293-4.293a1 1 0 0 1 1.414 0'/%3E%3C/svg%3E")}@media (forced-colors:active) {[type=checkbox]:checked{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}[type=radio]:checked{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='3'/%3E%3C/svg%3E")}@media (forced-colors:active) {[type=radio]:checked{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}[type=checkbox]:checked:focus,[type=checkbox]:checked:hover,[type=radio]:checked:focus,[type=radio]:checked:hover{border-color:transparent;background-color:currentColor}[type=checkbox]:indeterminate{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3E%3Cpath stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 8h8'/%3E%3C/svg%3E");border-color:transparent;background-color:currentColor;background-size:100% 100%;background-position:50%;background-repeat:no-repeat}@media (forced-colors:active) {[type=checkbox]:indeterminate{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}[type=checkbox]:indeterminate:focus,[type=checkbox]:indeterminate:hover{border-color:transparent;background-color:currentColor}[type=file]{background:unset;border-color:inherit;border-width:0;border-radius:0;padding:0;font-size:unset;line-height:inherit}[type=file]:focus{outline:1px solid ButtonText;outline:1px auto -webkit-focus-ring-color}.\!container{width:100%!important}.container{width:100%}@media (min-width:640px){.\!container{max-width:640px!important}.container{max-width:640px}}@media (min-width:768px){.\!container{max-width:768px!important}.container{max-width:768px}}@media (min-width:1024px){.\!container{max-width:1024px!important}.container{max-width:1024px}}@media (min-width:1280px){.\!container{max-width:1280px!important}.container{max-width:1280px}}@media (min-width:1536px){.\!container{max-width:1536px!important}.container{max-width:1536px}}.visible{visibility:visible}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.left-3{left:.75rem}.right-3{right:.75rem}.top-0{top:0}.top-1\/2{top:50%}.z-10{z-index:10}.z-50{z-index:50}.z-\[100\]{z-index:100}.m-4{margin:1rem}.mb-1{margin-bottom:.25rem}.mb-2{margin-bottom:.5rem}.mb-4{margin-bottom:1rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-2\.5{margin-left:.625rem}.mr-2{margin-right:.5rem}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-1\.5{margin-top:.375rem}.mt-2{margin-top:.5rem}.mt-6{margin-top:1.5rem}.mt-8{margin-top:2rem}.block{display:block}.inline{display:inline}.flex{display:flex}.table{display:table}.grid{display:grid}.hidden{display:none}.h-1\.5{height:.375rem}.h-10{height:2.5rem}.h-14{height:3.5rem}.h-16{height:4rem}.h-20{height:5rem}.h-4{height:1rem}.h-8{height:2rem}.h-full{height:100%}.h-screen{height:100vh}.min-h-0{min-height:0}.min-h-screen{min-height:100vh}.w-1\.5{width:.375rem}.w-10{width:2.5rem}.w-16{width:4rem}.w-4{width:1rem}.w-56{width:14rem}.w-8{width:2rem}.w-full{width:100%}.w-screen{width:100vw}.min-w-0{min-width:0}.max-w-lg{max-width:32rem}.max-w-md{max-width:28rem}.max-w-xs{max-width:20rem}.flex-1{flex:1 1 0%}.shrink-0{flex-shrink:0}.border-collapse{border-collapse:collapse}.-translate-y-1\/2{--tw-translate-y:-50%}.-translate-y-1\/2,.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.resize-none{resize:none}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.flex-col{flex-direction:column}.items-end{align-items:flex-end}.items-center{align-items:center}.items-baseline{align-items:baseline}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-6{gap:1.5rem}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.25rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem*var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem*var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem*var(--tw-space-y-reverse))}.space-y-5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.25rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.25rem*var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem*var(--tw-space-y-reverse))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px*var(--tw-divide-y-reverse))}.divide-outline-variant\/5>:not([hidden])~:not([hidden]){border-color:rgba(169,180,185,.05)}.divide-slate-100>:not([hidden])~:not([hidden]){--tw-divide-opacity:1;border-color:rgb(241 245 249/var(--tw-divide-opacity))}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.rounded{border-radius:.125rem}.rounded-2xl{border-radius:1rem}.rounded-full{border-radius:.75rem}.rounded-lg{border-radius:.25rem}.rounded-md{border-radius:.375rem}.rounded-xl{border-radius:.5rem}.border{border-width:1px}.border-b{border-bottom-width:1px}.border-l-2{border-left-width:2px}.border-l-4{border-left-width:4px}.border-r{border-right-width:1px}.border-t{border-top-width:1px}.border-blue-600{--tw-border-opacity:1;border-color:rgb(37 99 235/var(--tw-border-opacity))}.border-error\/30{border-color:rgba(159,64,61,.3)}.border-outline-variant\/10{border-color:rgba(169,180,185,.1)}.border-outline-variant\/15{border-color:rgba(169,180,185,.15)}.border-outline-variant\/20{border-color:rgba(169,180,185,.2)}.border-outline-variant\/30{border-color:rgba(169,180,185,.3)}.border-outline-variant\/5{border-color:rgba(169,180,185,.05)}.border-primary\/20{border-color:rgba(55,85,195,.2)}.border-primary\/50{border-color:rgba(55,85,195,.5)}.border-slate-100{--tw-border-opacity:1;border-color:rgb(241 245 249/var(--tw-border-opacity))}.border-slate-200{--tw-border-opacity:1;border-color:rgb(226 232 240/var(--tw-border-opacity))}.bg-background{--tw-bg-opacity:1;background-color:rgb(247 249 251/var(--tw-bg-opacity))}.bg-black\/40{background-color:rgba(0,0,0,.4)}.bg-black\/50{background-color:rgba(0,0,0,.5)}.bg-blue-100{--tw-bg-opacity:1;background-color:rgb(219 234 254/var(--tw-bg-opacity))}.bg-error{--tw-bg-opacity:1;background-color:rgb(159 64 61/var(--tw-bg-opacity))}.bg-error-container\/20{background-color:hsla(3,98%,75%,.2)}.bg-gray-100{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity))}.bg-green-100{--tw-bg-opacity:1;background-color:rgb(220 252 231/var(--tw-bg-opacity))}.bg-outline\/10{background-color:hsla(201,7%,48%,.1)}.bg-primary{--tw-bg-opacity:1;background-color:rgb(55 85 195/var(--tw-bg-opacity))}.bg-primary-container\/10{background-color:rgba(221,225,255,.1)}.bg-primary\/10{background-color:rgba(55,85,195,.1)}.bg-red-100{--tw-bg-opacity:1;background-color:rgb(254 226 226/var(--tw-bg-opacity))}.bg-red-50{--tw-bg-opacity:1;background-color:rgb(254 242 242/var(--tw-bg-opacity))}.bg-red-600{--tw-bg-opacity:1;background-color:rgb(220 38 38/var(--tw-bg-opacity))}.bg-secondary\/10{background-color:rgba(82,96,116,.1)}.bg-slate-100{--tw-bg-opacity:1;background-color:rgb(241 245 249/var(--tw-bg-opacity))}.bg-slate-200\/80{background-color:rgba(226,232,240,.8)}.bg-slate-50{--tw-bg-opacity:1;background-color:rgb(248 250 252/var(--tw-bg-opacity))}.bg-slate-50\/80{background-color:rgba(248,250,252,.8)}.bg-surface-container-highest{--tw-bg-opacity:1;background-color:rgb(217 228 234/var(--tw-bg-opacity))}.bg-surface-container-low{--tw-bg-opacity:1;background-color:rgb(240 244 247/var(--tw-bg-opacity))}.bg-surface-container-low\/30{background-color:rgba(240,244,247,.3)}.bg-surface-container-low\/40{background-color:rgba(240,244,247,.4)}.bg-surface-container-low\/50{background-color:rgba(240,244,247,.5)}.bg-surface-container-low\/60{background-color:rgba(240,244,247,.6)}.bg-surface-container-lowest{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity))}.bg-tertiary\/10{background-color:rgba(96,92,120,.1)}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity))}.bg-gradient-to-br{background-image:linear-gradient(to bottom right,var(--tw-gradient-stops))}.from-blue-50{--tw-gradient-from:#eff6ff var(--tw-gradient-from-position);--tw-gradient-to:rgba(239,246,255,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.via-background{--tw-gradient-to:rgba(247,249,251,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),#f7f9fb var(--tw-gradient-via-position),var(--tw-gradient-to)}.to-purple-50{--tw-gradient-to:#faf5ff var(--tw-gradient-to-position)}.p-1\.5{padding:.375rem}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-2\.5{padding-top:.625rem;padding-bottom:.625rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-6{padding-top:1.5rem;padding-bottom:1.5rem}.py-8{padding-top:2rem;padding-bottom:2rem}.pl-10{padding-left:2.5rem}.pr-4{padding-right:1rem}.pr-6{padding-right:1.5rem}.pt-4{padding-top:1rem}.pt-6{padding-top:1.5rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.text-2xl{font-size:1.5rem;line-height:2rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-\[9px\]{font-size:9px}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-black{font-weight:900}.font-bold{font-weight:700}.font-extrabold{font-weight:800}.font-medium{font-weight:500}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.leading-none{line-height:1}.tracking-tight{letter-spacing:-.025em}.tracking-wider{letter-spacing:.05em}.tracking-widest{letter-spacing:.1em}.text-blue-600{--tw-text-opacity:1;color:rgb(37 99 235/var(--tw-text-opacity))}.text-blue-700{--tw-text-opacity:1;color:rgb(29 78 216/var(--tw-text-opacity))}.text-error{--tw-text-opacity:1;color:rgb(159 64 61/var(--tw-text-opacity))}.text-error\/80{color:rgba(159,64,61,.8)}.text-gray-700{--tw-text-opacity:1;color:rgb(55 65 81/var(--tw-text-opacity))}.text-green-700{--tw-text-opacity:1;color:rgb(21 128 61/var(--tw-text-opacity))}.text-on-primary{--tw-text-opacity:1;color:rgb(248 247 255/var(--tw-text-opacity))}.text-on-primary-fixed-variant{--tw-text-opacity:1;color:rgb(51 82 192/var(--tw-text-opacity))}.text-on-surface{--tw-text-opacity:1;color:rgb(42 52 57/var(--tw-text-opacity))}.text-on-surface-variant{--tw-text-opacity:1;color:rgb(86 97 102/var(--tw-text-opacity))}.text-on-surface-variant\/40{color:rgba(86,97,102,.4)}.text-on-surface-variant\/60{color:rgba(86,97,102,.6)}.text-primary{--tw-text-opacity:1;color:rgb(55 85 195/var(--tw-text-opacity))}.text-red-600{--tw-text-opacity:1;color:rgb(220 38 38/var(--tw-text-opacity))}.text-red-700{--tw-text-opacity:1;color:rgb(185 28 28/var(--tw-text-opacity))}.text-secondary{--tw-text-opacity:1;color:rgb(82 96 116/var(--tw-text-opacity))}.text-slate-400{--tw-text-opacity:1;color:rgb(148 163 184/var(--tw-text-opacity))}.text-slate-500{--tw-text-opacity:1;color:rgb(100 116 139/var(--tw-text-opacity))}.text-slate-600{--tw-text-opacity:1;color:rgb(71 85 105/var(--tw-text-opacity))}.text-slate-700{--tw-text-opacity:1;color:rgb(51 65 85/var(--tw-text-opacity))}.text-slate-900{--tw-text-opacity:1;color:rgb(15 23 42/var(--tw-text-opacity))}.text-tertiary{--tw-text-opacity:1;color:rgb(96 92 120/var(--tw-text-opacity))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity))}.underline{text-decoration-line:underline}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.opacity-50{opacity:.5}.shadow-2xl{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color)}.shadow-2xl,.shadow-lg{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.shadow-sm{--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.ring-2{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.ring-error\/20{--tw-ring-color:rgba(159,64,61,.2)}.ring-primary\/20{--tw-ring-color:rgba(55,85,195,.2)}.blur{--tw-blur:blur(8px)}.blur,.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.backdrop-blur-sm{--tw-backdrop-blur:blur(4px)}.backdrop-blur-sm,.backdrop-blur-xl{backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.backdrop-blur-xl{--tw-backdrop-blur:blur(24px)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-100{transition-duration:.1s}.hover\:bg-blue-100:hover{--tw-bg-opacity:1;background-color:rgb(219 234 254/var(--tw-bg-opacity))}.hover\:bg-outline\/20:hover{background-color:hsla(201,7%,48%,.2)}.hover\:bg-primary-dim:hover{--tw-bg-opacity:1;background-color:rgb(40 72 183/var(--tw-bg-opacity))}.hover\:bg-red-100:hover{--tw-bg-opacity:1;background-color:rgb(254 226 226/var(--tw-bg-opacity))}.hover\:bg-red-700:hover{--tw-bg-opacity:1;background-color:rgb(185 28 28/var(--tw-bg-opacity))}.hover\:bg-slate-100:hover{--tw-bg-opacity:1;background-color:rgb(241 245 249/var(--tw-bg-opacity))}.hover\:bg-slate-200:hover{--tw-bg-opacity:1;background-color:rgb(226 232 240/var(--tw-bg-opacity))}.hover\:bg-slate-200\/50:hover{background-color:rgba(226,232,240,.5)}.hover\:bg-slate-50:hover{--tw-bg-opacity:1;background-color:rgb(248 250 252/var(--tw-bg-opacity))}.hover\:bg-slate-50\/80:hover{background-color:rgba(248,250,252,.8)}.hover\:bg-surface-container-low\/30:hover{background-color:rgba(240,244,247,.3)}.hover\:text-error:hover{--tw-text-opacity:1;color:rgb(159 64 61/var(--tw-text-opacity))}.hover\:text-on-surface:hover{--tw-text-opacity:1;color:rgb(42 52 57/var(--tw-text-opacity))}.hover\:text-primary:hover{--tw-text-opacity:1;color:rgb(55 85 195/var(--tw-text-opacity))}.hover\:text-red-700:hover{--tw-text-opacity:1;color:rgb(185 28 28/var(--tw-text-opacity))}.hover\:text-slate-600:hover{--tw-text-opacity:1;color:rgb(71 85 105/var(--tw-text-opacity))}.hover\:text-slate-900:hover{--tw-text-opacity:1;color:rgb(15 23 42/var(--tw-text-opacity))}.focus\:border-transparent:focus{border-color:transparent}.focus\:ring-1:focus{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color)}.focus\:ring-1:focus,.focus\:ring-2:focus{box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.focus\:ring-2:focus{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color)}.focus\:ring-primary:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(55 85 195/var(--tw-ring-opacity))}.focus\:ring-primary\/50:focus{--tw-ring-color:rgba(55,85,195,.5)}.active\:scale-95:active{--tw-scale-x:.95;--tw-scale-y:.95;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.dark\:bg-blue-900:is(.dark *){--tw-bg-opacity:1;background-color:rgb(30 58 138/var(--tw-bg-opacity))}.dark\:bg-gray-700:is(.dark *){--tw-bg-opacity:1;background-color:rgb(55 65 81/var(--tw-bg-opacity))}.dark\:bg-green-900:is(.dark *){--tw-bg-opacity:1;background-color:rgb(20 83 45/var(--tw-bg-opacity))}.dark\:bg-red-900:is(.dark *){--tw-bg-opacity:1;background-color:rgb(127 29 29/var(--tw-bg-opacity))}.dark\:bg-slate-800:is(.dark *){--tw-bg-opacity:1;background-color:rgb(30 41 59/var(--tw-bg-opacity))}.dark\:bg-slate-800\/40:is(.dark *){background-color:rgba(30,41,59,.4)}.dark\:bg-slate-900:is(.dark *){--tw-bg-opacity:1;background-color:rgb(15 23 42/var(--tw-bg-opacity))}.dark\:bg-slate-950\/80:is(.dark *){background-color:rgba(2,6,23,.8)}.dark\:from-slate-950:is(.dark *){--tw-gradient-from:#020617 var(--tw-gradient-from-position);--tw-gradient-to:rgba(2,6,23,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.dark\:via-slate-900:is(.dark *){--tw-gradient-to:rgba(15,23,42,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),#0f172a var(--tw-gradient-via-position),var(--tw-gradient-to)}.dark\:to-slate-950:is(.dark *){--tw-gradient-to:#020617 var(--tw-gradient-to-position)}.dark\:text-blue-200:is(.dark *){--tw-text-opacity:1;color:rgb(191 219 254/var(--tw-text-opacity))}.dark\:text-blue-400:is(.dark *){--tw-text-opacity:1;color:rgb(96 165 250/var(--tw-text-opacity))}.dark\:text-gray-200:is(.dark *){--tw-text-opacity:1;color:rgb(229 231 235/var(--tw-text-opacity))}.dark\:text-green-200:is(.dark *){--tw-text-opacity:1;color:rgb(187 247 208/var(--tw-text-opacity))}.dark\:text-red-200:is(.dark *){--tw-text-opacity:1;color:rgb(254 202 202/var(--tw-text-opacity))}.dark\:text-red-400:is(.dark *){--tw-text-opacity:1;color:rgb(248 113 113/var(--tw-text-opacity))}.dark\:text-slate-300:is(.dark *){--tw-text-opacity:1;color:rgb(203 213 225/var(--tw-text-opacity))}.dark\:text-slate-400:is(.dark *){--tw-text-opacity:1;color:rgb(148 163 184/var(--tw-text-opacity))}.dark\:text-slate-50:is(.dark *){--tw-text-opacity:1;color:rgb(248 250 252/var(--tw-text-opacity))}.dark\:hover\:bg-blue-900:hover:is(.dark *){--tw-bg-opacity:1;background-color:rgb(30 58 138/var(--tw-bg-opacity))}.dark\:hover\:bg-red-900:hover:is(.dark *){--tw-bg-opacity:1;background-color:rgb(127 29 29/var(--tw-bg-opacity))}.dark\:hover\:bg-red-950:hover:is(.dark *){--tw-bg-opacity:1;background-color:rgb(69 10 10/var(--tw-bg-opacity))}.dark\:hover\:bg-slate-800\/50:hover:is(.dark *){background-color:rgba(30,41,59,.5)}.dark\:hover\:text-red-300:hover:is(.dark *){--tw-text-opacity:1;color:rgb(252 165 165/var(--tw-text-opacity))}@media (min-width:768px){.md\:p-6{padding:1.5rem}} \ No newline at end of file diff --git a/public/css/tailwind.css b/public/css/tailwind.css new file mode 100644 index 0000000..b5c61c9 --- /dev/null +++ b/public/css/tailwind.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/public/js/app.js b/public/js/app.js index ad72027..153c98c 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -13,11 +13,21 @@ class AccountManager { this.currentUser = currentUser; this.accounts = []; this.applications = []; + this.users = []; + this.roles = []; + this.accountPage = 1; + this.accountPageSize = 9; + this.appPage = 1; + this.appPageSize = 9; + this.userPage = 1; + this.userPageSize = 9; this.apiBase = '/api'; this.currentPage = 'dashboard'; this.accountSearchTerm = ''; this.applicationSearchTerm = ''; this.accountServiceFilter = ''; + this.userSearchTerm = ''; + this.userRoleFilter = ''; this.configureNotifications(); this.initPromise = this.init(); this.pendingAccountAppId = undefined; @@ -70,6 +80,16 @@ class AccountManager { async init() { await this.fetchApplications(); await this.fetchAccounts(); + + // Check if user is admin and fetch users/roles + if (this.currentUser?.Role === 'admin') { + await this.fetchUsers(); + await this.fetchRoles(); + // Show Users menu + const usersNav = document.getElementById('usersNav'); + if (usersNav) usersNav.style.display = ''; + } + this.setupEventListeners(); this.loadModals(); // Load modals từ file riêng // Single-page navigation based on hash @@ -92,11 +112,23 @@ class AccountManager { this.setupAccountRowListeners(); this.setupAddButtonListeners(); this.setupFilters(); + this.setupAppPagerListeners(); } else if (page === 'accounts') { mainContent.innerHTML = this.getAccountsContent(); this.setupAccountRowListeners(); this.setupAddButtonListeners(); this.setupFilters(); + this.setupAccountPagerListeners(); + } else if (page === 'users') { + // Check if user is admin + if (this.currentUser?.Role !== 'admin') { + mainContent.innerHTML = this.renderDashboard(); + } else { + mainContent.innerHTML = this.getUsersContent(); + this.setupUsersRowListeners(); + this.setupAddButtonListeners(); + this.setupUsersPagerListeners(); + } } else { mainContent.innerHTML = this.renderDashboard(); } @@ -129,14 +161,44 @@ class AccountManager { } async fetchAccounts() { - const userId = this.getUserId(); - if (!userId) return; - const res = await fetch(`${this.apiBase}/accounts/user/${userId}`); - const data = await res.json(); - if (data.success) { - this.accounts = data.data; - } else { - console.error('Load accounts failed:', data.message); + try { + const res = await fetch(`${this.apiBase}/accounts/all`); + const data = await res.json(); + if (data.success) { + this.accounts = data.data; + } else { + console.error('Load accounts failed:', data.message); + } + } catch (err) { + console.error('Fetch accounts error:', err); + } + } + + async fetchUsers() { + try { + const res = await fetch(`${this.apiBase}/users`); + const data = await res.json(); + if (data.success) { + this.users = data.data; + } else { + console.error('Load users failed:', data.message); + } + } catch (err) { + console.error('Fetch users error:', err); + } + } + + async fetchRoles() { + try { + const res = await fetch(`${this.apiBase}/roles`); + const data = await res.json(); + if (data.success) { + this.roles = data.data; + } else { + console.error('Load roles failed:', data.message); + } + } catch (err) { + console.error('Fetch roles error:', err); } } @@ -197,16 +259,6 @@ class AccountManager { }); // Form submissions - const accountForm = document.getElementById('accountForm'); - if (accountForm) { - accountForm.addEventListener('submit', (e) => this.handleAccountSubmit(e)); - } - - const appForm = document.getElementById('appForm'); - if (appForm) { - appForm.addEventListener('submit', (e) => this.handleAppSubmit(e)); - } - // Logout button const logoutBtn = document.getElementById('logoutBtn'); if (logoutBtn) { @@ -224,12 +276,18 @@ class AccountManager { setupFormListeners() { const accountForm = document.getElementById('accountForm'); if (accountForm) { - accountForm.addEventListener('submit', (e) => this.handleAccountSubmit(e)); + if (!accountForm.dataset.boundSubmit) { + accountForm.addEventListener('submit', (e) => this.handleAccountSubmit(e)); + accountForm.dataset.boundSubmit = 'true'; + } } const appForm = document.getElementById('appForm'); if (appForm) { - appForm.addEventListener('submit', (e) => this.handleAppSubmit(e)); + if (!appForm.dataset.boundSubmit) { + appForm.addEventListener('submit', (e) => this.handleAppSubmit(e)); + appForm.dataset.boundSubmit = 'true'; + } } // Close when clicking backdrop outside modal content @@ -248,7 +306,7 @@ class AccountManager { const roleEl = document.getElementById('accountRole'); if (usernameEl) usernameEl.textContent = this.currentUser?.username || this.currentUser?.Username || 'User'; - if (roleEl) roleEl.textContent = this.currentUser?.role || this.currentUser?.Role || 'Administrator'; + if (roleEl) roleEl.textContent = this.currentUser?.role || this.currentUser?.Role || 'Guest'; } getFilteredAccounts() { @@ -272,6 +330,21 @@ class AccountManager { }); } + getPaged(items, page, pageSize) { + const total = items.length; + const totalPages = Math.max(1, Math.ceil(total / pageSize)); + const current = Math.min(Math.max(1, page), totalPages); + const start = (current - 1) * pageSize; + return { + current, + total, + totalPages, + data: items.slice(start, start + pageSize), + start: total === 0 ? 0 : start + 1, + end: Math.min(total, start + pageSize) + }; + } + handleLogout() { if (confirm('Are you sure you want to logout?')) { this.saveToStorage('currentUser', null); @@ -363,6 +436,9 @@ class AccountManager { getAccountsContent() { const filteredAccounts = this.getFilteredAccounts(); + const currentUserId = this.getUserId(); + const pageInfo = this.getPaged(filteredAccounts, this.accountPage, this.accountPageSize); + this.accountPage = pageInfo.current; return `
@@ -394,11 +470,12 @@ class AccountManager {
- ${filteredAccounts.length > 0 ? ` + ${pageInfo.data.length > 0 ? `
+ @@ -406,29 +483,41 @@ class AccountManager { - ${filteredAccounts.map(acc => ` - - + ${pageInfo.data.map(acc => { + const isOwnAccount = acc.UserId == currentUserId; + return ` + + + - `).join('')} + `; + }).join('')}
User Owner Username Service
+
+ Showing ${pageInfo.start}-${pageInfo.end} of ${pageInfo.total} +
+ + Page ${pageInfo.current} / ${pageInfo.totalPages} + +
+
` : `
@@ -448,6 +537,8 @@ class AccountManager { getApplicationsContent() { const filteredApps = this.getFilteredApplications(); + const pageInfo = this.getPaged(filteredApps, this.appPage, this.appPageSize); + this.appPage = pageInfo.current; return `
@@ -512,7 +603,7 @@ class AccountManager { - ${filteredApps.map(app => ` + ${pageInfo.data.map(app => `
@@ -551,6 +642,14 @@ class AccountManager {
+
+ Showing ${pageInfo.start}-${pageInfo.end} of ${pageInfo.total} +
+ + Page ${pageInfo.current} / ${pageInfo.totalPages} + +
+
@@ -560,35 +659,56 @@ class AccountManager { renderAccountsTableBody() { const tbody = document.querySelector('.accounts-table-body'); if (!tbody) return; - const filteredAccounts = this.getFilteredAccounts(); - tbody.innerHTML = filteredAccounts.map(acc => ` - - ${acc.Email || '-'} + const currentUserId = this.getUserId(); + const pageInfo = this.getPaged(this.getFilteredAccounts(), this.accountPage, this.accountPageSize); + this.accountPage = pageInfo.current; + tbody.innerHTML = pageInfo.data.map(acc => { + const isOwnAccount = acc.UserId == currentUserId; + return ` + + ${acc.Username || acc.FullName || '-'} + ${acc.Email || '-'} ${acc.AccountUsername || '-'} ${acc.AppName || '-'} - - - - `).join(''); + `; + }).join(''); + + const pager = document.getElementById('accountsPager'); + if (pager) { + pager.innerHTML = ` + Showing ${pageInfo.start}-${pageInfo.end} of ${pageInfo.total} +
+ + Page ${pageInfo.current} / ${pageInfo.totalPages} + +
+ `; + } + this.setupAccountRowListeners(); + this.setupAccountPagerListeners(); } renderApplicationsTableBody() { const tbody = document.querySelector('.apps-table-body'); if (!tbody) return; - const filteredApps = this.getFilteredApplications(); - tbody.innerHTML = filteredApps.map(app => ` + const pageInfo = this.getPaged(this.getFilteredApplications(), this.appPage, this.appPageSize); + this.appPage = pageInfo.current; + tbody.innerHTML = pageInfo.data.map(app => `
@@ -624,13 +744,50 @@ class AccountManager { `).join(''); + + const pager = document.getElementById('appsPager'); + if (pager) { + pager.innerHTML = ` + Showing ${pageInfo.start}-${pageInfo.end} of ${pageInfo.total} +
+ + Page ${pageInfo.current} / ${pageInfo.totalPages} + +
+ `; + } + this.setupAccountRowListeners(); + this.setupAppPagerListeners(); + } + + setupAccountPagerListeners() { + document.querySelectorAll('.account-page-btn').forEach(btn => { + btn.addEventListener('click', () => { + const targetPage = Number(btn.dataset.page); + if (!targetPage || targetPage < 1) return; + this.accountPage = targetPage; + this.renderAccountsTableBody(); + }); + }); + } + + setupAppPagerListeners() { + document.querySelectorAll('.app-page-btn').forEach(btn => { + btn.addEventListener('click', () => { + const targetPage = Number(btn.dataset.page); + if (!targetPage || targetPage < 1) return; + this.appPage = targetPage; + this.renderApplicationsTableBody(); + }); + }); } setupAccountRowListeners() { // View Account listeners document.querySelectorAll('.view-account').forEach(btn => { btn.addEventListener('click', (e) => { + if (btn.disabled) return; // Only view own accounts const accountId = Number(btn.dataset.accountId); const account = this.accounts.find(a => a.AccountId === accountId); this.currentViewAccountId = accountId; @@ -639,10 +796,30 @@ class AccountManager { document.getElementById('viewAccountOwner').textContent = account?.Email || '-'; document.getElementById('viewAccountUsername').textContent = account?.AccountUsername || '-'; const passwordEl = document.getElementById('viewAccountPassword'); - passwordEl.dataset.password = account?.AccountPassword || ''; - passwordEl.textContent = '••••••••'; + const toggleBtn = document.querySelector('.toggle-password'); + const toggleIcon = document.getElementById('toggleIcon'); + const storedPwd = account?.AccountPassword || ''; + passwordEl.dataset.password = storedPwd; + passwordEl.textContent = storedPwd ? '••••••••' : '(no password stored)'; passwordEl.dataset.visible = 'false'; - document.getElementById('toggleIcon').textContent = 'visibility'; + if (toggleIcon) toggleIcon.textContent = 'visibility'; + + // Rebind toggle each time modal opens to keep state fresh + if (toggleBtn) { + toggleBtn.onclick = () => { + const currentPwd = passwordEl.dataset.password || ''; + const isVisible = passwordEl.dataset.visible === 'true'; + if (isVisible) { + passwordEl.textContent = currentPwd ? '••••••••' : '(no password stored)'; + passwordEl.dataset.visible = 'false'; + if (toggleIcon) toggleIcon.textContent = 'visibility'; + } else { + passwordEl.textContent = currentPwd || '(no password stored)'; + passwordEl.dataset.visible = 'true'; + if (toggleIcon) toggleIcon.textContent = 'visibility_off'; + } + }; + } document.getElementById('viewAccountModal').classList.add('open'); }); }); @@ -650,6 +827,7 @@ class AccountManager { // Delete Account listeners - show confirmation modal document.querySelectorAll('.delete-account').forEach(btn => { btn.addEventListener('click', (e) => { + if (btn.disabled) return; // Don't delete others' accounts const accountId = Number(btn.dataset.accountId); const account = this.accounts.find(a => a.AccountId === accountId); this.pendingDeleteAccountId = accountId; @@ -684,6 +862,7 @@ class AccountManager { // Edit Account listeners document.querySelectorAll('.edit-account').forEach(btn => { btn.addEventListener('click', (e) => { + if (btn.disabled) return; // Don't edit others' accounts const accountId = Number(btn.dataset.accountId); const account = this.accounts.find(a => a.AccountId === accountId); // Populate form with existing data @@ -727,26 +906,6 @@ class AccountManager { }); }); - // Toggle Password Visibility - document.querySelectorAll('.toggle-password').forEach(btn => { - btn.addEventListener('click', () => { - const passwordEl = document.getElementById('viewAccountPassword'); - const toggleIcon = document.getElementById('toggleIcon'); - const storedPwd = passwordEl.dataset.password || this.currentViewAccount?.AccountPassword || ''; - const isVisible = passwordEl.dataset.visible === 'true'; - - if (isVisible) { - passwordEl.textContent = '••••••••'; - passwordEl.dataset.visible = 'false'; - toggleIcon.textContent = 'visibility'; - } else { - passwordEl.textContent = storedPwd || '(no password stored)'; - passwordEl.dataset.visible = 'true'; - toggleIcon.textContent = 'visibility_off'; - } - }); - }); - // View App listeners document.querySelectorAll('.view-app').forEach(btn => { btn.addEventListener('click', (e) => { @@ -1079,6 +1238,577 @@ class AccountManager { saveToStorage(key, data) { localStorage.setItem(key, JSON.stringify(data)); } + + formatDateTime(value) { + if (!value) return '-'; + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return String(value); + } + return date.toLocaleString(); + } + + // ========== Users Management ========== + getUsersContent() { + const filteredUsers = this.getFilteredUsers(); + const pageInfo = this.getPaged(filteredUsers, this.userPage, this.userPageSize); + this.userPage = pageInfo.current; + return ` +
+
+

Users Management

+ +
+ + +
+
+ + search +
+ +
+ + +
+
+ + + + + + + + + + + + + ${pageInfo.data.length === 0 ? ` + + + + ` : pageInfo.data.map(user => ` + + + + + + + + + `).join('')} + +
UsernameFull NameEmailRoleStatusActions
No users found
${user.Username}${user.FullName || '-'}${user.Email || '-'} + + ${user.RoleName || user.Role || 'N/A'} + + + + ${user.IsActive ? 'Active' : 'Inactive'} + + +
+ + + +
+
+
+
+ Showing ${pageInfo.start}-${pageInfo.end} of ${pageInfo.total} +
+ + Page ${pageInfo.current} / ${pageInfo.totalPages} + +
+
+
+
+ `; + } + + setupUsersRowListeners() { + const userRows = document.querySelectorAll('.user-row'); + userRows.forEach(row => { + const viewBtn = row.querySelector('.view-user-btn'); + const editBtn = row.querySelector('.edit-user-btn'); + const deleteBtn = row.querySelector('.delete-user-btn'); + const userId = row.dataset.userId; + + if (viewBtn) { + viewBtn.addEventListener('click', () => this.viewUserDetails(userId)); + } + + if (editBtn) { + editBtn.addEventListener('click', () => this.editUser(userId)); + } + + if (deleteBtn && !deleteBtn.disabled) { + deleteBtn.addEventListener('click', () => this.deleteUserConfirm(userId)); + } + }); + + // Search and Filter + const searchInput = document.getElementById('userSearch'); + const roleFilter = document.getElementById('roleFilter'); + + if (searchInput) { + searchInput.oninput = (e) => { + this.userSearchTerm = (e.target.value || '').toLowerCase(); + this.userPage = 1; + this.renderUsersTableBody(); + }; + } + + if (roleFilter) { + roleFilter.value = this.userRoleFilter || ''; + roleFilter.onchange = (e) => { + this.userRoleFilter = e.target.value; + this.userPage = 1; + this.renderUsersTableBody(); + }; + } + + // Add User Button + const addBtn = document.getElementById('addUserBtn'); + if (addBtn) { + addBtn.onclick = () => this.openUserModal(); + } + } + + getFilteredUsers() { + const search = (this.userSearchTerm || '').toLowerCase(); + const roleId = this.userRoleFilter || ''; + return this.users.filter(user => { + const matchesSearch = !search || [user.Username, user.FullName, user.Email].some(val => (val || '').toLowerCase().includes(search)); + const matchesRole = !roleId || String(user.RoleId) === String(roleId) || String(user.RoleID) === String(roleId); + return matchesSearch && matchesRole; + }); + } + + renderUsersTableBody() { + const tbody = document.querySelector('.users-table-body'); + if (!tbody) return; + const pageInfo = this.getPaged(this.getFilteredUsers(), this.userPage, this.userPageSize); + this.userPage = pageInfo.current; + tbody.innerHTML = pageInfo.data.length === 0 ? ` + + No users found + + ` : pageInfo.data.map(user => ` + + ${user.Username} + ${user.FullName || '-'} + ${user.Email || '-'} + + + ${user.RoleName || user.Role || 'N/A'} + + + + + ${user.IsActive ? 'Active' : 'Inactive'} + + + +
+ + + +
+ + + `).join(''); + + const pager = document.getElementById('usersPager'); + if (pager) { + pager.innerHTML = ` + Showing ${pageInfo.start}-${pageInfo.end} of ${pageInfo.total} +
+ + Page ${pageInfo.current} / ${pageInfo.totalPages} + +
+ `; + } + + this.setupUsersRowListeners(); + this.setupUsersPagerListeners(); + } + + setupUsersPagerListeners() { + document.querySelectorAll('.user-page-btn').forEach(btn => { + btn.addEventListener('click', () => { + const targetPage = Number(btn.dataset.page); + if (!targetPage || targetPage < 1) return; + this.userPage = targetPage; + this.renderUsersTableBody(); + }); + }); + } + + openUserModal() { + this.showUserFormModal(null); + } + + showUserFormModal(userId) { + const user = userId ? this.users.find(u => u.UserId == userId) : null; + + const html = ` + + `; + + // Insert modal in DOM + const editingContainer = document.getElementById('userModalContainer'); + if (editingContainer) { + editingContainer.innerHTML = html; + } else { + const container = document.createElement('div'); + container.id = 'userModalContainer'; + document.body.appendChild(container); + container.innerHTML = html; + } + + // Add form submit listener + const form = document.getElementById('userForm'); + if (form) { + form.addEventListener('submit', (e) => this.saveUser(e, userId)); + } + + const passwordInput = document.getElementById('userPassword'); + const passwordToggleBtn = document.getElementById('userPasswordToggle'); + const passwordToggleIcon = document.getElementById('userPasswordToggleIcon'); + if (passwordInput && passwordToggleBtn) { + passwordToggleBtn.addEventListener('click', () => { + const isHidden = passwordInput.type === 'password'; + passwordInput.type = isHidden ? 'text' : 'password'; + if (passwordToggleIcon) { + passwordToggleIcon.textContent = isHidden ? 'visibility_off' : 'visibility'; + } + }); + } + + // Close on backdrop click + const modal = document.getElementById('userModal'); + if (modal) { + modal.addEventListener('click', function(e) { + if (e.target === this) { + closeUserModal(); + } + }); + } + } + + async saveUser(e, userId) { + e.preventDefault(); + + const username = document.getElementById('userUsername').value.trim(); + const fullname = document.getElementById('userFullName').value.trim(); + const email = document.getElementById('userEmail').value.trim(); + const password = document.getElementById('userPassword')?.value.trim(); + const roleId = document.getElementById('userRole').value; + const isActive = document.getElementById('userActive').checked; + + // Validate required fields + if (!username || !fullname) { + this.notifyFailure('Username and Full Name are required'); + return; + } + + // Password required for new user + if (!userId && !password) { + this.notifyFailure('Password is required for new user'); + return; + } + + const method = userId ? 'PUT' : 'POST'; + const url = userId ? `${this.apiBase}/users/${userId}` : `${this.apiBase}/users`; + + const payload = userId + ? { + email: email || null, + fullname, + roleId: parseInt(roleId), + status: 'Active', + isActive, + ...(password ? { password } : {}) + } + : { username, password, email: email || null, fullname, roleId: parseInt(roleId) }; + + try { + const response = await fetch(url, { + method, + headers: { 'Content-Type': 'application/json', 'x-user-role': this.currentUser?.Role }, + body: JSON.stringify(payload) + }); + + const data = await response.json(); + if (data.success) { + this.notifySuccess(userId ? 'User updated' : 'User created'); + closeUserModal(); + this.refreshUsersUI(); + } else { + this.notifyFailure(data.message || 'Save failed'); + } + } catch (err) { + console.error(err); + this.notifyFailure('Save failed'); + } + } + + async editUser(userId) { + this.showUserFormModal(userId); + } + + async viewUserDetails(userId) { + try { + const response = await fetch(`${this.apiBase}/users/${userId}`, { + headers: { 'x-user-role': this.currentUser?.Role } + }); + const data = await response.json(); + + if (!data.success || !data.data) { + this.notifyFailure(data.message || 'Cannot load user details'); + return; + } + + this.showUserDetailsModal(data.data); + } catch (err) { + console.error(err); + this.notifyFailure('Cannot load user details'); + } + } + + showUserDetailsModal(user) { + const html = ` + + `; + + const detailsContainer = document.getElementById('userDetailsModalContainer'); + if (detailsContainer) { + detailsContainer.innerHTML = html; + } else { + const container = document.createElement('div'); + container.id = 'userDetailsModalContainer'; + container.innerHTML = html; + document.body.appendChild(container); + } + + const passwordValue = user?.Password || ''; + const hasReadablePassword = user?.PasswordAvailable === true || Boolean(passwordValue); + const usernameEl = document.getElementById('userDetailUsername'); + const fullNameEl = document.getElementById('userDetailFullName'); + const emailEl = document.getElementById('userDetailEmail'); + const roleEl = document.getElementById('userDetailRole'); + const statusEl = document.getElementById('userDetailStatus'); + const createdDateEl = document.getElementById('userDetailCreatedDate'); + const lastLoginEl = document.getElementById('userDetailLastLogin'); + const passwordEl = document.getElementById('userDetailPassword'); + const passwordToggleBtn = document.getElementById('userDetailPasswordToggle'); + const passwordToggleIcon = document.getElementById('userDetailPasswordToggleIcon'); + const editBtn = document.getElementById('userDetailEditBtn'); + const detailsModal = document.getElementById('userDetailsModal'); + + if (usernameEl) usernameEl.textContent = user?.Username || '-'; + if (fullNameEl) fullNameEl.textContent = user?.FullName || '-'; + if (emailEl) emailEl.textContent = user?.Email || '-'; + if (roleEl) roleEl.textContent = user?.RoleName || user?.Role || '-'; + if (statusEl) statusEl.textContent = user?.IsActive ? 'Active' : 'Inactive'; + if (createdDateEl) createdDateEl.textContent = this.formatDateTime(user?.CreatedDate); + if (lastLoginEl) lastLoginEl.textContent = this.formatDateTime(user?.LastLogin); + if (passwordEl) { + passwordEl.dataset.password = passwordValue; + passwordEl.dataset.visible = 'false'; + passwordEl.textContent = hasReadablePassword ? '••••••••' : '(khong the hien thi - can reset password 1 lan)'; + } + + if (passwordToggleBtn && passwordEl) { + passwordToggleBtn.addEventListener('click', () => { + if (!hasReadablePassword) { + return; + } + const isVisible = passwordEl.dataset.visible === 'true'; + if (isVisible) { + passwordEl.textContent = '••••••••'; + passwordEl.dataset.visible = 'false'; + if (passwordToggleIcon) passwordToggleIcon.textContent = 'visibility'; + } else { + passwordEl.textContent = passwordValue; + passwordEl.dataset.visible = 'true'; + if (passwordToggleIcon) passwordToggleIcon.textContent = 'visibility_off'; + } + }); + } + + if (passwordToggleBtn && !hasReadablePassword) { + passwordToggleBtn.disabled = true; + passwordToggleBtn.classList.add('opacity-50', 'cursor-not-allowed'); + } + + if (editBtn) { + editBtn.addEventListener('click', () => { + closeUserDetailsModal(); + this.editUser(user?.UserId); + }); + } + + if (detailsModal) { + detailsModal.addEventListener('click', function(e) { + if (e.target === this) { + closeUserDetailsModal(); + } + }); + } + } + + async deleteUserConfirm(userId) { + const user = this.users.find(u => u.UserId == userId); + if (!user) return; + + if (confirm(`Are you sure you want to delete user "${user.Username}"?`)) { + await this.deleteUser(userId); + } + } + + async deleteUser(userId) { + try { + const response = await fetch(`${this.apiBase}/users/${userId}`, { + method: 'DELETE', + headers: { 'x-user-role': this.currentUser?.Role } + }); + + const data = await response.json(); + if (data.success) { + this.notifySuccess('User deleted'); + this.refreshUsersUI(); + } else { + this.notifyFailure(data.message || 'Delete failed'); + } + } catch (err) { + console.error(err); + this.notifyFailure('Delete failed'); + } + } + + async refreshUsersUI() { + await this.fetchUsers(); + if (this.currentPage === 'users') { + this.renderView('users'); + } + } } // Global modal close functions @@ -1112,6 +1842,20 @@ function closeDeleteAppModal() { document.getElementById('deleteAppModal').classList.remove('open'); } +function closeUserModal() { + const userModalContainer = document.getElementById('userModalContainer'); + if (userModalContainer) { + userModalContainer.innerHTML = ''; + } +} + +function closeUserDetailsModal() { + const detailsContainer = document.getElementById('userDetailsModalContainer'); + if (detailsContainer) { + detailsContainer.innerHTML = ''; + } +} + // Initialize app when DOM is ready let app; document.addEventListener('DOMContentLoaded', () => { diff --git a/public/pages/accounts.html b/public/pages/accounts.html index 621c16b..3788abd 100644 --- a/public/pages/accounts.html +++ b/public/pages/accounts.html @@ -8,78 +8,10 @@ + - - + + + + + + diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 0000000..615d33d --- /dev/null +++ b/tailwind.config.js @@ -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") + ] +};