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}`));