Add user accounting

This commit is contained in:
2026-08-27 00:10:53 +03:00
parent ce70b7322f
commit b3ea30e807
6 changed files with 105 additions and 16 deletions
+73 -7
View File
@@ -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}`));
}