-

Wednesday, August 26, 2026

Good morning, workshop.

+

Wednesday, August 26, 2026

Good morning, workshop.

@@ -50,7 +50,8 @@

Directory

Add customer

Vehicle register

Add vehicle

Use an existing customer email to link this vehicle to that customer.

-

Access control

Add user

Rights
+

Access control

Add user

Rights
+

Workshop access

Sign in

Without an account, the dashboard remains read-only.

Workshop floor

Add service record

Enter a known VIN or license plate to autofill the vehicle and customer. Use customer email when names are shared.

Optional billing

Used parts and labor

Total: €0.00
diff --git a/public/styles.css b/public/styles.css index a7a4285..0bb6d9d 100644 --- a/public/styles.css +++ b/public/styles.css @@ -22,6 +22,7 @@ nav { display: grid; gap: 8px; margin-top: 76px; } .permission { color: #a15b4e; font-size: 12px; font-weight: 700; }.permission.allowed { color: #27784a; }.delete-button { background: transparent; border: 1px solid #e5c9c4; color: #a15b4e; padding: 7px 10px; }.delete-button:hover { background: #fff1ee; }select { border: 1px solid var(--line); padding: 10px; background: white; color: var(--ink); }fieldset { border: 1px solid var(--line); display: grid; gap: 10px; padding: 12px; }legend { color: var(--muted); font-size: 11px; font-weight: 700; text-transform: uppercase; }.check-label { display: flex; align-items: center; gap: 8px; }.check-label input { accent-color: var(--navy); } .dialog-actions { display: flex; gap: 10px; justify-content: flex-end; }.secondary-button { background: transparent; border: 1px solid var(--line); color: var(--ink); padding: 12px 17px; font-weight: 700; }.secondary-button:hover { background: var(--paper); } .settings-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; margin-top: 28px; }.settings-card { min-height: 235px; }.settings-card h3 { font: 600 21px 'Space Grotesk', sans-serif; margin: 0 0 10px; }.settings-card p:not(.eyebrow):not(.form-hint) { color: var(--muted); line-height: 1.6; margin: 0 0 24px; max-width: 48ch; }.file-button { align-items: center; background: var(--ink); color: white; cursor: pointer; display: inline-flex; font-weight: 700; justify-content: center; padding: 12px 17px; }.file-button input { display: none; } +.topbar-actions { align-items: center; display: flex; flex-wrap: wrap; gap: 10px; justify-content: flex-end; }.account-state { color: var(--muted); font-size: 12px; font-weight: 700; text-transform: capitalize; }.read-only-label { color: var(--muted); font-size: 12px; font-weight: 700; } @media (max-width: 720px) { .settings-grid { grid-template-columns: 1fr; } } .edit-button { background: transparent; border: 1px solid var(--line); color: var(--ink); padding: 7px 10px; }.edit-button:hover { background: var(--paper); } .form-hint { margin: -4px 0 4px; color: var(--muted); font-size: 0.85rem; } diff --git a/server.js b/server.js index 016df26..4e40774 100644 --- a/server.js +++ b/server.js @@ -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}`)); }