commit 95b47a69e339dd9a45903075625f8e7f13e754cf Author: jpmvaz Date: Sun Sep 13 20:21:31 2026 +0100 v_1 diff --git a/.env b/.env new file mode 100644 index 0000000..ba8cc16 --- /dev/null +++ b/.env @@ -0,0 +1,17 @@ +# Database Configuration +POSTGRES_DB=mobile_mgmt +POSTGRES_USER=app_user +POSTGRES_PASSWORD=StrongDBPassword123! + +# JWT Secrets (change these in production!) +JWT_SECRET=your-super-secret-jwt-key-change-this-in-production-min-32-chars +JWT_REFRESH_SECRET=your-super-secret-refresh-key-change-this-in-production-min-32-chars + +# Encryption key for MFA secrets (must be exactly 32 chars) +ENCRYPTION_KEY=12345678901234567890123456789012 + +# App host IP (0.0.0.0 = all interfaces, or specify e.g. 192.168.1.100) +APP_HOST=0.0.0.0 + +# App port +APP_PORT=80 diff --git a/README.md b/README.md new file mode 100644 index 0000000..3e0e13f --- /dev/null +++ b/README.md @@ -0,0 +1,68 @@ +# Mobile Contract Manager + +A full-stack web application for managing mobile contracts with role-based access control, MFA support, and a modern UI. + +## Features + +- **Authentication**: JWT-based with MFA (Google Authenticator / TOTP) +- **Roles**: Admin (full access) and User (read-only) +- **MFA**: QR code setup via Google Authenticator +- **First Login**: Forces password change on first login +- **Database**: PostgreSQL (self-hosted) +- **Frontend**: React + Tailwind CSS (dark modern UI) + +## Default Credentials + +| Username | Password | Role | +|----------|----------|-------| +| admin | admin | Admin | + +> You will be prompted to change the password on first login. + +## Quick Start + +1. **Clone / extract** this project + +2. **Configure** `.env` (change passwords and secrets for production!): + ``` + POSTGRES_PASSWORD=your_secure_password + JWT_SECRET=your_super_secret_key_min_32_characters + JWT_REFRESH_SECRET=another_super_secret_key_min_32_chars + ENCRYPTION_KEY=exactly32characterslongkeyhere123 + ``` + +3. **Deploy** with Docker Compose: + ```bash + docker compose up -d + ``` + +4. **Access** the app at `http://localhost` (or port defined in APP_PORT) + +## Stopping the App + +```bash +docker compose down +``` + +To also remove the database volume (all data): +```bash +docker compose down -v +``` + +## Project Structure + +``` +├── backend/ # Node.js/Express API +├── frontend/ # React SPA +├── nginx/ # Reverse proxy config +├── db-init/ # Database initialization SQL +├── docker-compose.yml +└── .env +``` + +## Security Notes + +- Change all `.env` secrets before production deployment +- The `ENCRYPTION_KEY` must be exactly 32 characters +- Consider using HTTPS (put a TLS-terminating reverse proxy in front) +- Passwords are hashed with bcrypt (cost factor 12) diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..da9abeb --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,12 @@ +FROM node:20-alpine + +WORKDIR /app + +COPY package*.json ./ +RUN npm install --omit=dev + +COPY . . + +EXPOSE 3001 + +CMD ["node", "server.js"] diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..b7b7022 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,20 @@ +{ + "name": "mobile-mgmt-backend", + "version": "1.0.0", + "description": "Backend API for Mobile Contract Management", + "main": "server.js", + "scripts": { + "start": "node server.js" + }, + "dependencies": { + "bcrypt": "^6.0.0", + "cors": "^2.8.5", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "jsonwebtoken": "^9.0.3", + "multer": "^2.0.1", + "otplib": "^13.3.0", + "pg": "^8.18.0", + "qrcode": "^1.5.4" + } +} diff --git a/backend/server.js b/backend/server.js new file mode 100644 index 0000000..b37fc96 --- /dev/null +++ b/backend/server.js @@ -0,0 +1,430 @@ +const express = require('express'); +const cors = require('cors'); +const rateLimit = require('express-rate-limit'); +const { Pool } = require('pg'); +const bcrypt = require('bcrypt'); +const jwt = require('jsonwebtoken'); +// otplib v13: new async-first flat imports +const { generateSecret, generate: totpGenerate, verify: totpVerify, generateURI } = require('otplib'); +const qrcode = require('qrcode'); +const multer = require('multer'); + +const app = express(); +app.use(express.json({ limit: '50mb' })); +app.use(cors({ origin: '*' })); + +// multer: in-memory storage for backup import uploads +const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 50 * 1024 * 1024 } }); + +const limiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 500 }); +const authLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 20 }); +app.use(limiter); + +const pool = new Pool({ connectionString: process.env.DATABASE_URL }); +const JWT_SECRET = process.env.JWT_SECRET; + +// --- Seed default admin on startup --- +async function seedAdmin() { + try { + const { rows } = await pool.query("SELECT id FROM users WHERE username = 'admin'"); + if (rows.length === 0) { + const hash = await bcrypt.hash('admin', 12); + await pool.query( + "INSERT INTO users (username, password_hash, role, must_change_password) VALUES ('admin', $1, 'admin', TRUE)", + [hash] + ); + console.log('Default admin user created (username: admin, password: admin)'); + } + } catch (err) { + console.error('Failed to seed admin:', err.message); + } +} + +async function connectWithRetry(retries = 10, delay = 3000) { + for (let i = 0; i < retries; i++) { + try { + await pool.query('SELECT 1'); + console.log('Database connected'); + await seedAdmin(); + return; + } catch (err) { + console.log(`DB not ready, retrying in ${delay / 1000}s... (${i + 1}/${retries})`); + await new Promise(r => setTimeout(r, delay)); + } + } + console.error('Could not connect to database after retries'); +} + +connectWithRetry(); + +// --- Auth Middleware --- +function authenticate(req, res, next) { + const auth = req.headers.authorization; + if (!auth || !auth.startsWith('Bearer ')) return res.status(401).json({ error: 'Unauthorized' }); + try { + req.user = jwt.verify(auth.slice(7), JWT_SECRET); + next(); + } catch { + res.status(401).json({ error: 'Invalid token' }); + } +} + +function requireAdmin(req, res, next) { + if (req.user.role !== 'admin') return res.status(403).json({ error: 'Forbidden' }); + next(); +} + +// --- Auth Routes --- +app.post('/api/auth/login', authLimiter, async (req, res) => { + const { username, password, mfa_code } = req.body; + if (!username || !password) return res.status(400).json({ error: 'Username and password required' }); + + const { rows } = await pool.query('SELECT * FROM users WHERE username = $1', [username]); + if (!rows.length) return res.status(401).json({ error: 'Invalid credentials' }); + + const user = rows[0]; + const valid = await bcrypt.compare(password, user.password_hash); + if (!valid) return res.status(401).json({ error: 'Invalid credentials' }); + + if (user.mfa_enabled) { + if (!mfa_code) return res.status(200).json({ mfa_required: true }); + // otplib v13: verify returns { valid: boolean } + const result = await totpVerify({ secret: user.mfa_secret, token: mfa_code }); + if (!result.valid) return res.status(401).json({ error: 'Invalid MFA code' }); + } + + const token = jwt.sign({ id: user.id, username: user.username, role: user.role }, JWT_SECRET, { expiresIn: '8h' }); + res.json({ + token, + user: { id: user.id, username: user.username, role: user.role, must_change_password: user.must_change_password, mfa_enabled: user.mfa_enabled } + }); +}); + +app.post('/api/auth/change-password', authenticate, async (req, res) => { + const { current_password, new_password } = req.body; + if (!new_password || new_password.length < 6) return res.status(400).json({ error: 'Password must be at least 6 characters' }); + + const { rows } = await pool.query('SELECT * FROM users WHERE id = $1', [req.user.id]); + const user = rows[0]; + + if (current_password) { + const valid = await bcrypt.compare(current_password, user.password_hash); + if (!valid) return res.status(401).json({ error: 'Current password incorrect' }); + } else if (!user.must_change_password) { + return res.status(400).json({ error: 'Current password required' }); + } + + const hash = await bcrypt.hash(new_password, 12); + await pool.query('UPDATE users SET password_hash = $1, must_change_password = FALSE, updated_at = NOW() WHERE id = $2', [hash, req.user.id]); + res.json({ message: 'Password changed successfully' }); +}); + +// --- MFA Routes --- +app.post('/api/auth/mfa/setup', authenticate, async (req, res) => { + // otplib v13: generateSecret() is sync + const secret = generateSecret(); + // generateURI builds the otpauth:// string + const uri = generateURI({ issuer: 'MobileManager', label: req.user.username, secret }); + const qr = await qrcode.toDataURL(uri); + await pool.query('UPDATE users SET mfa_secret = $1 WHERE id = $2', [secret, req.user.id]); + res.json({ secret, qr }); +}); + +app.post('/api/auth/mfa/verify', authenticate, async (req, res) => { + const { code } = req.body; + const { rows } = await pool.query('SELECT mfa_secret FROM users WHERE id = $1', [req.user.id]); + if (!rows[0]?.mfa_secret) return res.status(400).json({ error: 'MFA not set up' }); + const result = await totpVerify({ secret: rows[0].mfa_secret, token: code }); + if (!result.valid) return res.status(400).json({ error: 'Invalid code' }); + await pool.query('UPDATE users SET mfa_enabled = TRUE WHERE id = $1', [req.user.id]); + res.json({ message: 'MFA enabled successfully' }); +}); + +app.post('/api/auth/mfa/disable', authenticate, async (req, res) => { + await pool.query('UPDATE users SET mfa_enabled = FALSE, mfa_secret = NULL WHERE id = $1', [req.user.id]); + res.json({ message: 'MFA disabled' }); +}); + +// --- User Management (Admin only) --- +app.get('/api/users', authenticate, requireAdmin, async (req, res) => { + const { rows } = await pool.query('SELECT id, username, role, mfa_enabled, must_change_password, created_at FROM users ORDER BY created_at DESC'); + res.json(rows); +}); + +app.post('/api/users', authenticate, requireAdmin, async (req, res) => { + const { username, password, role } = req.body; + if (!username || !password) return res.status(400).json({ error: 'Username and password required' }); + if (!['admin', 'user'].includes(role)) return res.status(400).json({ error: 'Invalid role' }); + if (password.length < 6) return res.status(400).json({ error: 'Password must be at least 6 characters' }); + try { + const hash = await bcrypt.hash(password, 12); + const { rows } = await pool.query( + 'INSERT INTO users (username, password_hash, role, must_change_password) VALUES ($1, $2, $3, TRUE) RETURNING id, username, role, created_at', + [username, hash, role] + ); + res.status(201).json(rows[0]); + } catch (e) { + if (e.code === '23505') return res.status(409).json({ error: 'Username already exists' }); + throw e; + } +}); + +app.put('/api/users/:id', authenticate, requireAdmin, async (req, res) => { + const { username, role, password } = req.body; + const { id } = req.params; + if (role && !['admin', 'user'].includes(role)) return res.status(400).json({ error: 'Invalid role' }); + + const updates = [], values = []; + let idx = 1; + if (username) { updates.push(`username = $${idx++}`); values.push(username); } + if (role) { updates.push(`role = $${idx++}`); values.push(role); } + if (password) { + if (password.length < 6) return res.status(400).json({ error: 'Password must be at least 6 characters' }); + const hash = await bcrypt.hash(password, 12); + updates.push(`password_hash = $${idx++}`); values.push(hash); + updates.push(`must_change_password = TRUE`); + } + if (!updates.length) return res.status(400).json({ error: 'Nothing to update' }); + updates.push(`updated_at = NOW()`); + values.push(id); + try { + const { rows } = await pool.query( + `UPDATE users SET ${updates.join(', ')} WHERE id = $${idx} RETURNING id, username, role, mfa_enabled, must_change_password`, + values + ); + if (!rows.length) return res.status(404).json({ error: 'User not found' }); + res.json(rows[0]); + } catch (e) { + if (e.code === '23505') return res.status(409).json({ error: 'Username already exists' }); + throw e; + } +}); + +app.delete('/api/users/:id', authenticate, requireAdmin, async (req, res) => { + if (parseInt(req.params.id) === req.user.id) return res.status(400).json({ error: 'Cannot delete yourself' }); + const { rowCount } = await pool.query('DELETE FROM users WHERE id = $1', [req.params.id]); + if (!rowCount) return res.status(404).json({ error: 'User not found' }); + res.json({ message: 'User deleted' }); +}); + +// --- Field Definition Routes --- +app.get('/api/fields', authenticate, async (req, res) => { + const { rows } = await pool.query('SELECT * FROM field_definitions ORDER BY sort_order ASC, id ASC'); + res.json(rows); +}); + +app.post('/api/fields', authenticate, requireAdmin, async (req, res) => { + let { label, field_type, required } = req.body; + if (!label || !label.trim()) return res.status(400).json({ error: 'Label is required' }); + if (!['text', 'number', 'phone', 'email'].includes(field_type)) field_type = 'text'; + + const field_key = label.trim().toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, ''); + if (!field_key) return res.status(400).json({ error: 'Invalid label' }); + + const { rows: maxRows } = await pool.query('SELECT COALESCE(MAX(sort_order), 0) as max FROM field_definitions'); + const sort_order = maxRows[0].max + 1; + + try { + const { rows } = await pool.query( + 'INSERT INTO field_definitions (field_key, label, field_type, required, sort_order) VALUES ($1, $2, $3, $4, $5) RETURNING *', + [field_key, label.trim(), field_type, required || false, sort_order] + ); + res.status(201).json(rows[0]); + } catch (e) { + if (e.code === '23505') return res.status(409).json({ error: 'A field with a similar name already exists' }); + throw e; + } +}); + +app.put('/api/fields/reorder', authenticate, requireAdmin, async (req, res) => { + const { order } = req.body; + if (!Array.isArray(order)) return res.status(400).json({ error: 'order must be an array' }); + const client = await pool.connect(); + try { + await client.query('BEGIN'); + for (const { id, sort_order } of order) { + await client.query('UPDATE field_definitions SET sort_order = $1 WHERE id = $2', [sort_order, id]); + } + await client.query('COMMIT'); + res.json({ message: 'Reordered' }); + } catch (e) { + await client.query('ROLLBACK'); + throw e; + } finally { + client.release(); + } +}); + +app.put('/api/fields/:id', authenticate, requireAdmin, async (req, res) => { + const { label, field_type, required, sort_order } = req.body; + const { id } = req.params; + const updates = [], values = []; + let idx = 1; + if (label !== undefined) { + if (!label.trim()) return res.status(400).json({ error: 'Label cannot be empty' }); + updates.push(`label = $${idx++}`); values.push(label.trim()); + } + if (field_type !== undefined) { + if (!['text', 'number', 'phone', 'email'].includes(field_type)) return res.status(400).json({ error: 'Invalid field type' }); + updates.push(`field_type = $${idx++}`); values.push(field_type); + } + if (required !== undefined) { updates.push(`required = $${idx++}`); values.push(required); } + if (sort_order !== undefined) { updates.push(`sort_order = $${idx++}`); values.push(sort_order); } + if (!updates.length) return res.status(400).json({ error: 'Nothing to update' }); + updates.push(`updated_at = NOW()`); + values.push(id); + const { rows } = await pool.query( + `UPDATE field_definitions SET ${updates.join(', ')} WHERE id = $${idx} RETURNING *`, + values + ); + if (!rows.length) return res.status(404).json({ error: 'Field not found' }); + res.json(rows[0]); +}); + +app.delete('/api/fields/:id', authenticate, requireAdmin, async (req, res) => { + const { rows } = await pool.query('SELECT field_key FROM field_definitions WHERE id = $1', [req.params.id]); + if (!rows.length) return res.status(404).json({ error: 'Field not found' }); + await pool.query(`UPDATE mobile_contracts SET data = data - $1, updated_at = NOW()`, [rows[0].field_key]); + await pool.query('DELETE FROM field_definitions WHERE id = $1', [req.params.id]); + res.json({ message: 'Field deleted' }); +}); + +// --- Contract Routes --- +app.get('/api/contracts', authenticate, async (req, res) => { + const { search } = req.query; + let query, values = []; + if (search) { + query = `SELECT id, data, created_at, updated_at FROM mobile_contracts + WHERE EXISTS (SELECT 1 FROM jsonb_each_text(data) kv WHERE kv.value ILIKE $1) + ORDER BY created_at DESC`; + values = [`%${search}%`]; + } else { + query = 'SELECT id, data, created_at, updated_at FROM mobile_contracts ORDER BY created_at DESC'; + } + const { rows } = await pool.query(query, values); + res.json(rows); +}); + +app.post('/api/contracts', authenticate, requireAdmin, async (req, res) => { + const { data } = req.body; + if (!data || typeof data !== 'object') return res.status(400).json({ error: 'Data object required' }); + const { rows: fields } = await pool.query('SELECT field_key, label, required FROM field_definitions'); + const missing = fields.filter(f => f.required && !data[f.field_key]?.toString().trim()); + if (missing.length) return res.status(400).json({ error: `Required fields missing: ${missing.map(f => f.label).join(', ')}` }); + const { rows } = await pool.query('INSERT INTO mobile_contracts (data) VALUES ($1) RETURNING *', [JSON.stringify(data)]); + res.status(201).json(rows[0]); +}); + +app.put('/api/contracts/:id', authenticate, requireAdmin, async (req, res) => { + const { data } = req.body; + if (!data || typeof data !== 'object') return res.status(400).json({ error: 'Data object required' }); + const { rows: fields } = await pool.query('SELECT field_key, label, required FROM field_definitions'); + const missing = fields.filter(f => f.required && !data[f.field_key]?.toString().trim()); + if (missing.length) return res.status(400).json({ error: `Required fields missing: ${missing.map(f => f.label).join(', ')}` }); + const { rows } = await pool.query( + 'UPDATE mobile_contracts SET data = $1, updated_at = NOW() WHERE id = $2 RETURNING *', + [JSON.stringify(data), req.params.id] + ); + if (!rows.length) return res.status(404).json({ error: 'Contract not found' }); + res.json(rows[0]); +}); + +app.delete('/api/contracts/:id', authenticate, requireAdmin, async (req, res) => { + const { rowCount } = await pool.query('DELETE FROM mobile_contracts WHERE id = $1', [req.params.id]); + if (!rowCount) return res.status(404).json({ error: 'Contract not found' }); + res.json({ message: 'Contract deleted' }); +}); + +// --- Maintenance: Backup & Restore (Admin only) --- +app.get('/api/maintenance/backup', authenticate, requireAdmin, async (req, res) => { + const { rows: fields } = await pool.query('SELECT * FROM field_definitions ORDER BY sort_order ASC, id ASC'); + const { rows: contracts } = await pool.query('SELECT id, data, created_at, updated_at FROM mobile_contracts ORDER BY created_at ASC'); + + const backup = { + version: '1.0', + exported_at: new Date().toISOString(), + exported_by: req.user.username, + fields, + contracts, + }; + + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Content-Disposition', `attachment; filename="backup-${new Date().toISOString().split('T')[0]}.json"`); + res.json(backup); +}); + +app.post('/api/maintenance/restore', authenticate, requireAdmin, upload.single('backup'), async (req, res) => { + if (!req.file) return res.status(400).json({ error: 'No backup file uploaded' }); + + let backup; + try { + backup = JSON.parse(req.file.buffer.toString('utf8')); + } catch { + return res.status(400).json({ error: 'Invalid JSON file' }); + } + + if (!backup.fields || !backup.contracts) { + return res.status(400).json({ error: 'Invalid backup format: missing fields or contracts' }); + } + + const mode = req.body.mode || 'merge'; // 'merge' | 'replace' + + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + if (mode === 'replace') { + await client.query('DELETE FROM mobile_contracts'); + await client.query('DELETE FROM field_definitions'); + } + + // Restore field definitions + for (const f of backup.fields) { + await client.query(` + INSERT INTO field_definitions (field_key, label, field_type, required, sort_order, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (field_key) DO UPDATE SET + label = EXCLUDED.label, + field_type = EXCLUDED.field_type, + required = EXCLUDED.required, + sort_order = EXCLUDED.sort_order, + updated_at = NOW() + `, [f.field_key, f.label, f.field_type, f.required, f.sort_order, f.created_at, f.updated_at]); + } + + // Restore contracts + let imported = 0; + for (const c of backup.contracts) { + await client.query( + 'INSERT INTO mobile_contracts (data, created_at, updated_at) VALUES ($1, $2, $3)', + [JSON.stringify(c.data), c.created_at, c.updated_at] + ); + imported++; + } + + await client.query('COMMIT'); + res.json({ + message: `Restore complete`, + fields_restored: backup.fields.length, + contracts_imported: imported, + mode + }); + } catch (err) { + await client.query('ROLLBACK'); + console.error('Restore failed:', err); + res.status(500).json({ error: 'Restore failed: ' + err.message }); + } finally { + client.release(); + } +}); + +app.get('/api/health', (req, res) => res.json({ status: 'ok' })); + +// Express v5: error handler (4-arg signature still works, but async errors now auto-propagate) +app.use((err, req, res, next) => { + console.error(err.stack); + res.status(500).json({ error: 'Internal server error' }); +}); + +const PORT = 3001; +app.listen(PORT, () => console.log(`Backend running on port ${PORT}`)); diff --git a/db-init/01-init.sql b/db-init/01-init.sql new file mode 100644 index 0000000..b69f41a --- /dev/null +++ b/db-init/01-init.sql @@ -0,0 +1,51 @@ +-- Users table +CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY, + username VARCHAR(100) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + role VARCHAR(20) NOT NULL DEFAULT 'user' CHECK (role IN ('admin', 'user')), + mfa_secret VARCHAR(255), + mfa_enabled BOOLEAN DEFAULT FALSE, + must_change_password BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Field definitions table (dynamic fields configured by admin) +CREATE TABLE IF NOT EXISTS field_definitions ( + id SERIAL PRIMARY KEY, + field_key VARCHAR(100) UNIQUE NOT NULL, -- internal key e.g. "first_name" + label VARCHAR(200) NOT NULL, -- display label e.g. "First Name" + field_type VARCHAR(20) NOT NULL DEFAULT 'text' CHECK (field_type IN ('text', 'number', 'phone', 'email')), + required BOOLEAN DEFAULT FALSE, + sort_order INTEGER DEFAULT 0, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Contracts table with JSONB data (fully dynamic) +CREATE TABLE IF NOT EXISTS mobile_contracts ( + id SERIAL PRIMARY KEY, + data JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Audit log table +CREATE TABLE IF NOT EXISTS audit_log ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id), + action VARCHAR(100) NOT NULL, + details TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Seed default fields +INSERT INTO field_definitions (field_key, label, field_type, required, sort_order) VALUES + ('first_name', 'First Name', 'text', TRUE, 1), + ('last_name', 'Last Name', 'text', TRUE, 2), + ('phone_number', 'Phone Number', 'phone', FALSE, 3), + ('mobile_contract_number', 'Mobile Contract Number', 'text', FALSE, 4), + ('monthly_fixed_cost', 'Monthly Fixed Cost', 'number', FALSE, 5), + ('monthly_available_data', 'Monthly Available Data', 'number', FALSE, 6) +ON CONFLICT (field_key) DO NOTHING; diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c115be3 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,51 @@ +version: '3.9' + +services: + db: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + volumes: + - postgres_data:/var/lib/postgresql/data + - ./db-init:/docker-entrypoint-initdb.d + networks: + - app-network + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] + interval: 10s + timeout: 5s + retries: 5 + + backend: + build: ./backend + restart: unless-stopped + environment: + DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} + JWT_SECRET: ${JWT_SECRET} + JWT_REFRESH_SECRET: ${JWT_REFRESH_SECRET} + ENCRYPTION_KEY: ${ENCRYPTION_KEY} + depends_on: + db: + condition: service_healthy + networks: + - app-network + + frontend: + build: ./frontend + restart: unless-stopped + ports: + - "${APP_HOST:-0.0.0.0}:${APP_PORT:-80}:80" + depends_on: + - backend + networks: + - app-network + +volumes: + postgres_data: + +networks: + app-network: + driver: bridge diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..2bd5027 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,15 @@ +FROM node:20-alpine AS builder + +WORKDIR /app + +COPY package*.json ./ +RUN npm install + +COPY . . +RUN npm run build + +FROM nginx:alpine +COPY --from=builder /app/dist /usr/share/nginx/html +COPY nginx-frontend.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..c2af69e --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + Mobile Contract Manager + + +
+ + + diff --git a/frontend/nginx-frontend.conf b/frontend/nginx-frontend.conf new file mode 100644 index 0000000..df4d2e0 --- /dev/null +++ b/frontend/nginx-frontend.conf @@ -0,0 +1,19 @@ +server { + listen 80; + root /usr/share/nginx/html; + index index.html; + + # Proxy API requests to the backend + location /api/ { + proxy_pass http://backend:3001; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_read_timeout 30s; + } + + # Serve React SPA + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..4f023cf --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,26 @@ +{ + "name": "mobile-mgmt-frontend", + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^7.13.0", + "axios": "^1.13.5", + "lucide-react": "^0.575.0" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^5.1.4", + "vite": "^7.3.1", + "tailwindcss": "^3.4.11", + "autoprefixer": "^10.4.21", + "postcss": "^8.5.4" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2380945 --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,3 @@ +export default { + plugins: { tailwindcss: {}, autoprefixer: {} } +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..f8bead3 --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,45 @@ +import React from 'react' +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' +import { AuthProvider, useAuth } from './AuthContext.jsx' +import Login from './pages/Login.jsx' +import Dashboard from './pages/Dashboard.jsx' +import Admin from './pages/Admin.jsx' +import Profile from './pages/Profile.jsx' +import Fields from './pages/Fields.jsx' +import Maintenance from './pages/Maintenance.jsx' +import Layout from './components/Layout.jsx' +import ChangePassword from './pages/ChangePassword.jsx' + +function ProtectedRoute({ children, adminOnly = false }) { + const { user } = useAuth() + if (!user) return + if (user.must_change_password) return + if (adminOnly && user.role !== 'admin') return + return children +} + +function AppRoutes() { + const { user } = useAuth() + return ( + + : } /> + : } /> + } /> + } /> + } /> + } /> + } /> + } /> + + ) +} + +export default function App() { + return ( + + + + + + ) +} diff --git a/frontend/src/AuthContext.jsx b/frontend/src/AuthContext.jsx new file mode 100644 index 0000000..724d594 --- /dev/null +++ b/frontend/src/AuthContext.jsx @@ -0,0 +1,38 @@ +import React, { createContext, useContext, useState, useEffect } from 'react' + +const AuthContext = createContext(null) + +export function AuthProvider({ children }) { + const [user, setUser] = useState(() => { + try { return JSON.parse(localStorage.getItem('user')) } catch { return null } + }) + const [token, setToken] = useState(() => localStorage.getItem('token')) + + const login = (userData, tokenData) => { + setUser(userData) + setToken(tokenData) + localStorage.setItem('user', JSON.stringify(userData)) + localStorage.setItem('token', tokenData) + } + + const logout = () => { + setUser(null) + setToken(null) + localStorage.removeItem('user') + localStorage.removeItem('token') + } + + const updateUser = (updates) => { + const updated = { ...user, ...updates } + setUser(updated) + localStorage.setItem('user', JSON.stringify(updated)) + } + + return ( + + {children} + + ) +} + +export const useAuth = () => useContext(AuthContext) diff --git a/frontend/src/api.js b/frontend/src/api.js new file mode 100644 index 0000000..12a6bfe --- /dev/null +++ b/frontend/src/api.js @@ -0,0 +1,23 @@ +import axios from 'axios' + +const api = axios.create({ baseURL: '/api' }) + +api.interceptors.request.use(config => { + const token = localStorage.getItem('token') + if (token) config.headers.Authorization = `Bearer ${token}` + return config +}) + +api.interceptors.response.use( + res => res, + err => { + if (err.response?.status === 401) { + localStorage.removeItem('token') + localStorage.removeItem('user') + window.location.href = '/login' + } + return Promise.reject(err) + } +) + +export default api diff --git a/frontend/src/components/Layout.jsx b/frontend/src/components/Layout.jsx new file mode 100644 index 0000000..af380e9 --- /dev/null +++ b/frontend/src/components/Layout.jsx @@ -0,0 +1,118 @@ +import React, { useState } from 'react' +import { Link, useLocation } from 'react-router-dom' +import { useAuth } from '../AuthContext.jsx' +import { LayoutDashboard, Settings, User, LogOut, Menu, X, Smartphone, Columns, Wrench } from 'lucide-react' + +export default function Layout({ children }) { + const { user, logout, isAdmin } = useAuth() + const location = useLocation() + const [mobileOpen, setMobileOpen] = useState(false) + + const navItems = [ + { path: '/', icon: LayoutDashboard, label: 'Dashboard' }, + ...(isAdmin ? [ + { path: '/admin', icon: Settings, label: 'Admin Panel' }, + ] : []), + { path: '/profile', icon: User, label: 'Profile' }, + ...(isAdmin ? [ + { path: '/fields', icon: Columns, label: 'Fields' }, + { path: '/maintenance', icon: Wrench, label: 'Maintenance' }, + ] : []), + ] + + const NavLinks = () => ( + <> + {navItems.map(({ path, icon: Icon, label }) => ( + setMobileOpen(false)} + className={`flex items-center gap-3 px-4 py-3 rounded-xl transition-all ${ + location.pathname === path + ? 'bg-indigo-600 text-white shadow-lg shadow-indigo-900/40' + : 'text-slate-400 hover:text-white hover:bg-slate-700/60' + }`} + > + + {label} + + ))} + + ) + + return ( +
+ {/* Sidebar desktop */} + + + {/* Mobile sidebar */} + {mobileOpen && ( +
+
setMobileOpen(false)} /> + +
+ )} + + {/* Main content */} +
+
+ +

