Add user accounting
This commit is contained in:
@@ -1,10 +1,44 @@
|
||||
const express = require('express');
|
||||
const path = require('node:path');
|
||||
const crypto = require('node:crypto');
|
||||
const { Pool } = require('pg');
|
||||
|
||||
const app = express();
|
||||
const port = Number(process.env.PORT || 3000);
|
||||
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
||||
const sessions = new Map();
|
||||
|
||||
function hashPassword(password, salt = crypto.randomBytes(16).toString('hex')) {
|
||||
return new Promise((resolve, reject) => crypto.scrypt(String(password), salt, 64, (error, derivedKey) => error ? reject(error) : resolve(`${salt}:${derivedKey.toString('hex')}`)));
|
||||
}
|
||||
|
||||
function verifyPassword(password, storedHash) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const [salt, hash] = String(storedHash || '').split(':');
|
||||
if (!salt || !hash) return resolve(false);
|
||||
crypto.scrypt(String(password), salt, 64, (error, derivedKey) => {
|
||||
if (error) return reject(error);
|
||||
const expected = Buffer.from(hash, 'hex');
|
||||
resolve(expected.length === derivedKey.length && crypto.timingSafeEqual(expected, derivedKey));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getSessionUser(request) {
|
||||
const token = String(request.headers.cookie || '').split(';').map((part) => part.trim()).find((part) => part.startsWith('car_service_session='))?.split('=')[1];
|
||||
return token ? sessions.get(token) : null;
|
||||
}
|
||||
|
||||
function requirePermission(permission) {
|
||||
return (request, response, next) => {
|
||||
const user = getSessionUser(request);
|
||||
if (!user) return response.status(401).json({ error: 'Sign in to modify workshop data.' });
|
||||
if (permission === 'delete' && user.role !== 'admin') return response.status(403).json({ error: 'Only administrators can delete data.' });
|
||||
if (permission === 'admin' && user.role !== 'admin') return response.status(403).json({ error: 'Only administrators can manage user accounts or restore backups.' });
|
||||
if (permission === 'add' && user.can_add !== true && !['admin', 'technician', 'service_advisor'].includes(user.role)) return response.status(403).json({ error: 'Your account does not have add or modify rights.' });
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeItems(items) {
|
||||
if (!Array.isArray(items)) return [];
|
||||
@@ -26,6 +60,34 @@ async function insertItems(client, serviceOrderId, items) {
|
||||
app.use(express.json());
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
|
||||
app.get('/api/auth/me', (request, response) => response.json({ user: getSessionUser(request) }));
|
||||
app.post('/api/auth/login', async (request, response) => {
|
||||
const username = String(request.body.username || '').trim();
|
||||
const password = String(request.body.password || '');
|
||||
if (!username || !password) return response.status(400).json({ error: 'Username and password are required.' });
|
||||
try {
|
||||
const result = await pool.query('SELECT user_id, username, display_name, email, role, can_read, can_add, can_delete, password_hash FROM app_users WHERE LOWER(username) = LOWER($1)', [username]);
|
||||
if (!result.rowCount || !(await verifyPassword(password, result.rows[0].password_hash))) return response.status(401).json({ error: 'Invalid username or password.' });
|
||||
const user = result.rows[0]; delete user.password_hash;
|
||||
const token = crypto.randomBytes(32).toString('hex'); sessions.set(token, user);
|
||||
response.setHeader('Set-Cookie', `car_service_session=${token}; HttpOnly; SameSite=Lax; Path=/; Max-Age=28800`);
|
||||
response.json({ user });
|
||||
} catch (error) { response.status(500).json({ error: 'Unable to sign in.' }); }
|
||||
});
|
||||
app.post('/api/auth/logout', (request, response) => {
|
||||
const token = String(request.headers.cookie || '').split(';').map((part) => part.trim()).find((part) => part.startsWith('car_service_session='))?.split('=')[1];
|
||||
if (token) sessions.delete(token);
|
||||
response.setHeader('Set-Cookie', 'car_service_session=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0'); response.status(204).end();
|
||||
});
|
||||
|
||||
app.use('/api', (request, response, next) => {
|
||||
if (request.path.startsWith('/auth/') || request.method === 'GET' || request.method === 'HEAD' || request.method === 'OPTIONS') return next();
|
||||
if (request.path === '/data/import') return requirePermission('admin')(request, response, next);
|
||||
if (request.path.startsWith('/users')) return requirePermission('admin')(request, response, next);
|
||||
if (request.method === 'DELETE') return requirePermission('delete')(request, response, next);
|
||||
return requirePermission('add')(request, response, next);
|
||||
});
|
||||
|
||||
const dataTables = [
|
||||
{ name: 'customers', columns: ['customer_id', 'first_name', 'last_name', 'email', 'phone', 'created_at'] },
|
||||
{ name: 'employees', columns: ['employee_id', 'first_name', 'last_name', 'role', 'email', 'active'] },
|
||||
@@ -310,21 +372,23 @@ app.get('/api/users', async (_request, response) => {
|
||||
});
|
||||
|
||||
app.post('/api/users', async (request, response) => {
|
||||
const { username, displayName, email, role, canRead, canAdd, canDelete } = request.body;
|
||||
const { username, displayName, email, role, canRead, canAdd, canDelete, password } = request.body;
|
||||
const allowedRoles = ['admin', 'service_advisor', 'technician', 'viewer'];
|
||||
if (!username || !displayName || !allowedRoles.includes(role)) return response.status(400).json({ error: 'Username, display name, and a valid role are required.' });
|
||||
if (!username || !displayName || !allowedRoles.includes(role) || !password) return response.status(400).json({ error: 'Username, display name, valid role, and password are required.' });
|
||||
try {
|
||||
const result = await pool.query(`INSERT INTO app_users (username, display_name, email, role, can_read, can_add, can_delete) VALUES ($1, $2, NULLIF($3, ''), $4, $5, $6, $7) RETURNING user_id, username, display_name, email, role, can_read, can_add, can_delete, created_at`, [username.trim(), displayName.trim(), email?.trim() || '', role, canRead !== false, canAdd === true, canDelete === true]);
|
||||
const passwordHash = await hashPassword(password);
|
||||
const result = await pool.query(`INSERT INTO app_users (username, display_name, email, role, can_read, can_add, can_delete, password_hash) VALUES ($1, $2, NULLIF($3, ''), $4, $5, $6, $7, $8) RETURNING user_id, username, display_name, email, role, can_read, can_add, can_delete, created_at`, [username.trim(), displayName.trim(), email?.trim() || '', role, canRead !== false, canAdd === true || ['technician', 'service_advisor'].includes(role), role === 'admin' && canDelete === true, passwordHash]);
|
||||
response.status(201).json(result.rows[0]);
|
||||
} catch (error) { response.status(error.code === '23505' ? 409 : 500).json({ error: error.code === '23505' ? 'That username or email is already in use.' : 'Unable to create user account.' }); }
|
||||
});
|
||||
|
||||
app.patch('/api/users/:id', async (request, response) => {
|
||||
const { displayName, email, role, canRead, canAdd, canDelete } = request.body;
|
||||
const { displayName, email, role, canRead, canAdd, canDelete, password } = request.body;
|
||||
const allowedRoles = ['admin', 'service_advisor', 'technician', 'viewer'];
|
||||
if (!displayName || !allowedRoles.includes(role)) return response.status(400).json({ error: 'Display name and a valid role are required.' });
|
||||
try {
|
||||
const result = await pool.query(`UPDATE app_users SET display_name = $1, email = NULLIF($2, ''), role = $3, can_read = $4, can_add = $5, can_delete = $6 WHERE user_id = $7 RETURNING user_id`, [displayName.trim(), email?.trim() || '', role, canRead === true, canAdd === true, canDelete === true, request.params.id]);
|
||||
const passwordHash = password ? await hashPassword(password) : null;
|
||||
const result = await pool.query(`UPDATE app_users SET display_name = $1, email = NULLIF($2, ''), role = $3, can_read = $4, can_add = $5, can_delete = $6, password_hash = COALESCE($7, password_hash) WHERE user_id = $8 RETURNING user_id`, [displayName.trim(), email?.trim() || '', role, canRead === true, canAdd === true, canDelete === true, passwordHash, request.params.id]);
|
||||
if (!result.rowCount) return response.status(404).json({ error: 'User account not found.' });
|
||||
response.json(result.rows[0]);
|
||||
} catch (error) { response.status(error.code === '23505' ? 409 : 500).json({ error: error.code === '23505' ? 'That email is already in use.' : 'Unable to update user account.' }); }
|
||||
@@ -352,8 +416,10 @@ async function start() {
|
||||
await pool.query(`UPDATE service_orders SET service_date = opened_at::date WHERE service_date IS NULL`);
|
||||
await pool.query(`ALTER TABLE service_orders ALTER COLUMN service_date SET DEFAULT CURRENT_DATE`);
|
||||
await pool.query(`ALTER TABLE service_orders ALTER COLUMN service_date SET NOT NULL`);
|
||||
await pool.query(`CREATE TABLE IF NOT EXISTS app_users (user_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), username TEXT NOT NULL UNIQUE, display_name TEXT NOT NULL, email TEXT UNIQUE, role TEXT NOT NULL DEFAULT 'viewer' CHECK (role IN ('admin', 'service_advisor', 'technician', 'viewer')), can_read BOOLEAN NOT NULL DEFAULT TRUE, can_add BOOLEAN NOT NULL DEFAULT FALSE, can_delete BOOLEAN NOT NULL DEFAULT FALSE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW())`);
|
||||
await pool.query("INSERT INTO app_users (username, display_name, role, can_read, can_add, can_delete) VALUES ('admin', 'Workshop administrator', 'admin', TRUE, TRUE, TRUE) ON CONFLICT (username) DO NOTHING");
|
||||
await pool.query(`CREATE TABLE IF NOT EXISTS app_users (user_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), username TEXT NOT NULL UNIQUE, display_name TEXT NOT NULL, email TEXT UNIQUE, role TEXT NOT NULL DEFAULT 'viewer' CHECK (role IN ('admin', 'service_advisor', 'technician', 'viewer')), can_read BOOLEAN NOT NULL DEFAULT TRUE, can_add BOOLEAN NOT NULL DEFAULT FALSE, can_delete BOOLEAN NOT NULL DEFAULT FALSE, password_hash TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW())`);
|
||||
await pool.query('ALTER TABLE app_users ADD COLUMN IF NOT EXISTS password_hash TEXT');
|
||||
const adminPasswordHash = await hashPassword(process.env.ADMIN_PASSWORD || 'change-me-in-production');
|
||||
await pool.query("INSERT INTO app_users (username, display_name, role, can_read, can_add, can_delete, password_hash) VALUES ('admin', 'Workshop administrator', 'admin', TRUE, TRUE, TRUE, $1) ON CONFLICT (username) DO UPDATE SET password_hash = COALESCE(app_users.password_hash, EXCLUDED.password_hash)", [adminPasswordHash]);
|
||||
app.listen(port, () => console.log(`Car service web app listening on port ${port}`));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user