This commit is contained in:
jpmvaz
2026-09-13 20:21:31 +01:00
commit 95b47a69e3
29 changed files with 2608 additions and 0 deletions
+17
View File
@@ -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
+68
View File
@@ -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)
+12
View File
@@ -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"]
+20
View File
@@ -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"
}
}
+430
View File
@@ -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}`));
+51
View File
@@ -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;
+51
View File
@@ -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
+15
View File
@@ -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
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Mobile Contract Manager</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+19
View File
@@ -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;
}
}
+26
View File
@@ -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"
}
}
+3
View File
@@ -0,0 +1,3 @@
export default {
plugins: { tailwindcss: {}, autoprefixer: {} }
}
+45
View File
@@ -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 <Navigate to="/login" replace />
if (user.must_change_password) return <Navigate to="/change-password" replace />
if (adminOnly && user.role !== 'admin') return <Navigate to="/" replace />
return children
}
function AppRoutes() {
const { user } = useAuth()
return (
<Routes>
<Route path="/login" element={!user ? <Login /> : <Navigate to="/" replace />} />
<Route path="/change-password" element={user ? <ChangePassword /> : <Navigate to="/login" replace />} />
<Route path="/" element={<ProtectedRoute><Layout><Dashboard /></Layout></ProtectedRoute>} />
<Route path="/admin" element={<ProtectedRoute adminOnly><Layout><Admin /></Layout></ProtectedRoute>} />
<Route path="/profile" element={<ProtectedRoute><Layout><Profile /></Layout></ProtectedRoute>} />
<Route path="/fields" element={<ProtectedRoute adminOnly><Layout><Fields /></Layout></ProtectedRoute>} />
<Route path="/maintenance" element={<ProtectedRoute adminOnly><Layout><Maintenance /></Layout></ProtectedRoute>} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
)
}
export default function App() {
return (
<AuthProvider>
<BrowserRouter>
<AppRoutes />
</BrowserRouter>
</AuthProvider>
)
}
+38
View File
@@ -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 (
<AuthContext.Provider value={{ user, token, login, logout, updateUser, isAdmin: user?.role === 'admin' }}>
{children}
</AuthContext.Provider>
)
}
export const useAuth = () => useContext(AuthContext)
+23
View File
@@ -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
+118
View File
@@ -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 }) => (
<Link
key={path}
to={path}
onClick={() => 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'
}`}
>
<Icon size={20} />
<span className="font-medium">{label}</span>
</Link>
))}
</>
)
return (
<div className="flex min-h-screen bg-slate-950">
{/* Sidebar desktop */}
<aside className="hidden lg:flex flex-col w-64 bg-slate-900 border-r border-slate-800 p-4">
<div className="flex items-center gap-3 px-4 py-4 mb-6">
<div className="w-10 h-10 bg-indigo-600 rounded-xl flex items-center justify-center">
<Smartphone size={20} className="text-white" />
</div>
<div>
<h1 className="font-bold text-white text-sm leading-tight">Mobile Contract</h1>
<p className="text-xs text-slate-400">Manager</p>
</div>
</div>
<nav className="flex flex-col gap-1 flex-1">
<NavLinks />
</nav>
<div className="border-t border-slate-800 pt-4">
<div className="px-4 py-2 mb-2">
<p className="text-sm font-medium text-white truncate">{user?.username}</p>
<span className={`text-xs px-2 py-0.5 rounded-full ${isAdmin ? 'bg-amber-500/20 text-amber-400' : 'bg-slate-700 text-slate-400'}`}>
{user?.role}
</span>
</div>
<button
onClick={logout}
className="flex items-center gap-3 px-4 py-3 rounded-xl w-full text-slate-400 hover:text-white hover:bg-slate-700/60 transition-all"
>
<LogOut size={20} />
<span className="font-medium">Logout</span>
</button>
</div>
</aside>
{/* Mobile sidebar */}
{mobileOpen && (
<div className="fixed inset-0 z-50 lg:hidden">
<div className="absolute inset-0 bg-black/60" onClick={() => setMobileOpen(false)} />
<aside className="absolute left-0 top-0 bottom-0 w-64 bg-slate-900 border-r border-slate-800 p-4 flex flex-col">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-indigo-600 rounded-xl flex items-center justify-center">
<Smartphone size={20} className="text-white" />
</div>
<div>
<h1 className="font-bold text-white text-sm">Mobile Contract</h1>
<p className="text-xs text-slate-400">Manager</p>
</div>
</div>
<button onClick={() => setMobileOpen(false)} className="text-slate-400 hover:text-white">
<X size={20} />
</button>
</div>
<nav className="flex flex-col gap-1 flex-1"><NavLinks /></nav>
<div className="border-t border-slate-800 pt-4">
<button onClick={logout} className="flex items-center gap-3 px-4 py-3 rounded-xl w-full text-slate-400 hover:text-white hover:bg-slate-700/60">
<LogOut size={20} /><span>Logout</span>
</button>
</div>
</aside>
</div>
)}
{/* Main content */}
<div className="flex-1 flex flex-col">
<header className="lg:hidden flex items-center justify-between px-4 py-3 bg-slate-900 border-b border-slate-800">
<button onClick={() => setMobileOpen(true)} className="text-slate-400 hover:text-white">
<Menu size={24} />
</button>
<h1 className="text-white font-bold">Mobile Contract Manager</h1>
<div className="w-6" />
</header>
<main className="flex-1 p-6 overflow-auto">{children}</main>
</div>
</div>
)
}
+9
View File
@@ -0,0 +1,9 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
background-color: #0f172a;
color: #e2e8f0;
font-family: 'Inter', system-ui, sans-serif;
}
+10
View File
@@ -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(
<React.StrictMode>
<App />
</React.StrictMode>
)
+267
View File
@@ -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 (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70">
<div className="bg-slate-900 rounded-2xl border border-slate-800 w-full max-w-md shadow-2xl">
<div className="flex items-center justify-between p-6 border-b border-slate-800">
<h3 className="text-lg font-bold text-white">{user ? 'Edit User' : 'New User'}</h3>
<button onClick={onClose} className="text-slate-400 hover:text-white"><X size={20} /></button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
{error && <div className="bg-red-500/10 border border-red-500/30 text-red-400 text-sm rounded-xl px-4 py-3">{error}</div>}
<div>
<label className="block text-sm font-medium text-slate-400 mb-1.5">Username</label>
<input
type="text"
value={form.username}
onChange={e => 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"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-400 mb-1.5">
{user ? 'New Password (leave blank to keep current)' : 'Password'}
</label>
<input
type="password"
value={form.password}
onChange={e => 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'}
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-400 mb-1.5">Role</label>
<div className="grid grid-cols-2 gap-3">
{['user', 'admin'].map(role => (
<button
type="button"
key={role}
onClick={() => setForm({ ...form, role })}
className={`py-3 rounded-xl border text-sm font-semibold flex items-center justify-center gap-2 transition ${
form.role === role
? role === 'admin' ? 'border-amber-500 bg-amber-500/10 text-amber-400' : 'border-indigo-500 bg-indigo-500/10 text-indigo-400'
: 'border-slate-700 text-slate-400 hover:border-slate-600'
}`}
>
{role === 'admin' ? <Shield size={16} /> : <User size={16} />}
{role.charAt(0).toUpperCase() + role.slice(1)}
</button>
))}
</div>
</div>
<div className="flex gap-3 pt-2">
<button type="button" onClick={onClose} className="flex-1 py-2.5 rounded-xl border border-slate-700 text-slate-400 hover:text-white transition text-sm font-medium">Cancel</button>
<button type="submit" disabled={loading} className="flex-1 py-2.5 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-semibold disabled:opacity-50 transition">
{loading ? 'Saving...' : 'Save User'}
</button>
</div>
</form>
</div>
</div>
)
}
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 (
<div>
{toast && (
<div className="fixed top-4 right-4 z-50 bg-green-500/20 border border-green-500/40 text-green-300 px-4 py-3 rounded-xl shadow-xl text-sm font-medium flex items-center gap-2">
<Check size={16} />{toast}
</div>
)}
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-2xl font-bold text-white">Admin Panel</h2>
<p className="text-slate-400 text-sm mt-1">Manage user accounts and permissions</p>
</div>
<button
onClick={() => setModal('add')}
className="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-500 text-white px-4 py-2.5 rounded-xl text-sm font-semibold transition shadow-lg shadow-indigo-900/40"
>
<Plus size={16} />Add User
</button>
</div>
{/* Stats */}
<div className="grid grid-cols-3 gap-4 mb-6">
{[
{ 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 }) => (
<div key={label} className="bg-slate-900 rounded-xl border border-slate-800 p-4 flex items-center gap-4">
<div className={`w-10 h-10 rounded-xl flex items-center justify-center bg-${color}-500/20`}>
<Icon size={20} className={`text-${color}-400`} />
</div>
<div>
<p className="text-2xl font-bold text-white">{value}</p>
<p className="text-xs text-slate-400">{label}</p>
</div>
</div>
))}
</div>
{/* Users table */}
<div className="bg-slate-900 rounded-2xl border border-slate-800 overflow-hidden">
{loading ? (
<div className="p-12 text-center text-slate-500">Loading...</div>
) : (
<table className="w-full">
<thead>
<tr className="border-b border-slate-800">
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider">Username</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider">Role</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider">MFA</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider">Status</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider">Created</th>
<th className="text-right px-4 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody>
{users.map(u => (
<tr key={u.id} className="border-b border-slate-800/50 hover:bg-slate-800/30 transition">
<td className="px-4 py-3.5">
<div className="flex items-center gap-3">
<div className={`w-8 h-8 rounded-lg flex items-center justify-center ${u.role === 'admin' ? 'bg-amber-500/20' : 'bg-indigo-500/20'}`}>
{u.role === 'admin' ? <Shield size={14} className="text-amber-400" /> : <User size={14} className="text-indigo-400" />}
</div>
<span className="text-white font-medium">{u.username}</span>
{u.id === currentUser.id && <span className="text-xs text-slate-500">(you)</span>}
</div>
</td>
<td className="px-4 py-3.5">
<span className={`text-xs px-2.5 py-1 rounded-full font-medium ${u.role === 'admin' ? 'bg-amber-500/20 text-amber-400' : 'bg-slate-700 text-slate-300'}`}>
{u.role}
</span>
</td>
<td className="px-4 py-3.5">
<span className={`text-xs px-2.5 py-1 rounded-full font-medium ${u.mfa_enabled ? 'bg-green-500/20 text-green-400' : 'bg-slate-700/50 text-slate-500'}`}>
{u.mfa_enabled ? 'Enabled' : 'Disabled'}
</span>
</td>
<td className="px-4 py-3.5">
{u.must_change_password ? (
<span className="text-xs px-2.5 py-1 rounded-full font-medium bg-amber-500/20 text-amber-400 flex items-center gap-1 w-fit">
<KeyRound size={10} />Must change pw
</span>
) : (
<span className="text-xs text-green-400">Active</span>
)}
</td>
<td className="px-4 py-3.5 text-slate-400 text-sm">{new Date(u.created_at).toLocaleDateString()}</td>
<td className="px-4 py-3.5 text-right">
<div className="flex items-center justify-end gap-2">
<button onClick={() => setModal(u)} className="p-2 rounded-lg text-slate-400 hover:text-white hover:bg-slate-700 transition">
<Edit2 size={14} />
</button>
{u.id !== currentUser.id && (
<button onClick={() => setDeleteConfirm(u)} className="p-2 rounded-lg text-slate-400 hover:text-red-400 hover:bg-red-500/10 transition">
<Trash2 size={14} />
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{modal && (
<UserModal
user={modal === 'add' ? null : modal}
onClose={() => setModal(null)}
onSave={handleSave}
/>
)}
{deleteConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70">
<div className="bg-slate-900 rounded-2xl border border-slate-800 p-6 w-full max-w-sm shadow-2xl">
<h3 className="text-lg font-bold text-white mb-2">Delete User</h3>
<p className="text-slate-400 text-sm mb-6">Delete <strong className="text-white">{deleteConfirm.username}</strong>? This cannot be undone.</p>
<div className="flex gap-3">
<button onClick={() => setDeleteConfirm(null)} className="flex-1 py-2.5 rounded-xl border border-slate-700 text-slate-400 hover:text-white transition text-sm">Cancel</button>
<button onClick={() => handleDelete(deleteConfirm.id)} className="flex-1 py-2.5 rounded-xl bg-red-500/20 hover:bg-red-500/30 text-red-400 border border-red-500/30 transition text-sm font-semibold">Delete</button>
</div>
</div>
</div>
)}
</div>
)
}
+104
View File
@@ -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 (
<div className="min-h-screen bg-slate-950 flex items-center justify-center p-4">
<div className="w-full max-w-md">
<div className="bg-slate-900 rounded-2xl border border-slate-800 p-8 shadow-2xl">
<div className="flex items-center gap-3 mb-6">
<div className="w-12 h-12 bg-amber-500/20 rounded-xl flex items-center justify-center">
<KeyRound size={24} className="text-amber-400" />
</div>
<div>
<h2 className="text-xl font-bold text-white">
{isForced ? 'Change Required' : 'Change Password'}
</h2>
{isForced && <p className="text-sm text-amber-400">You must set a new password before continuing.</p>}
</div>
</div>
{error && (
<div className="bg-red-500/10 border border-red-500/30 text-red-400 text-sm rounded-xl px-4 py-3 mb-4">{error}</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-400 mb-1.5">New Password</label>
<div className="relative">
<input
type={showPw ? 'text' : 'password'}
value={form.new_password}
onChange={e => 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
/>
<button type="button" onClick={() => setShowPw(!showPw)} className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-white">
{showPw ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-400 mb-1.5">Confirm Password</label>
<input
type={showPw ? 'text' : 'password'}
value={form.confirm}
onChange={e => 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
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white font-semibold py-3 rounded-xl transition-all mt-2"
>
{loading ? 'Saving...' : 'Set New Password'}
</button>
{!isForced && (
<button type="button" onClick={() => navigate(-1)} className="w-full text-slate-400 hover:text-white text-sm py-2 transition">
Cancel
</button>
)}
{isForced && (
<button type="button" onClick={logout} className="w-full text-slate-500 hover:text-slate-300 text-sm py-2 transition">
Logout instead
</button>
)}
</form>
</div>
</div>
</div>
)
}
+278
View File
@@ -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 (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70">
<div className="bg-slate-900 rounded-2xl border border-slate-800 w-full max-w-lg shadow-2xl max-h-[90vh] flex flex-col">
<div className="flex items-center justify-between p-6 border-b border-slate-800">
<h3 className="text-lg font-bold text-white">{contract ? 'Edit Contract' : 'New Contract'}</h3>
<button onClick={onClose} className="text-slate-400 hover:text-white"><X size={20} /></button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4 overflow-y-auto flex-1">
{error && <div className="bg-red-500/10 border border-red-500/30 text-red-400 text-sm rounded-xl px-4 py-3">{error}</div>}
{fields.length === 0 ? (
<p className="text-slate-400 text-sm text-center py-4">No fields defined. Go to Fields to add some.</p>
) : (
<div className="grid grid-cols-2 gap-4">
{fields.map(f => (
<div key={f.field_key} className={fields.length === 1 ? 'col-span-2' : ''}>
<label className="block text-xs font-medium text-slate-400 mb-1.5">
{f.label}{f.required && <span className="text-red-400 ml-1">*</span>}
</label>
<input
type={inputType(f.field_type)}
step={f.field_type === 'number' ? 'any' : undefined}
value={form[f.field_key] || ''}
onChange={e => 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}
/>
</div>
))}
</div>
)}
</form>
<div className="flex gap-3 p-6 border-t border-slate-800">
<button type="button" onClick={onClose} className="flex-1 py-2.5 rounded-xl border border-slate-700 text-slate-400 hover:text-white hover:border-slate-600 transition text-sm font-medium">
Cancel
</button>
<button
onClick={handleSubmit}
disabled={loading || fields.length === 0}
className="flex-1 py-2.5 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-semibold disabled:opacity-50 transition"
>
{loading ? 'Saving...' : 'Save'}
</button>
</div>
</div>
</div>
)
}
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 <ChevronUp size={14} className="inline ml-1 opacity-20" />
return sortDir === 'asc'
? <ChevronUp size={14} className="inline ml-1 text-indigo-400" />
: <ChevronDown size={14} className="inline ml-1 text-indigo-400" />
}
return (
<div>
{toast && (
<div className="fixed top-4 right-4 z-50 bg-green-500/20 border border-green-500/40 text-green-300 px-4 py-3 rounded-xl shadow-xl text-sm font-medium flex items-center gap-2">
<Check size={16} />{toast}
</div>
)}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
<div>
<h2 className="text-2xl font-bold text-white">Mobile Contracts</h2>
<p className="text-slate-400 text-sm mt-1">{contracts.length} record{contracts.length !== 1 ? 's' : ''}</p>
</div>
<div className="flex gap-3">
<div className="relative">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
<input
type="text"
value={search}
onChange={e => 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 && (
<button onClick={() => setSearch('')} className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-white">
<X size={14} />
</button>
)}
</div>
{isAdmin && (
<button
onClick={() => setModal('add')}
className="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-500 text-white px-4 py-2.5 rounded-xl text-sm font-semibold transition shadow-lg shadow-indigo-900/40"
>
<Plus size={16} />Add
</button>
)}
</div>
</div>
<div className="bg-slate-900 rounded-2xl border border-slate-800 overflow-hidden">
{loading ? (
<div className="p-12 text-center text-slate-500">Loading...</div>
) : fields.length === 0 ? (
<div className="p-12 text-center">
<Database size={48} className="text-slate-700 mx-auto mb-3" />
<p className="text-slate-400">No fields defined yet.</p>
{isAdmin && <p className="text-slate-500 text-sm mt-1">Go to <strong className="text-slate-400">Fields</strong> in the sidebar to add some.</p>}
</div>
) : sorted.length === 0 ? (
<div className="p-12 text-center">
<Database size={48} className="text-slate-700 mx-auto mb-3" />
<p className="text-slate-400">{search ? 'No results found' : 'No contracts yet'}</p>
{isAdmin && !search && (
<button onClick={() => setModal('add')} className="mt-3 text-indigo-400 hover:text-indigo-300 text-sm">
Add the first one
</button>
)}
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-slate-800">
{fields.map(f => (
<th
key={f.field_key}
onClick={() => 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}<SortIcon fieldKey={f.field_key} />
</th>
))}
{isAdmin && <th className="px-4 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider text-right">Actions</th>}
</tr>
</thead>
<tbody>
{sorted.map((c, i) => (
<tr key={c.id} className={`border-b border-slate-800/50 hover:bg-slate-800/30 transition ${i % 2 === 0 ? '' : 'bg-slate-800/10'}`}>
{fields.map(f => (
<td key={f.field_key} className="px-4 py-3.5 text-slate-300 whitespace-nowrap">
{c.data?.[f.field_key] != null && c.data?.[f.field_key] !== ''
? String(c.data[f.field_key])
: <span className="text-slate-600"></span>
}
</td>
))}
{isAdmin && (
<td className="px-4 py-3.5 text-right">
<div className="flex items-center justify-end gap-2">
<button onClick={() => setModal(c)} className="p-2 rounded-lg text-slate-400 hover:text-white hover:bg-slate-700 transition">
<Edit2 size={14} />
</button>
<button onClick={() => setDeleteConfirm(c.id)} className="p-2 rounded-lg text-slate-400 hover:text-red-400 hover:bg-red-500/10 transition">
<Trash2 size={14} />
</button>
</div>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{modal && (
<ContractModal
contract={modal === 'add' ? null : modal}
fields={fields}
onClose={() => setModal(null)}
onSave={handleSave}
/>
)}
{deleteConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70">
<div className="bg-slate-900 rounded-2xl border border-slate-800 p-6 w-full max-w-sm shadow-2xl">
<h3 className="text-lg font-bold text-white mb-2">Confirm Delete</h3>
<p className="text-slate-400 text-sm mb-6">This contract will be permanently deleted.</p>
<div className="flex gap-3">
<button onClick={() => setDeleteConfirm(null)} className="flex-1 py-2.5 rounded-xl border border-slate-700 text-slate-400 hover:text-white transition text-sm">Cancel</button>
<button onClick={() => handleDelete(deleteConfirm)} className="flex-1 py-2.5 rounded-xl bg-red-500/20 hover:bg-red-500/30 text-red-400 border border-red-500/30 transition text-sm font-semibold">Delete</button>
</div>
</div>
</div>
)}
</div>
)
}
+307
View File
@@ -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 <Icon size={size} />
}
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 (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70">
<div className="bg-slate-900 rounded-2xl border border-slate-800 w-full max-w-md shadow-2xl">
<div className="flex items-center justify-between p-6 border-b border-slate-800">
<h3 className="text-lg font-bold text-white">{field ? 'Edit Field' : 'New Field'}</h3>
<button onClick={onClose} className="text-slate-400 hover:text-white"><X size={20} /></button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-5">
{error && <div className="bg-red-500/10 border border-red-500/30 text-red-400 text-sm rounded-xl px-4 py-3">{error}</div>}
<div>
<label className="block text-sm font-medium text-slate-400 mb-1.5">Field Label <span className="text-red-400">*</span></label>
<input
type="text"
value={form.label}
onChange={e => 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 && (
<p className="text-xs text-slate-500 mt-1.5">
Key: <code className="text-slate-400">{form.label.trim().toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '')}</code>
</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-slate-400 mb-2">Field Type</label>
<div className="grid grid-cols-4 gap-2">
{TYPE_OPTIONS.map(({ value, label, icon: Icon }) => (
<button
type="button"
key={value}
onClick={() => setForm({ ...form, field_type: value })}
className={`flex flex-col items-center gap-1.5 py-3 px-2 rounded-xl border text-xs font-medium transition ${
form.field_type === value
? 'border-indigo-500 bg-indigo-500/10 text-indigo-400'
: 'border-slate-700 text-slate-400 hover:border-slate-600 hover:text-white'
}`}
>
<Icon size={16} />
{label}
</button>
))}
</div>
</div>
<div>
<label className="flex items-center gap-3 cursor-pointer group">
<div
onClick={() => setForm({ ...form, required: !form.required })}
className={`w-11 h-6 rounded-full transition-colors relative ${form.required ? 'bg-indigo-600' : 'bg-slate-700'}`}
>
<div className={`absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform ${form.required ? 'translate-x-5' : ''}`} />
</div>
<span className="text-sm font-medium text-slate-300 group-hover:text-white transition">Required field</span>
</label>
<p className="text-xs text-slate-500 mt-1 ml-14">Users must fill this field when adding a contract</p>
</div>
<div className="flex gap-3 pt-1">
<button type="button" onClick={onClose} className="flex-1 py-2.5 rounded-xl border border-slate-700 text-slate-400 hover:text-white transition text-sm font-medium">Cancel</button>
<button type="submit" disabled={loading} className="flex-1 py-2.5 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-semibold disabled:opacity-50 transition">
{loading ? 'Saving...' : field ? 'Save Changes' : 'Add Field'}
</button>
</div>
</form>
</div>
</div>
)
}
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 (
<div className="max-w-3xl">
{toast && (
<div className="fixed top-4 right-4 z-50 bg-green-500/20 border border-green-500/40 text-green-300 px-4 py-3 rounded-xl shadow-xl text-sm font-medium flex items-center gap-2">
<Check size={16} />{toast}
</div>
)}
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-2xl font-bold text-white">Fields</h2>
<p className="text-slate-400 text-sm mt-1">Manage the columns shown in the Dashboard</p>
</div>
<button
onClick={() => setModal('add')}
className="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-500 text-white px-4 py-2.5 rounded-xl text-sm font-semibold transition shadow-lg shadow-indigo-900/40"
>
<Plus size={16} />Add Field
</button>
</div>
{/* Info banner */}
<div className="bg-indigo-500/10 border border-indigo-500/20 rounded-xl px-4 py-3 mb-5 flex items-start gap-3">
<Columns size={16} className="text-indigo-400 mt-0.5 flex-shrink-0" />
<p className="text-sm text-indigo-300">
Fields define the columns in the Dashboard. You can drag rows to reorder them. Deleting a field removes its data from all existing contracts.
</p>
</div>
<div className="bg-slate-900 rounded-2xl border border-slate-800 overflow-hidden">
{loading ? (
<div className="p-12 text-center text-slate-500">Loading...</div>
) : fields.length === 0 ? (
<div className="p-12 text-center">
<Columns size={48} className="text-slate-700 mx-auto mb-3" />
<p className="text-slate-400 mb-1">No fields defined yet</p>
<button onClick={() => setModal('add')} className="text-indigo-400 hover:text-indigo-300 text-sm mt-2">
Add your first field
</button>
</div>
) : (
<table className="w-full">
<thead>
<tr className="border-b border-slate-800">
<th className="w-10 px-4 py-3"></th>
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider">Label</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider">Key</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider">Type</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider">Required</th>
<th className="text-right px-4 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody>
{fields.map((f, idx) => (
<tr
key={f.id}
draggable
onDragStart={() => 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' : ''}`}
>
<td className="px-4 py-3.5">
<GripVertical size={16} className="text-slate-600 cursor-grab active:cursor-grabbing" />
</td>
<td className="px-4 py-3.5">
<span className="text-white font-medium">{f.label}</span>
</td>
<td className="px-4 py-3.5">
<code className="text-xs text-slate-400 bg-slate-800 px-2 py-1 rounded-lg">{f.field_key}</code>
</td>
<td className="px-4 py-3.5">
<div className="flex items-center gap-1.5 text-slate-400 text-sm">
<TypeIcon type={f.field_type} />
<span className="capitalize">{f.field_type}</span>
</div>
</td>
<td className="px-4 py-3.5">
{f.required
? <span className="text-xs px-2.5 py-1 rounded-full bg-amber-500/20 text-amber-400 font-medium">Required</span>
: <span className="text-xs text-slate-600">Optional</span>
}
</td>
<td className="px-4 py-3.5 text-right">
<div className="flex items-center justify-end gap-2">
<button onClick={() => setModal(f)} className="p-2 rounded-lg text-slate-400 hover:text-white hover:bg-slate-700 transition">
<Edit2 size={14} />
</button>
<button onClick={() => setDeleteConfirm(f)} className="p-2 rounded-lg text-slate-400 hover:text-red-400 hover:bg-red-500/10 transition">
<Trash2 size={14} />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{modal && (
<FieldModal
field={modal === 'add' ? null : modal}
onClose={() => setModal(null)}
onSave={handleSave}
/>
)}
{deleteConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70">
<div className="bg-slate-900 rounded-2xl border border-slate-800 p-6 w-full max-w-sm shadow-2xl">
<div className="flex items-start gap-3 mb-4">
<div className="w-10 h-10 bg-red-500/20 rounded-xl flex items-center justify-center flex-shrink-0">
<AlertTriangle size={20} className="text-red-400" />
</div>
<div>
<h3 className="text-lg font-bold text-white">Delete Field</h3>
<p className="text-slate-400 text-sm mt-1">
Delete <strong className="text-white">"{deleteConfirm.label}"</strong>? This will permanently remove this column and all its data from every contract.
</p>
</div>
</div>
<div className="flex gap-3">
<button onClick={() => setDeleteConfirm(null)} className="flex-1 py-2.5 rounded-xl border border-slate-700 text-slate-400 hover:text-white transition text-sm">Cancel</button>
<button onClick={() => handleDelete(deleteConfirm)} className="flex-1 py-2.5 rounded-xl bg-red-500/20 hover:bg-red-500/30 text-red-400 border border-red-500/30 transition text-sm font-semibold">Delete Field</button>
</div>
</div>
</div>
)}
</div>
)
}
+140
View File
@@ -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 (
<div className="min-h-screen bg-slate-950 flex items-center justify-center p-4">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<div className="w-16 h-16 bg-indigo-600 rounded-2xl flex items-center justify-center mx-auto mb-4 shadow-xl shadow-indigo-900/50">
<Smartphone size={32} className="text-white" />
</div>
<h1 className="text-3xl font-bold text-white">Mobile Contract</h1>
<p className="text-slate-400 mt-1">Manager</p>
</div>
<div className="bg-slate-900 rounded-2xl border border-slate-800 p-8 shadow-2xl">
<h2 className="text-xl font-semibold text-white mb-6">
{step === 'mfa' ? 'Two-Factor Authentication' : 'Sign In'}
</h2>
{error && (
<div className="bg-red-500/10 border border-red-500/30 text-red-400 text-sm rounded-xl px-4 py-3 mb-4">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
{step === 'credentials' ? (
<>
<div>
<label className="block text-sm font-medium text-slate-400 mb-1.5">Username</label>
<input
type="text"
autoComplete="username"
value={form.username}
onChange={e => 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
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-400 mb-1.5">Password</label>
<div className="relative">
<input
type={showPw ? 'text' : 'password'}
autoComplete="current-password"
value={form.password}
onChange={e => 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
/>
<button type="button" onClick={() => setShowPw(!showPw)} className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-white">
{showPw ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</div>
</div>
</>
) : (
<div>
<div className="flex items-center gap-3 mb-4 p-4 bg-indigo-500/10 border border-indigo-500/30 rounded-xl">
<Shield size={20} className="text-indigo-400 flex-shrink-0" />
<p className="text-sm text-indigo-300">Enter the 6-digit code from your authenticator app.</p>
</div>
<label className="block text-sm font-medium text-slate-400 mb-1.5">Authentication Code</label>
<input
type="text"
inputMode="numeric"
pattern="[0-9]{6}"
maxLength={6}
value={form.mfa_code}
onChange={e => 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
/>
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white font-semibold py-3 rounded-xl transition-all mt-2 shadow-lg shadow-indigo-900/40"
>
{loading ? 'Signing in...' : step === 'mfa' ? 'Verify' : 'Sign In'}
</button>
{step === 'mfa' && (
<button type="button" onClick={() => setStep('credentials')} className="w-full text-slate-400 hover:text-white text-sm py-2 transition">
Back
</button>
)}
</form>
</div>
</div>
</div>
)
}
+317
View File
@@ -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 (
<div className="max-w-3xl">
<div className="mb-6">
<h2 className="text-2xl font-bold text-white">Maintenance</h2>
<p className="text-slate-400 text-sm mt-1">Export and import backups of your contracts and field definitions</p>
</div>
{/* Success result */}
{result && (
<div className="bg-green-500/10 border border-green-500/30 rounded-xl p-4 mb-6 flex items-start gap-3">
<Check size={20} className="text-green-400 flex-shrink-0 mt-0.5" />
<div>
<p className="text-green-300 font-semibold">Restore completed successfully</p>
<p className="text-green-400/80 text-sm mt-1">
{result.fields_restored} field{result.fields_restored !== 1 ? 's' : ''} restored · {result.contracts_imported} contract{result.contracts_imported !== 1 ? 's' : ''} imported · Mode: <span className="capitalize">{result.mode}</span>
</p>
</div>
</div>
)}
{/* Global error */}
{error && (
<div className="bg-red-500/10 border border-red-500/30 rounded-xl p-4 mb-6 flex items-start gap-3">
<AlertTriangle size={20} className="text-red-400 flex-shrink-0 mt-0.5" />
<p className="text-red-400 text-sm">{error}</p>
</div>
)}
{/* Export section */}
<div className="bg-slate-900 rounded-2xl border border-slate-800 p-6 mb-5">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 bg-indigo-500/20 rounded-xl flex items-center justify-center">
<Download size={20} className="text-indigo-400" />
</div>
<div>
<h3 className="text-lg font-bold text-white">Export Backup</h3>
<p className="text-slate-400 text-sm">Download a full JSON backup of all contracts and field definitions</p>
</div>
</div>
<div className="bg-slate-800/60 rounded-xl p-4 mb-5 flex items-start gap-3">
<Info size={15} className="text-slate-400 flex-shrink-0 mt-0.5" />
<p className="text-slate-400 text-sm">
The backup file includes all field definitions and every contract record. It does <strong className="text-slate-300">not</strong> include user accounts or passwords.
</p>
</div>
<button
onClick={handleExport}
disabled={exporting}
className="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white px-5 py-2.5 rounded-xl text-sm font-semibold transition shadow-lg shadow-indigo-900/40"
>
{exporting ? <RefreshCw size={16} className="animate-spin" /> : <Download size={16} />}
{exporting ? 'Exporting...' : 'Download Backup'}
</button>
</div>
{/* Import section */}
<div className="bg-slate-900 rounded-2xl border border-slate-800 p-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 bg-amber-500/20 rounded-xl flex items-center justify-center">
<Upload size={20} className="text-amber-400" />
</div>
<div>
<h3 className="text-lg font-bold text-white">Import Backup</h3>
<p className="text-slate-400 text-sm">Restore data from a previously exported backup file</p>
</div>
</div>
{/* Import mode */}
<div className="mb-5">
<label className="block text-sm font-medium text-slate-400 mb-2">Import Mode</label>
<div className="grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => setImportMode('merge')}
className={`text-left p-4 rounded-xl border transition ${
importMode === 'merge'
? 'border-indigo-500 bg-indigo-500/10'
: 'border-slate-700 hover:border-slate-600'
}`}
>
<p className={`text-sm font-semibold mb-1 ${importMode === 'merge' ? 'text-indigo-400' : 'text-white'}`}>
Merge
</p>
<p className="text-xs text-slate-400">Add backup records to existing data. Existing field definitions are updated if the key matches. Contracts are always appended.</p>
</button>
<button
type="button"
onClick={() => setImportMode('replace')}
className={`text-left p-4 rounded-xl border transition ${
importMode === 'replace'
? 'border-red-500 bg-red-500/10'
: 'border-slate-700 hover:border-slate-600'
}`}
>
<p className={`text-sm font-semibold mb-1 ${importMode === 'replace' ? 'text-red-400' : 'text-white'}`}>
Replace
</p>
<p className="text-xs text-slate-400">Delete all existing contracts and fields before importing. This cannot be undone.</p>
</button>
</div>
</div>
{/* Replace warning */}
{importMode === 'replace' && (
<div className="bg-red-500/10 border border-red-500/30 rounded-xl p-4 mb-5 flex items-start gap-3">
<ShieldAlert size={18} className="text-red-400 flex-shrink-0 mt-0.5" />
<p className="text-red-400 text-sm">
<strong>Warning:</strong> Replace mode will permanently delete ALL existing contracts and field definitions before importing. This action cannot be undone.
</p>
</div>
)}
{/* File upload */}
<div className="mb-5">
<label className="block text-sm font-medium text-slate-400 mb-2">Backup File</label>
<div
onClick={() => 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'
}`}
>
<input
type="file"
accept=".json,application/json"
ref={fileRef}
onChange={handleFileChange}
className="hidden"
/>
{selectedFile ? (
<div className="flex items-center justify-center gap-3">
<FileJson size={24} className="text-indigo-400" />
<div className="text-left">
<p className="text-white text-sm font-medium">{selectedFile.name}</p>
<p className="text-slate-400 text-xs">{(selectedFile.size / 1024).toFixed(1)} KB</p>
</div>
</div>
) : (
<div>
<FileJson size={32} className="text-slate-600 mx-auto mb-2" />
<p className="text-slate-400 text-sm">Click to select a <strong className="text-slate-300">.json</strong> backup file</p>
</div>
)}
</div>
{previewError && (
<div className="mt-3 bg-red-500/10 border border-red-500/30 rounded-xl px-4 py-3 flex items-center gap-2">
<AlertTriangle size={14} className="text-red-400" />
<p className="text-red-400 text-sm">{previewError}</p>
</div>
)}
</div>
{/* Preview */}
{previewData && (
<div className="mb-5 bg-slate-800/60 rounded-xl p-4 border border-slate-700">
<p className="text-sm font-semibold text-white mb-3 flex items-center gap-2">
<Database size={14} className="text-slate-400" />Backup Preview
</p>
<div className="grid grid-cols-2 gap-3 text-sm">
{[
{ 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 }) => (
<div key={label} className="bg-slate-800 rounded-lg px-3 py-2.5">
<p className="text-xs text-slate-500 mb-0.5">{label}</p>
<p className="text-slate-200 text-sm font-medium">{value}</p>
</div>
))}
</div>
{previewData.fields.length > 0 && (
<div className="mt-3">
<p className="text-xs text-slate-500 mb-1.5">Fields in backup:</p>
<div className="flex flex-wrap gap-1.5">
{previewData.fields.map(f => (
<span key={f.field_key} className="text-xs bg-slate-700 text-slate-300 px-2.5 py-1 rounded-full">
{f.label}
</span>
))}
</div>
</div>
)}
</div>
)}
<div className="flex gap-3">
{selectedFile && (
<button
type="button"
onClick={clearFile}
className="px-4 py-2.5 rounded-xl border border-slate-700 text-slate-400 hover:text-white transition text-sm font-medium"
>
Clear
</button>
)}
<button
onClick={handleImport}
disabled={importing || !previewData}
className={`flex items-center gap-2 px-5 py-2.5 rounded-xl text-sm font-semibold transition disabled:opacity-50 ${
importMode === 'replace'
? 'bg-red-600/80 hover:bg-red-600 text-white'
: 'bg-amber-600/80 hover:bg-amber-600 text-white'
}`}
>
{importing ? <RefreshCw size={16} className="animate-spin" /> : <Upload size={16} />}
{importing ? 'Importing...' : importMode === 'replace' ? 'Replace & Import' : 'Merge & Import'}
</button>
</div>
</div>
</div>
)
}
+214
View File
@@ -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 (
<div className="max-w-2xl">
<h2 className="text-2xl font-bold text-white mb-6">Profile</h2>
{/* User Info */}
<div className="bg-slate-900 rounded-2xl border border-slate-800 p-6 mb-6">
<div className="flex items-center gap-4">
<div className="w-16 h-16 bg-indigo-600/20 rounded-2xl flex items-center justify-center">
<User size={32} className="text-indigo-400" />
</div>
<div>
<h3 className="text-xl font-bold text-white">{user?.username}</h3>
<span className={`text-sm px-3 py-1 rounded-full font-medium ${user?.role === 'admin' ? 'bg-amber-500/20 text-amber-400' : 'bg-indigo-500/20 text-indigo-400'}`}>
{user?.role}
</span>
</div>
</div>
</div>
{/* Change Password */}
<div className="bg-slate-900 rounded-2xl border border-slate-800 p-6 mb-6">
<div className="flex items-center gap-3 mb-5">
<div className="w-10 h-10 bg-indigo-500/20 rounded-xl flex items-center justify-center">
<KeyRound size={20} className="text-indigo-400" />
</div>
<h3 className="text-lg font-bold text-white">Change Password</h3>
</div>
{pwSuccess && <div className="bg-green-500/10 border border-green-500/30 text-green-400 text-sm rounded-xl px-4 py-3 mb-4 flex items-center gap-2"><Check size={14} />{pwSuccess}</div>}
{pwError && <div className="bg-red-500/10 border border-red-500/30 text-red-400 text-sm rounded-xl px-4 py-3 mb-4">{pwError}</div>}
<form onSubmit={handlePwChange} className="space-y-4">
{['current_password', 'new_password', 'confirm'].map((field) => (
<div key={field}>
<label className="block text-sm font-medium text-slate-400 mb-1.5">
{field === 'current_password' ? 'Current Password' : field === 'new_password' ? 'New Password' : 'Confirm New Password'}
</label>
<div className="relative">
<input
type={showPw ? 'text' : 'password'}
value={pwForm[field]}
onChange={e => 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' && (
<button type="button" onClick={() => setShowPw(!showPw)} className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-white">
{showPw ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
)}
</div>
</div>
))}
<button type="submit" disabled={pwLoading} className="w-full py-3 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white font-semibold disabled:opacity-50 transition">
{pwLoading ? 'Saving...' : 'Change Password'}
</button>
</form>
</div>
{/* MFA */}
<div className="bg-slate-900 rounded-2xl border border-slate-800 p-6">
<div className="flex items-center gap-3 mb-5">
<div className="w-10 h-10 bg-emerald-500/20 rounded-xl flex items-center justify-center">
<Shield size={20} className="text-emerald-400" />
</div>
<div>
<h3 className="text-lg font-bold text-white">Two-Factor Authentication</h3>
<span className={`text-xs font-medium px-2 py-0.5 rounded-full ${user?.mfa_enabled ? 'bg-green-500/20 text-green-400' : 'bg-slate-700 text-slate-400'}`}>
{user?.mfa_enabled ? 'Enabled' : 'Disabled'}
</span>
</div>
</div>
{mfaSuccess && <div className="bg-green-500/10 border border-green-500/30 text-green-400 text-sm rounded-xl px-4 py-3 mb-4 flex items-center gap-2"><Check size={14} />{mfaSuccess}</div>}
{mfaError && <div className="bg-red-500/10 border border-red-500/30 text-red-400 text-sm rounded-xl px-4 py-3 mb-4">{mfaError}</div>}
{!user?.mfa_enabled && !mfaSetup && (
<div>
<p className="text-slate-400 text-sm mb-4">Protect your account with Google Authenticator or any TOTP-compatible app.</p>
<button onClick={startMfaSetup} disabled={mfaLoading} className="flex items-center gap-2 bg-emerald-600/20 hover:bg-emerald-600/30 text-emerald-400 border border-emerald-600/30 px-4 py-2.5 rounded-xl text-sm font-semibold transition disabled:opacity-50">
<QrCode size={16} />{mfaLoading ? 'Loading...' : 'Set Up MFA'}
</button>
</div>
)}
{mfaSetup && (
<div className="space-y-4">
<div className="text-sm text-slate-400 space-y-2">
<p>1. Install <strong className="text-white">Google Authenticator</strong> or any TOTP app.</p>
<p>2. Scan this QR code with the app:</p>
</div>
<div className="flex justify-center">
<img src={mfaSetup.qr} alt="QR Code" className="w-48 h-48 rounded-xl bg-white p-2" />
</div>
<div className="bg-slate-800 rounded-xl p-3 text-center">
<p className="text-xs text-slate-400 mb-1">Manual entry code:</p>
<code className="text-xs text-indigo-400 font-mono break-all">{mfaSetup.secret}</code>
</div>
<div>
<label className="block text-sm font-medium text-slate-400 mb-1.5">3. Enter the 6-digit code to confirm</label>
<input
type="text"
inputMode="numeric"
maxLength={6}
value={mfaCode}
onChange={e => 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"
/>
</div>
<div className="flex gap-3">
<button onClick={() => { setMfaSetup(null); setMfaCode('') }} className="flex-1 py-2.5 rounded-xl border border-slate-700 text-slate-400 hover:text-white transition text-sm">Cancel</button>
<button onClick={verifyMfa} disabled={mfaLoading || mfaCode.length !== 6} className="flex-1 py-2.5 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white font-semibold disabled:opacity-50 transition text-sm">
{mfaLoading ? 'Verifying...' : 'Enable MFA'}
</button>
</div>
</div>
)}
{user?.mfa_enabled && !mfaSetup && (
<div>
<p className="text-slate-400 text-sm mb-4">MFA is active. Your account is protected with two-factor authentication.</p>
<button onClick={disableMfa} disabled={mfaLoading} className="flex items-center gap-2 bg-red-500/10 hover:bg-red-500/20 text-red-400 border border-red-500/30 px-4 py-2.5 rounded-xl text-sm font-semibold transition disabled:opacity-50">
<ShieldOff size={16} />{mfaLoading ? 'Disabling...' : 'Disable MFA'}
</button>
</div>
)}
</div>
</div>
)
}
+1
View File
@@ -0,0 +1 @@
a
+6
View File
@@ -0,0 +1,6 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,jsx}'],
theme: { extend: {} },
plugins: [],
}
+6
View File
@@ -0,0 +1,6 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
})
+1
View File
@@ -0,0 +1 @@
a