v_1
This commit is contained in:
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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"]
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}`));
|
||||||
@@ -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;
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export default {
|
||||||
|
plugins: { tailwindcss: {}, autoprefixer: {} }
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: #0f172a;
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-family: 'Inter', system-ui, sans-serif;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
a
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ['./index.html', './src/**/*.{js,jsx}'],
|
||||||
|
theme: { extend: {} },
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
})
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
a
|
||||||
Reference in New Issue
Block a user