Mobile Contract Manager

+
+
+
{children}
+
+
+ ) +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..49a1002 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,9 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +body { + background-color: #0f172a; + color: #e2e8f0; + font-family: 'Inter', system-ui, sans-serif; +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx new file mode 100644 index 0000000..5e8d112 --- /dev/null +++ b/frontend/src/main.jsx @@ -0,0 +1,10 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App.jsx' +import './index.css' + +ReactDOM.createRoot(document.getElementById('root')).render( + + + +) diff --git a/frontend/src/pages/Admin.jsx b/frontend/src/pages/Admin.jsx new file mode 100644 index 0000000..1723249 --- /dev/null +++ b/frontend/src/pages/Admin.jsx @@ -0,0 +1,267 @@ +import React, { useState, useEffect } from 'react' +import api from '../api.js' +import { useAuth } from '../AuthContext.jsx' +import { Plus, Trash2, Edit2, X, Check, Users, Shield, User, KeyRound } from 'lucide-react' + +function UserModal({ user, onClose, onSave }) { + const [form, setForm] = useState({ + username: user?.username || '', + password: '', + role: user?.role || 'user', + }) + const [error, setError] = useState('') + const [loading, setLoading] = useState(false) + + const handleSubmit = async (e) => { + e.preventDefault() + setError('') + setLoading(true) + try { + if (user) { + const payload = { role: form.role } + if (form.username !== user.username) payload.username = form.username + if (form.password) payload.password = form.password + const { data } = await api.put(`/users/${user.id}`, payload) + onSave(data, 'edit') + } else { + if (!form.password) return setError('Password is required') + const { data } = await api.post('/users', form) + onSave(data, 'add') + } + onClose() + } catch (err) { + setError(err.response?.data?.error || 'Failed to save user') + } finally { + setLoading(false) + } + } + + return ( +
+
+
+

{user ? 'Edit User' : 'New User'}

+ +
+
+ {error &&
{error}
} +
+ + setForm({ ...form, username: e.target.value })} + required + className="w-full bg-slate-800 border border-slate-700 rounded-xl px-4 py-3 text-white focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 transition" + placeholder="Username" + /> +
+
+ + setForm({ ...form, password: e.target.value })} + required={!user} + className="w-full bg-slate-800 border border-slate-700 rounded-xl px-4 py-3 text-white focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 transition" + placeholder={user ? '••••••••' : 'Min. 6 characters'} + /> +
+
+ +
+ {['user', 'admin'].map(role => ( + + ))} +
+
+
+ + +
+
+
+
+ ) +} + +export default function Admin() { + const { user: currentUser } = useAuth() + const [users, setUsers] = useState([]) + const [loading, setLoading] = useState(true) + const [modal, setModal] = useState(null) + const [deleteConfirm, setDeleteConfirm] = useState(null) + const [toast, setToast] = useState('') + + useEffect(() => { + api.get('/users').then(({ data }) => { setUsers(data); setLoading(false) }).catch(() => setLoading(false)) + }, []) + + const showToast = (msg) => { setToast(msg); setTimeout(() => setToast(''), 3000) } + + const handleSave = (data, type) => { + if (type === 'add') setUsers(prev => [data, ...prev]) + else setUsers(prev => prev.map(u => u.id === data.id ? data : u)) + showToast(type === 'add' ? 'User created' : 'User updated') + } + + const handleDelete = async (id) => { + try { + await api.delete(`/users/${id}`) + setUsers(prev => prev.filter(u => u.id !== id)) + setDeleteConfirm(null) + showToast('User deleted') + } catch (err) { + showToast(err.response?.data?.error || 'Failed to delete') + } + } + + const adminCount = users.filter(u => u.role === 'admin').length + const userCount = users.filter(u => u.role === 'user').length + + return ( +
+ {toast && ( +
+ {toast} +
+ )} + +
+
+

Admin Panel

+

Manage user accounts and permissions

+
+ +
+ + {/* Stats */} +
+ {[ + { label: 'Total Users', value: users.length, icon: Users, color: 'indigo' }, + { label: 'Admins', value: adminCount, icon: Shield, color: 'amber' }, + { label: 'Regular Users', value: userCount, icon: User, color: 'blue' }, + ].map(({ label, value, icon: Icon, color }) => ( +
+
+ +
+
+

{value}

+

{label}

+
+
+ ))} +
+ + {/* Users table */} +
+ {loading ? ( +
Loading...
+ ) : ( + + + + + + + + + + + + + {users.map(u => ( + + + + + + + + + ))} + +
UsernameRoleMFAStatusCreatedActions
+
+
+ {u.role === 'admin' ? : } +
+ {u.username} + {u.id === currentUser.id && (you)} +
+
+ + {u.role} + + + + {u.mfa_enabled ? 'Enabled' : 'Disabled'} + + + {u.must_change_password ? ( + + Must change pw + + ) : ( + Active + )} + {new Date(u.created_at).toLocaleDateString()} +
+ + {u.id !== currentUser.id && ( + + )} +
+
+ )} +
+ + {modal && ( + setModal(null)} + onSave={handleSave} + /> + )} + + {deleteConfirm && ( +
+
+

Delete User

+

Delete {deleteConfirm.username}? This cannot be undone.

+
+ + +
+
+
+ )} +
+ ) +} diff --git a/frontend/src/pages/ChangePassword.jsx b/frontend/src/pages/ChangePassword.jsx new file mode 100644 index 0000000..f177101 --- /dev/null +++ b/frontend/src/pages/ChangePassword.jsx @@ -0,0 +1,104 @@ +import React, { useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { useAuth } from '../AuthContext.jsx' +import api from '../api.js' +import { KeyRound, Eye, EyeOff } from 'lucide-react' + +export default function ChangePassword() { + const { user, updateUser, logout } = useAuth() + const navigate = useNavigate() + const [form, setForm] = useState({ new_password: '', confirm: '' }) + const [showPw, setShowPw] = useState(false) + const [error, setError] = useState('') + const [loading, setLoading] = useState(false) + + const isForced = user?.must_change_password + + const handleSubmit = async (e) => { + e.preventDefault() + setError('') + if (form.new_password !== form.confirm) return setError('Passwords do not match') + if (form.new_password.length < 6) return setError('Password must be at least 6 characters') + setLoading(true) + try { + await api.post('/auth/change-password', { new_password: form.new_password }) + updateUser({ must_change_password: false }) + navigate('/') + } catch (err) { + setError(err.response?.data?.error || 'Failed to change password') + } finally { + setLoading(false) + } + } + + return ( +
+
+
+
+
+ +
+
+

+ {isForced ? 'Change Required' : 'Change Password'} +

+ {isForced &&

You must set a new password before continuing.

} +
+
+ + {error && ( +
{error}
+ )} + +
+
+ +
+ setForm({ ...form, new_password: e.target.value })} + className="w-full bg-slate-800 border border-slate-700 rounded-xl px-4 py-3 pr-12 text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 transition" + placeholder="Min. 6 characters" + required + /> + +
+
+
+ + setForm({ ...form, confirm: e.target.value })} + className="w-full bg-slate-800 border border-slate-700 rounded-xl px-4 py-3 text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 transition" + placeholder="Repeat password" + required + /> +
+ + {!isForced && ( + + )} + {isForced && ( + + )} +
+
+
+
+ ) +} diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx new file mode 100644 index 0000000..cdd7d85 --- /dev/null +++ b/frontend/src/pages/Dashboard.jsx @@ -0,0 +1,278 @@ +import React, { useState, useEffect, useCallback } from 'react' +import { useAuth } from '../AuthContext.jsx' +import api from '../api.js' +import { Search, Plus, Edit2, Trash2, X, Check, Database, ChevronUp, ChevronDown } from 'lucide-react' + +function ContractModal({ contract, fields, onClose, onSave }) { + const emptyForm = () => fields.reduce((acc, f) => ({ ...acc, [f.field_key]: '' }), {}) + const [form, setForm] = useState(contract ? { ...emptyForm(), ...contract.data } : emptyForm()) + const [error, setError] = useState('') + const [loading, setLoading] = useState(false) + + const handleSubmit = async (e) => { + e.preventDefault() + setError('') + setLoading(true) + try { + if (contract) { + const { data } = await api.put(`/contracts/${contract.id}`, { data: form }) + onSave(data, 'edit') + } else { + const { data } = await api.post('/contracts', { data: form }) + onSave(data, 'add') + } + onClose() + } catch (err) { + setError(err.response?.data?.error || 'Failed to save') + } finally { + setLoading(false) + } + } + + const inputType = (type) => ({ number: 'number', phone: 'tel', email: 'email', text: 'text' }[type] || 'text') + + return ( +
+
+
+

{contract ? 'Edit Contract' : 'New Contract'}

+ +
+
+ {error &&
{error}
} + {fields.length === 0 ? ( +

No fields defined. Go to Fields to add some.

+ ) : ( +
+ {fields.map(f => ( +
+ + setForm({ ...form, [f.field_key]: e.target.value })} + required={f.required} + className="w-full bg-slate-800 border border-slate-700 rounded-xl px-3 py-2.5 text-white text-sm placeholder-slate-500 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 transition" + placeholder={f.label} + /> +
+ ))} +
+ )} +
+
+ + +
+
+
+ ) +} + +export default function Dashboard() { + const { isAdmin } = useAuth() + const [contracts, setContracts] = useState([]) + const [fields, setFields] = useState([]) + const [search, setSearch] = useState('') + const [loading, setLoading] = useState(true) + const [modal, setModal] = useState(null) + const [deleteConfirm, setDeleteConfirm] = useState(null) + const [sortField, setSortField] = useState(null) + const [sortDir, setSortDir] = useState('asc') + const [toast, setToast] = useState('') + + useEffect(() => { + api.get('/fields').then(({ data }) => setFields(data)).catch(() => {}) + }, []) + + const fetchContracts = useCallback(async () => { + try { + const { data } = await api.get('/contracts', { params: search ? { search } : {} }) + setContracts(data) + } catch {} + setLoading(false) + }, [search]) + + useEffect(() => { + const t = setTimeout(fetchContracts, 300) + return () => clearTimeout(t) + }, [fetchContracts]) + + const showToast = (msg) => { setToast(msg); setTimeout(() => setToast(''), 3000) } + + const handleSave = (data, type) => { + if (type === 'add') setContracts(prev => [data, ...prev]) + else setContracts(prev => prev.map(c => c.id === data.id ? data : c)) + showToast(type === 'add' ? 'Contract added' : 'Contract updated') + } + + const handleDelete = async (id) => { + try { + await api.delete(`/contracts/${id}`) + setContracts(prev => prev.filter(c => c.id !== id)) + setDeleteConfirm(null) + showToast('Contract deleted') + } catch { showToast('Failed to delete') } + } + + const handleSort = (key) => { + if (sortField === key) setSortDir(d => d === 'asc' ? 'desc' : 'asc') + else { setSortField(key); setSortDir('asc') } + } + + const sorted = [...contracts].sort((a, b) => { + if (!sortField) return 0 + const av = a.data?.[sortField] ?? '' + const bv = b.data?.[sortField] ?? '' + return sortDir === 'asc' ? (av > bv ? 1 : -1) : (av < bv ? 1 : -1) + }) + + const SortIcon = ({ fieldKey }) => { + if (sortField !== fieldKey) return + return sortDir === 'asc' + ? + : + } + + return ( +
+ {toast && ( +
+ {toast} +
+ )} + +
+
+

Mobile Contracts

+

{contracts.length} record{contracts.length !== 1 ? 's' : ''}

+
+
+
+ + setSearch(e.target.value)} + placeholder="Search all fields..." + className="bg-slate-800 border border-slate-700 rounded-xl pl-9 pr-4 py-2.5 text-sm text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 w-60 transition" + /> + {search && ( + + )} +
+ {isAdmin && ( + + )} +
+
+ +
+ {loading ? ( +
Loading...
+ ) : fields.length === 0 ? ( +
+ +

No fields defined yet.

+ {isAdmin &&

Go to Fields in the sidebar to add some.

} +
+ ) : sorted.length === 0 ? ( +
+ +

{search ? 'No results found' : 'No contracts yet'}

+ {isAdmin && !search && ( + + )} +
+ ) : ( +
+ + + + {fields.map(f => ( + + ))} + {isAdmin && } + + + + {sorted.map((c, i) => ( + + {fields.map(f => ( + + ))} + {isAdmin && ( + + )} + + ))} + +
handleSort(f.field_key)} + className="text-left px-4 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider cursor-pointer hover:text-white transition whitespace-nowrap select-none" + > + {f.label} + Actions
+ {c.data?.[f.field_key] != null && c.data?.[f.field_key] !== '' + ? String(c.data[f.field_key]) + : + } + +
+ + +
+
+
+ )} +
+ + {modal && ( + setModal(null)} + onSave={handleSave} + /> + )} + + {deleteConfirm && ( +
+
+

Confirm Delete

+

This contract will be permanently deleted.

+
+ + +
+
+
+ )} +
+ ) +} diff --git a/frontend/src/pages/Fields.jsx b/frontend/src/pages/Fields.jsx new file mode 100644 index 0000000..b20388e --- /dev/null +++ b/frontend/src/pages/Fields.jsx @@ -0,0 +1,307 @@ +import React, { useState, useEffect } from 'react' +import api from '../api.js' +import { + Plus, Trash2, Edit2, X, Check, GripVertical, + Type, Hash, Phone, Mail, AlertTriangle, Columns +} from 'lucide-react' + +const TYPE_OPTIONS = [ + { value: 'text', label: 'Text', icon: Type }, + { value: 'number', label: 'Number', icon: Hash }, + { value: 'phone', label: 'Phone', icon: Phone }, + { value: 'email', label: 'Email', icon: Mail }, +] + +const TypeIcon = ({ type, size = 14 }) => { + const opt = TYPE_OPTIONS.find(t => t.value === type) + const Icon = opt?.icon || Type + return +} + +function FieldModal({ field, onClose, onSave }) { + const [form, setForm] = useState({ + label: field?.label || '', + field_type: field?.field_type || 'text', + required: field?.required || false, + }) + const [error, setError] = useState('') + const [loading, setLoading] = useState(false) + + const handleSubmit = async (e) => { + e.preventDefault() + setError('') + if (!form.label.trim()) return setError('Label is required') + setLoading(true) + try { + if (field) { + const { data } = await api.put(`/fields/${field.id}`, form) + onSave(data, 'edit') + } else { + const { data } = await api.post('/fields', form) + onSave(data, 'add') + } + onClose() + } catch (err) { + setError(err.response?.data?.error || 'Failed to save field') + } finally { + setLoading(false) + } + } + + return ( +
+
+
+

{field ? 'Edit Field' : 'New Field'}

+ +
+
+ {error &&
{error}
} + +
+ + setForm({ ...form, label: e.target.value })} + autoFocus + className="w-full bg-slate-800 border border-slate-700 rounded-xl px-4 py-3 text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 transition" + placeholder="e.g. First Name, Contract ID..." + /> + {!field && form.label && ( +

+ Key: {form.label.trim().toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '')} +

+ )} +
+ +
+ +
+ {TYPE_OPTIONS.map(({ value, label, icon: Icon }) => ( + + ))} +
+
+ +
+
+
+ ) +} + +export default function Fields() { + const [fields, setFields] = useState([]) + const [loading, setLoading] = useState(true) + const [modal, setModal] = useState(null) // null | 'add' | field object + const [deleteConfirm, setDeleteConfirm] = useState(null) + const [toast, setToast] = useState('') + const [dragIdx, setDragIdx] = useState(null) + const [dragOverIdx, setDragOverIdx] = useState(null) + + useEffect(() => { + api.get('/fields').then(({ data }) => { setFields(data); setLoading(false) }).catch(() => setLoading(false)) + }, []) + + const showToast = (msg) => { setToast(msg); setTimeout(() => setToast(''), 3000) } + + const handleSave = (data, type) => { + if (type === 'add') setFields(prev => [...prev, data]) + else setFields(prev => prev.map(f => f.id === data.id ? data : f)) + showToast(type === 'add' ? 'Field added' : 'Field updated') + } + + const handleDelete = async (field) => { + try { + await api.delete(`/fields/${field.id}`) + setFields(prev => prev.filter(f => f.id !== field.id)) + setDeleteConfirm(null) + showToast('Field deleted') + } catch (err) { + showToast(err.response?.data?.error || 'Failed to delete') + } + } + + // Drag-to-reorder + const handleDragStart = (idx) => setDragIdx(idx) + const handleDragOver = (e, idx) => { e.preventDefault(); setDragOverIdx(idx) } + const handleDrop = async (idx) => { + if (dragIdx === null || dragIdx === idx) { setDragIdx(null); setDragOverIdx(null); return } + const reordered = [...fields] + const [moved] = reordered.splice(dragIdx, 1) + reordered.splice(idx, 0, moved) + const withOrder = reordered.map((f, i) => ({ ...f, sort_order: i + 1 })) + setFields(withOrder) + setDragIdx(null) + setDragOverIdx(null) + try { + await api.put('/fields/reorder', { order: withOrder.map(f => ({ id: f.id, sort_order: f.sort_order })) }) + } catch { showToast('Failed to save order') } + } + + return ( +
+ {toast && ( +
+ {toast} +
+ )} + +
+
+

Fields

+

Manage the columns shown in the Dashboard

+
+ +
+ + {/* Info banner */} +
+ +

+ Fields define the columns in the Dashboard. You can drag rows to reorder them. Deleting a field removes its data from all existing contracts. +

+
+ +
+ {loading ? ( +
Loading...
+ ) : fields.length === 0 ? ( +
+ +

No fields defined yet

+ +
+ ) : ( + + + + + + + + + + + + + {fields.map((f, idx) => ( + handleDragStart(idx)} + onDragOver={(e) => handleDragOver(e, idx)} + onDrop={() => handleDrop(idx)} + onDragEnd={() => { setDragIdx(null); setDragOverIdx(null) }} + className={`border-b border-slate-800/50 transition ${ + dragOverIdx === idx && dragIdx !== idx ? 'bg-indigo-500/10' : 'hover:bg-slate-800/30' + } ${dragIdx === idx ? 'opacity-40' : ''}`} + > + + + + + + + + ))} + +
LabelKeyTypeRequiredActions
+ + + {f.label} + + {f.field_key} + +
+ + {f.field_type} +
+
+ {f.required + ? Required + : Optional + } + +
+ + +
+
+ )} +
+ + {modal && ( + setModal(null)} + onSave={handleSave} + /> + )} + + {deleteConfirm && ( +
+
+
+
+ +
+
+

Delete Field

+

+ Delete "{deleteConfirm.label}"? This will permanently remove this column and all its data from every contract. +

+
+
+
+ + +
+
+
+ )} +
+ ) +} diff --git a/frontend/src/pages/Login.jsx b/frontend/src/pages/Login.jsx new file mode 100644 index 0000000..e6575cd --- /dev/null +++ b/frontend/src/pages/Login.jsx @@ -0,0 +1,140 @@ +import React, { useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { useAuth } from '../AuthContext.jsx' +import api from '../api.js' +import { Smartphone, Eye, EyeOff, Shield } from 'lucide-react' + +export default function Login() { + const [form, setForm] = useState({ username: '', password: '', mfa_code: '' }) + const [step, setStep] = useState('credentials') // 'credentials' | 'mfa' + const [showPw, setShowPw] = useState(false) + const [error, setError] = useState('') + const [loading, setLoading] = useState(false) + const { login } = useAuth() + const navigate = useNavigate() + + const handleSubmit = async (e) => { + e.preventDefault() + setError('') + setLoading(true) + try { + const payload = { username: form.username, password: form.password } + if (step === 'mfa') payload.mfa_code = form.mfa_code + + const { data } = await api.post('/auth/login', payload) + + if (data.mfa_required) { + setStep('mfa') + setLoading(false) + return + } + + login(data.user, data.token) + if (data.user.must_change_password) { + navigate('/change-password') + } else { + navigate('/') + } + } catch (err) { + setError(err.response?.data?.error || 'Login failed') + } finally { + setLoading(false) + } + } + + return ( +
+
+
+
+ +
+

Mobile Contract

+

Manager

+
+ +
+

+ {step === 'mfa' ? 'Two-Factor Authentication' : 'Sign In'} +

+ + {error && ( +
+ {error} +
+ )} + +
+ {step === 'credentials' ? ( + <> +
+ + setForm({ ...form, username: e.target.value })} + className="w-full bg-slate-800 border border-slate-700 rounded-xl px-4 py-3 text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 transition" + placeholder="Enter your username" + required + /> +
+
+ +
+ setForm({ ...form, password: e.target.value })} + className="w-full bg-slate-800 border border-slate-700 rounded-xl px-4 py-3 pr-12 text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 transition" + placeholder="Enter your password" + required + /> + +
+
+ + ) : ( +
+
+ +

Enter the 6-digit code from your authenticator app.

+
+ + setForm({ ...form, mfa_code: e.target.value })} + className="w-full bg-slate-800 border border-slate-700 rounded-xl px-4 py-3 text-white text-center text-2xl tracking-widest placeholder-slate-500 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 transition" + placeholder="000000" + required + autoFocus + /> +
+ )} + + + + {step === 'mfa' && ( + + )} +
+
+
+
+ ) +} diff --git a/frontend/src/pages/Maintenance.jsx b/frontend/src/pages/Maintenance.jsx new file mode 100644 index 0000000..f76aa25 --- /dev/null +++ b/frontend/src/pages/Maintenance.jsx @@ -0,0 +1,317 @@ +import React, { useState, useRef } from 'react' +import api from '../api.js' +import { + Download, Upload, Database, AlertTriangle, Check, + RefreshCw, FileJson, ShieldAlert, Info +} from 'lucide-react' + +export default function Maintenance() { + const [exporting, setExporting] = useState(false) + const [importing, setImporting] = useState(false) + const [importMode, setImportMode] = useState('merge') + const [selectedFile, setSelectedFile] = useState(null) + const [previewData, setPreviewData] = useState(null) + const [previewError, setPreviewError] = useState('') + const [result, setResult] = useState(null) + const [error, setError] = useState('') + const fileRef = useRef() + + // --- Export --- + const handleExport = async () => { + setExporting(true) + setError('') + try { + const { data } = await api.get('/maintenance/backup', { responseType: 'blob' }) + const url = URL.createObjectURL(new Blob([data], { type: 'application/json' })) + const a = document.createElement('a') + a.href = url + a.download = `backup-${new Date().toISOString().split('T')[0]}.json` + a.click() + URL.revokeObjectURL(url) + } catch { + setError('Export failed. Please try again.') + } finally { + setExporting(false) + } + } + + // --- File selection & preview --- + const handleFileChange = (e) => { + const file = e.target.files[0] + setSelectedFile(file || null) + setPreviewData(null) + setPreviewError('') + setResult(null) + setError('') + + if (!file) return + + const reader = new FileReader() + reader.onload = (ev) => { + try { + const parsed = JSON.parse(ev.target.result) + if (!parsed.fields || !parsed.contracts) { + setPreviewError('Invalid backup file: missing fields or contracts data.') + return + } + setPreviewData(parsed) + } catch { + setPreviewError('Could not parse file — make sure it is a valid JSON backup.') + } + } + reader.readAsText(file) + } + + // --- Import --- + const handleImport = async () => { + if (!selectedFile || !previewData) return + setImporting(true) + setError('') + setResult(null) + + const formData = new FormData() + formData.append('backup', selectedFile) + formData.append('mode', importMode) + + try { + const { data } = await api.post('/maintenance/restore', formData, { + headers: { 'Content-Type': 'multipart/form-data' } + }) + setResult(data) + setSelectedFile(null) + setPreviewData(null) + if (fileRef.current) fileRef.current.value = '' + } catch (err) { + setError(err.response?.data?.error || 'Import failed. Please try again.') + } finally { + setImporting(false) + } + } + + const clearFile = () => { + setSelectedFile(null) + setPreviewData(null) + setPreviewError('') + setResult(null) + setError('') + if (fileRef.current) fileRef.current.value = '' + } + + return ( +
+
+

Maintenance

+

Export and import backups of your contracts and field definitions

+
+ + {/* Success result */} + {result && ( +
+ +
+

Restore completed successfully

+

+ {result.fields_restored} field{result.fields_restored !== 1 ? 's' : ''} restored · {result.contracts_imported} contract{result.contracts_imported !== 1 ? 's' : ''} imported · Mode: {result.mode} +

+
+
+ )} + + {/* Global error */} + {error && ( +
+ +

{error}

+
+ )} + + {/* Export section */} +
+
+
+ +
+
+

Export Backup

+

Download a full JSON backup of all contracts and field definitions

+
+
+ +
+ +

+ The backup file includes all field definitions and every contract record. It does not include user accounts or passwords. +

+
+ + +
+ + {/* Import section */} +
+
+
+ +
+
+

Import Backup

+

Restore data from a previously exported backup file

+
+
+ + {/* Import mode */} +
+ +
+ + +
+
+ + {/* Replace warning */} + {importMode === 'replace' && ( +
+ +

+ Warning: Replace mode will permanently delete ALL existing contracts and field definitions before importing. This action cannot be undone. +

+
+ )} + + {/* File upload */} +
+ +
fileRef.current?.click()} + className={`border-2 border-dashed rounded-xl p-6 text-center cursor-pointer transition ${ + selectedFile + ? 'border-indigo-500/50 bg-indigo-500/5' + : 'border-slate-700 hover:border-slate-600 hover:bg-slate-800/30' + }`} + > + + {selectedFile ? ( +
+ +
+

{selectedFile.name}

+

{(selectedFile.size / 1024).toFixed(1)} KB

+
+
+ ) : ( +
+ +

Click to select a .json backup file

+
+ )} +
+ + {previewError && ( +
+ +

{previewError}

+
+ )} +
+ + {/* Preview */} + {previewData && ( +
+

+ Backup Preview +

+
+ {[ + { label: 'Exported', value: previewData.exported_at ? new Date(previewData.exported_at).toLocaleString() : 'Unknown' }, + { label: 'Exported by', value: previewData.exported_by || 'Unknown' }, + { label: 'Fields', value: `${previewData.fields.length} field definition${previewData.fields.length !== 1 ? 's' : ''}` }, + { label: 'Contracts', value: `${previewData.contracts.length} record${previewData.contracts.length !== 1 ? 's' : ''}` }, + ].map(({ label, value }) => ( +
+

{label}

+

{value}

+
+ ))} +
+ + {previewData.fields.length > 0 && ( +
+

Fields in backup:

+
+ {previewData.fields.map(f => ( + + {f.label} + + ))} +
+
+ )} +
+ )} + +
+ {selectedFile && ( + + )} + +
+
+
+ ) +} diff --git a/frontend/src/pages/Profile.jsx b/frontend/src/pages/Profile.jsx new file mode 100644 index 0000000..83b8f09 --- /dev/null +++ b/frontend/src/pages/Profile.jsx @@ -0,0 +1,214 @@ +import React, { useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { useAuth } from '../AuthContext.jsx' +import api from '../api.js' +import { Shield, KeyRound, Check, User, Eye, EyeOff, QrCode, ShieldOff } from 'lucide-react' + +export default function Profile() { + const { user, updateUser } = useAuth() + const navigate = useNavigate() + + const [pwForm, setPwForm] = useState({ current_password: '', new_password: '', confirm: '' }) + const [showPw, setShowPw] = useState(false) + const [pwLoading, setPwLoading] = useState(false) + const [pwError, setPwError] = useState('') + const [pwSuccess, setPwSuccess] = useState('') + + const [mfaSetup, setMfaSetup] = useState(null) // { qr, secret } + const [mfaCode, setMfaCode] = useState('') + const [mfaLoading, setMfaLoading] = useState(false) + const [mfaError, setMfaError] = useState('') + const [mfaSuccess, setMfaSuccess] = useState('') + + const handlePwChange = async (e) => { + e.preventDefault() + setPwError(''); setPwSuccess('') + if (pwForm.new_password !== pwForm.confirm) return setPwError('Passwords do not match') + if (pwForm.new_password.length < 6) return setPwError('Password must be at least 6 characters') + setPwLoading(true) + try { + await api.post('/auth/change-password', { current_password: pwForm.current_password, new_password: pwForm.new_password }) + setPwSuccess('Password changed successfully!') + setPwForm({ current_password: '', new_password: '', confirm: '' }) + } catch (err) { + setPwError(err.response?.data?.error || 'Failed to change password') + } finally { + setPwLoading(false) + } + } + + const startMfaSetup = async () => { + setMfaError(''); setMfaSuccess('') + setMfaLoading(true) + try { + const { data } = await api.post('/auth/mfa/setup') + setMfaSetup(data) + } catch { + setMfaError('Failed to start MFA setup') + } finally { + setMfaLoading(false) + } + } + + const verifyMfa = async () => { + setMfaError('') + setMfaLoading(true) + try { + await api.post('/auth/mfa/verify', { code: mfaCode }) + updateUser({ mfa_enabled: true }) + setMfaSetup(null) + setMfaCode('') + setMfaSuccess('MFA enabled successfully!') + } catch (err) { + setMfaError(err.response?.data?.error || 'Invalid code') + } finally { + setMfaLoading(false) + } + } + + const disableMfa = async () => { + setMfaError(''); setMfaSuccess('') + setMfaLoading(true) + try { + await api.post('/auth/mfa/disable') + updateUser({ mfa_enabled: false }) + setMfaSuccess('MFA disabled.') + } catch { + setMfaError('Failed to disable MFA') + } finally { + setMfaLoading(false) + } + } + + return ( +
+

Profile

+ + {/* User Info */} +
+
+
+ +
+
+

{user?.username}

+ + {user?.role} + +
+
+
+ + {/* Change Password */} +
+
+
+ +
+

Change Password

+
+ + {pwSuccess &&
{pwSuccess}
} + {pwError &&
{pwError}
} + +
+ {['current_password', 'new_password', 'confirm'].map((field) => ( +
+ +
+ setPwForm({ ...pwForm, [field]: e.target.value })} + required + className="w-full bg-slate-800 border border-slate-700 rounded-xl px-4 py-3 pr-12 text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 transition" + placeholder="••••••••" + /> + {field === 'current_password' && ( + + )} +
+
+ ))} + +
+
+ + {/* MFA */} +
+
+
+ +
+
+

Two-Factor Authentication

+ + {user?.mfa_enabled ? 'Enabled' : 'Disabled'} + +
+
+ + {mfaSuccess &&
{mfaSuccess}
} + {mfaError &&
{mfaError}
} + + {!user?.mfa_enabled && !mfaSetup && ( +
+

Protect your account with Google Authenticator or any TOTP-compatible app.

+ +
+ )} + + {mfaSetup && ( +
+
+

1. Install Google Authenticator or any TOTP app.

+

2. Scan this QR code with the app:

+
+
+ QR Code +
+
+

Manual entry code:

+ {mfaSetup.secret} +
+
+ + setMfaCode(e.target.value)} + className="w-full bg-slate-800 border border-slate-700 rounded-xl px-4 py-3 text-white text-center text-2xl tracking-widest focus:outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500 transition" + placeholder="000000" + /> +
+
+ + +
+
+ )} + + {user?.mfa_enabled && !mfaSetup && ( +
+

MFA is active. Your account is protected with two-factor authentication.

+ +
+ )} +
+
+ ) +} diff --git a/frontend/src/{pages,components}/1.txt b/frontend/src/{pages,components}/1.txt new file mode 100644 index 0000000..2e65efe --- /dev/null +++ b/frontend/src/{pages,components}/1.txt @@ -0,0 +1 @@ +a \ No newline at end of file diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js new file mode 100644 index 0000000..e88d8ce --- /dev/null +++ b/frontend/tailwind.config.js @@ -0,0 +1,6 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: ['./index.html', './src/**/*.{js,jsx}'], + theme: { extend: {} }, + plugins: [], +} diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..9ffcc67 --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,6 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], +}) diff --git a/{backend,frontend,nginx,db-init}/1.txt b/{backend,frontend,nginx,db-init}/1.txt new file mode 100644 index 0000000..2e65efe --- /dev/null +++ b/{backend,frontend,nginx,db-init}/1.txt @@ -0,0 +1 @@ +a \ No newline at end of file