Add user accounting
This commit is contained in:
@@ -16,7 +16,9 @@ Service records can also include optional used parts and labor lines. Parts supp
|
|||||||
|
|
||||||
The **Settings** view provides complete JSON data backup and restore. **Download backup** exports all customers, employees, vehicles, appointments, service records, service-order items, invoices, and user accounts. **Choose backup file** imports a backup after confirmation; importing replaces the current database contents in one transaction, so a failed import leaves the existing data unchanged. Keep exported backup files secure because they contain all workshop data.
|
The **Settings** view provides complete JSON data backup and restore. **Download backup** exports all customers, employees, vehicles, appointments, service records, service-order items, invoices, and user accounts. **Choose backup file** imports a backup after confirmation; importing replaces the current database contents in one transaction, so a failed import leaves the existing data unchanged. Keep exported backup files secure because they contain all workshop data.
|
||||||
|
|
||||||
The **Accounts** view supports reading, adding, editing, and deleting users with `read`, `add`, and `delete` rights. A default `admin` account is created automatically, and the last administrator cannot be deleted. These permissions are currently stored as staff access profiles; login/session enforcement can be added when authentication is required.
|
The dashboard supports user sign-in and permission-based access. Without signing in, all views are read-only. Administrators can manage user accounts, add and modify workshop data, restore backups, and delete data. Technicians and service advisors can add and modify workshop data but cannot delete anything. Viewer accounts are read-only. The **Accounts** view is visible to everyone, but only administrators can manage accounts.
|
||||||
|
|
||||||
|
A default `admin` account is created automatically. Its initial password is `change-me-in-production`, unless `ADMIN_PASSWORD` is set in `.env` before the web container starts. Sign in at the top of the dashboard and change the initial password by editing the administrator account. Do not use the default password outside local development.
|
||||||
|
|
||||||
Check that the database is ready:
|
Check that the database is ready:
|
||||||
|
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ CREATE TABLE IF NOT EXISTS app_users (
|
|||||||
can_read BOOLEAN NOT NULL DEFAULT TRUE,
|
can_read BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
can_add BOOLEAN NOT NULL DEFAULT FALSE,
|
can_add BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
can_delete BOOLEAN NOT NULL DEFAULT FALSE,
|
can_delete BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
password_hash TEXT,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
+23
-5
@@ -1,4 +1,4 @@
|
|||||||
const state = { orders: [], users: [], vehicles: [], recordItems: [] };
|
const state = { orders: [], users: [], vehicles: [], recordItems: [], user: null };
|
||||||
const $ = (selector) => document.querySelector(selector);
|
const $ = (selector) => document.querySelector(selector);
|
||||||
|
|
||||||
async function request(url, options) {
|
async function request(url, options) {
|
||||||
@@ -13,6 +13,10 @@ function formatTime(value) { return new Date(value).toLocaleTimeString([], { hou
|
|||||||
function titleCase(value) { return value.replaceAll('_', ' '); }
|
function titleCase(value) { return value.replaceAll('_', ' '); }
|
||||||
function emptyMessage(message) { return `<div class="empty-state">${message}</div>`; }
|
function emptyMessage(message) { return `<div class="empty-state">${message}</div>`; }
|
||||||
function escapeHtml(value) { return String(value ?? '').replace(/[&<>'"]/g, (character) => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[character])); }
|
function escapeHtml(value) { return String(value ?? '').replace(/[&<>'"]/g, (character) => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[character])); }
|
||||||
|
function canModify() { return ['admin', 'technician', 'service_advisor'].includes(state.user?.role) || state.user?.can_add === true; }
|
||||||
|
function canDelete() { return state.user?.role === 'admin'; }
|
||||||
|
async function loadAuth() { try { state.user = (await request('/api/auth/me')).user; updateAuthUi(); } catch (error) { showError(error); } }
|
||||||
|
function updateAuthUi() { const signedIn = Boolean(state.user); $('#current-user').textContent = signedIn ? `${state.user.display_name} · ${state.user.role.replaceAll('_', ' ')}` : 'Read-only mode'; $('#login-button').hidden = signedIn; $('#logout-button').hidden = !signedIn; document.querySelectorAll('#new-customer-button, #new-customer-button-secondary, #new-vehicle-button, #new-record-button').forEach((button) => { button.hidden = !canModify(); }); $('#new-user-button').hidden = !canDelete(); if (state.users.length) renderUsers(); renderOrders(); }
|
||||||
|
|
||||||
async function loadOverview() {
|
async function loadOverview() {
|
||||||
try {
|
try {
|
||||||
@@ -53,7 +57,7 @@ function renderOrders() {
|
|||||||
const serviceDate = String(order.service_date || '').slice(0, 10);
|
const serviceDate = String(order.service_date || '').slice(0, 10);
|
||||||
return (!vehicleFilter || vehicleText.includes(vehicleFilter)) && (!customerFilter || customerText.includes(customerFilter)) && (!technicianFilter || technicianText.includes(technicianFilter)) && (!filters.dateFrom || serviceDate >= filters.dateFrom) && (!filters.dateTo || serviceDate <= filters.dateTo) && (!filters.status || order.status === filters.status);
|
return (!vehicleFilter || vehicleText.includes(vehicleFilter)) && (!customerFilter || customerText.includes(customerFilter)) && (!technicianFilter || technicianText.includes(technicianFilter)) && (!filters.dateFrom || serviceDate >= filters.dateFrom) && (!filters.dateTo || serviceDate <= filters.dateTo) && (!filters.status || order.status === filters.status);
|
||||||
});
|
});
|
||||||
$('#orders-table').innerHTML = visibleOrders.length ? visibleOrders.map((order) => `<tr><td>${escapeHtml(order.service_date)}</td><td>${escapeHtml(order.vin || '—')}</td><td><strong>${escapeHtml(order.license_plate)}</strong></td><td>${escapeHtml(`${order.make || ''} ${order.model || ''}`.trim())}</td><td>${Number(order.mileage).toLocaleString()} km</td><td>${escapeHtml(order.customer_name)}</td><td>${escapeHtml(order.technician)}</td><td>${escapeHtml(order.complaint)}</td><td>€${Number(order.total || 0).toFixed(2)}</td><td><select class="status-select" data-order-id="${order.service_order_id}"><option value="${order.status}" selected>${titleCase(order.status)}</option><option value="open">Open</option><option value="in_progress">In progress</option><option value="waiting_for_parts">Waiting for parts</option><option value="ready">Ready</option><option value="closed">Closed</option></select></td><td><button class="edit-button" data-record-id="${order.service_order_id}">Edit</button></td></tr>`).join('') : `<tr><td colspan="11">${emptyMessage(state.orders.length ? 'No service records match these filters.' : 'No service records yet.')}</td></tr>`;
|
$('#orders-table').innerHTML = visibleOrders.length ? visibleOrders.map((order) => `<tr><td>${escapeHtml(order.service_date)}</td><td>${escapeHtml(order.vin || '—')}</td><td><strong>${escapeHtml(order.license_plate)}</strong></td><td>${escapeHtml(`${order.make || ''} ${order.model || ''}`.trim())}</td><td>${Number(order.mileage).toLocaleString()} km</td><td>${escapeHtml(order.customer_name)}</td><td>${escapeHtml(order.technician)}</td><td>${escapeHtml(order.complaint)}</td><td>€${Number(order.total || 0).toFixed(2)}</td><td><select class="status-select" data-order-id="${order.service_order_id}" ${canModify() ? '' : 'disabled'}><option value="${order.status}" selected>${titleCase(order.status)}</option><option value="open">Open</option><option value="in_progress">In progress</option><option value="waiting_for_parts">Waiting for parts</option><option value="ready">Ready</option><option value="closed">Closed</option></select></td><td>${canModify() ? `<button class="edit-button" data-record-id="${order.service_order_id}">Edit</button>` : '<span class="read-only-label">Read only</span>'}</td></tr>`).join('') : `<tr><td colspan="11">${emptyMessage(state.orders.length ? 'No service records match these filters.' : 'No service records yet.')}</td></tr>`;
|
||||||
document.querySelectorAll('.status-select').forEach((select) => select.addEventListener('change', updateOrderStatus));
|
document.querySelectorAll('.status-select').forEach((select) => select.addEventListener('change', updateOrderStatus));
|
||||||
document.querySelectorAll('.edit-button[data-record-id]').forEach((button) => button.addEventListener('click', () => editRecord(state.orders.find((order) => order.service_order_id === button.dataset.recordId))));
|
document.querySelectorAll('.edit-button[data-record-id]').forEach((button) => button.addEventListener('click', () => editRecord(state.orders.find((order) => order.service_order_id === button.dataset.recordId))));
|
||||||
}
|
}
|
||||||
@@ -68,10 +72,14 @@ async function loadVehicles() {
|
|||||||
async function loadUsers() {
|
async function loadUsers() {
|
||||||
try {
|
try {
|
||||||
state.users = await request('/api/users');
|
state.users = await request('/api/users');
|
||||||
$('#users-table').innerHTML = state.users.map((user) => `<tr><td><strong>${escapeHtml(user.display_name)}</strong>${escapeHtml(user.username)}<br />${escapeHtml(user.email || '')}</td><td>${escapeHtml(titleCase(user.role))}</td><td><span class="permission ${user.can_read ? 'allowed' : ''}">${user.can_read ? 'Yes' : 'No'}</span></td><td><span class="permission ${user.can_add ? 'allowed' : ''}">${user.can_add ? 'Yes' : 'No'}</span></td><td><span class="permission ${user.can_delete ? 'allowed' : ''}">${user.can_delete ? 'Yes' : 'No'}</span></td><td><button class="edit-button" data-user-id="${user.user_id}">Edit</button> <button class="delete-button" data-user-id="${user.user_id}" data-user-name="${escapeHtml(user.display_name)}">Delete</button></td></tr>`).join('');
|
renderUsers();
|
||||||
|
} catch (error) { showError(error); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderUsers() {
|
||||||
|
$('#users-table').innerHTML = state.users.map((user) => `<tr><td><strong>${escapeHtml(user.display_name)}</strong>${escapeHtml(user.username)}<br />${escapeHtml(user.email || '')}</td><td>${escapeHtml(titleCase(user.role))}</td><td><span class="permission ${user.can_read ? 'allowed' : ''}">${user.can_read ? 'Yes' : 'No'}</span></td><td><span class="permission ${user.can_add ? 'allowed' : ''}">${user.can_add ? 'Yes' : 'No'}</span></td><td><span class="permission ${user.can_delete ? 'allowed' : ''}">${user.can_delete ? 'Yes' : 'No'}</span></td><td>${canDelete() ? `<button class="edit-button" data-user-id="${user.user_id}">Edit</button> <button class="delete-button" data-user-id="${user.user_id}" data-user-name="${escapeHtml(user.display_name)}">Delete</button>` : '<span class="read-only-label">Admin only</span>'}</td></tr>`).join('');
|
||||||
document.querySelectorAll('.delete-button').forEach((button) => button.addEventListener('click', deleteUser));
|
document.querySelectorAll('.delete-button').forEach((button) => button.addEventListener('click', deleteUser));
|
||||||
document.querySelectorAll('.edit-button[data-user-id]').forEach((button) => button.addEventListener('click', () => editUser(state.users.find((user) => user.user_id === button.dataset.userId))));
|
document.querySelectorAll('.edit-button[data-user-id]').forEach((button) => button.addEventListener('click', () => editUser(state.users.find((user) => user.user_id === button.dataset.userId))));
|
||||||
} catch (error) { showError(error); }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function editUser(user) {
|
function editUser(user) {
|
||||||
@@ -122,6 +130,10 @@ function switchView(viewName) {
|
|||||||
|
|
||||||
document.querySelectorAll('[data-view]').forEach((button) => button.addEventListener('click', () => switchView(button.dataset.view)));
|
document.querySelectorAll('[data-view]').forEach((button) => button.addEventListener('click', () => switchView(button.dataset.view)));
|
||||||
document.querySelectorAll('#new-customer-button, #new-customer-button-secondary').forEach((button) => button.addEventListener('click', () => $('#customer-dialog').showModal()));
|
document.querySelectorAll('#new-customer-button, #new-customer-button-secondary').forEach((button) => button.addEventListener('click', () => $('#customer-dialog').showModal()));
|
||||||
|
$('#login-button').addEventListener('click', () => $('#login-dialog').showModal());
|
||||||
|
$('#cancel-login-button').addEventListener('click', () => $('#login-dialog').close());
|
||||||
|
$('#close-login-button').addEventListener('click', () => $('#login-dialog').close());
|
||||||
|
$('#logout-button').addEventListener('click', async () => { await request('/api/auth/logout', { method: 'POST' }); state.user = null; updateAuthUi(); });
|
||||||
$('#cancel-customer-button').addEventListener('click', () => $('#customer-dialog').close());
|
$('#cancel-customer-button').addEventListener('click', () => $('#customer-dialog').close());
|
||||||
$('#close-customer-button').addEventListener('click', () => $('#customer-dialog').close());
|
$('#close-customer-button').addEventListener('click', () => $('#customer-dialog').close());
|
||||||
$('#new-user-button').addEventListener('click', () => { const form = $('#user-form'); delete form.dataset.editId; form.reset(); form.elements.username.disabled = false; $('#user-dialog-title').textContent = 'Add user'; $('#user-submit-button').textContent = 'Create user'; $('#user-dialog').showModal(); });
|
$('#new-user-button').addEventListener('click', () => { const form = $('#user-form'); delete form.dataset.editId; form.reset(); form.elements.username.disabled = false; $('#user-dialog-title').textContent = 'Add user'; $('#user-submit-button').textContent = 'Create user'; $('#user-dialog').showModal(); });
|
||||||
@@ -193,11 +205,16 @@ $('#user-form').addEventListener('submit', async (event) => {
|
|||||||
try {
|
try {
|
||||||
const form = new FormData(event.target);
|
const form = new FormData(event.target);
|
||||||
const editId = event.target.dataset.editId;
|
const editId = event.target.dataset.editId;
|
||||||
await request(editId ? `/api/users/${editId}` : '/api/users', { method: editId ? 'PATCH' : 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: form.get('username'), displayName: form.get('displayName'), email: form.get('email'), role: form.get('role'), canRead: form.has('canRead'), canAdd: form.has('canAdd'), canDelete: form.has('canDelete') }) });
|
await request(editId ? `/api/users/${editId}` : '/api/users', { method: editId ? 'PATCH' : 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: form.get('username'), displayName: form.get('displayName'), email: form.get('email'), password: form.get('password'), role: form.get('role'), canRead: form.has('canRead'), canAdd: form.has('canAdd'), canDelete: form.has('canDelete') }) });
|
||||||
event.target.reset(); delete event.target.dataset.editId; event.target.elements.username.disabled = false; $('#user-dialog-title').textContent = 'Add user'; $('#user-submit-button').textContent = 'Create user'; $('#user-dialog').close(); await loadUsers();
|
event.target.reset(); delete event.target.dataset.editId; event.target.elements.username.disabled = false; $('#user-dialog-title').textContent = 'Add user'; $('#user-submit-button').textContent = 'Create user'; $('#user-dialog').close(); await loadUsers();
|
||||||
} catch (error) { showError(error); }
|
} catch (error) { showError(error); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$('#login-form').addEventListener('submit', async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
try { state.user = (await request('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(Object.fromEntries(new FormData(event.target))) })).user; event.target.reset(); $('#login-dialog').close(); updateAuthUi(); } catch (error) { showError(error); }
|
||||||
|
});
|
||||||
|
|
||||||
$('#record-form').addEventListener('submit', async (event) => {
|
$('#record-form').addEventListener('submit', async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
try {
|
try {
|
||||||
@@ -209,4 +226,5 @@ $('#record-form').addEventListener('submit', async (event) => {
|
|||||||
} catch (error) { showError(error); }
|
} catch (error) { showError(error); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
loadAuth();
|
||||||
loadOverview();
|
loadOverview();
|
||||||
+3
-2
@@ -22,7 +22,7 @@
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main class="main-content">
|
<main class="main-content">
|
||||||
<header class="topbar"><div><p class="eyebrow">Wednesday, August 26, 2026</p><h1 id="page-title">Good morning, workshop.</h1></div><button class="primary-button" id="new-customer-button">+ New customer</button></header>
|
<header class="topbar"><div><p class="eyebrow">Wednesday, August 26, 2026</p><h1 id="page-title">Good morning, workshop.</h1></div><div class="topbar-actions"><span id="current-user" class="account-state">Read-only mode</span><button class="secondary-button" id="login-button">Sign in</button><button class="secondary-button" id="logout-button" hidden>Sign out</button><button class="primary-button" id="new-customer-button">+ New customer</button></div></header>
|
||||||
<div id="notice" class="notice" role="status"></div>
|
<div id="notice" class="notice" role="status"></div>
|
||||||
|
|
||||||
<section id="overview-view" class="view active-view">
|
<section id="overview-view" class="view active-view">
|
||||||
@@ -50,7 +50,8 @@
|
|||||||
|
|
||||||
<dialog id="customer-dialog"><form method="dialog" id="customer-form"><div class="dialog-heading"><div><p class="eyebrow">Directory</p><h2>Add customer</h2></div><button class="close-button" type="button" id="close-customer-button" aria-label="Close">×</button></div><label>First name<input name="firstName" required /></label><label>Last name<input name="lastName" required /></label><label>Email<input name="email" type="email" /></label><label>Phone<input name="phone" type="tel" /></label><div class="dialog-actions"><button class="secondary-button" type="button" id="cancel-customer-button">Cancel</button><button class="primary-button" value="default">Save customer</button></div></form></dialog>
|
<dialog id="customer-dialog"><form method="dialog" id="customer-form"><div class="dialog-heading"><div><p class="eyebrow">Directory</p><h2>Add customer</h2></div><button class="close-button" type="button" id="close-customer-button" aria-label="Close">×</button></div><label>First name<input name="firstName" required /></label><label>Last name<input name="lastName" required /></label><label>Email<input name="email" type="email" /></label><label>Phone<input name="phone" type="tel" /></label><div class="dialog-actions"><button class="secondary-button" type="button" id="cancel-customer-button">Cancel</button><button class="primary-button" value="default">Save customer</button></div></form></dialog>
|
||||||
<dialog id="vehicle-dialog"><form method="dialog" id="vehicle-form"><div class="dialog-heading"><div><p class="eyebrow">Vehicle register</p><h2>Add vehicle</h2></div><button class="close-button" type="button" id="close-vehicle-button" aria-label="Close">×</button></div><p class="form-hint">Use an existing customer email to link this vehicle to that customer.</p><label>Customer name<input name="customerName" required /></label><label>Customer email<input name="customerEmail" type="email" /></label><label>Customer phone<input name="customerPhone" type="tel" /></label><label>VIN<input name="vin" minlength="17" maxlength="17" required /></label><label>License plate number<input name="licensePlate" required /></label><label>Make<input name="make" required /></label><label>Model<input name="model" required /></label><label>Model year<input name="modelYear" type="number" min="1886" max="2100" /></label><label>Engine<input name="engine" /></label><label>Transmission<input name="transmission" /></label><label>Fuel type<input name="fuelType" /></label><label>Color<input name="color" /></label><label>Car mileage<input name="mileage" type="number" min="0" step="1" value="0" required /></label><div class="dialog-actions"><button class="secondary-button" type="button" id="cancel-vehicle-button">Cancel</button><button class="primary-button" value="default">Create vehicle</button></div></form></dialog>
|
<dialog id="vehicle-dialog"><form method="dialog" id="vehicle-form"><div class="dialog-heading"><div><p class="eyebrow">Vehicle register</p><h2>Add vehicle</h2></div><button class="close-button" type="button" id="close-vehicle-button" aria-label="Close">×</button></div><p class="form-hint">Use an existing customer email to link this vehicle to that customer.</p><label>Customer name<input name="customerName" required /></label><label>Customer email<input name="customerEmail" type="email" /></label><label>Customer phone<input name="customerPhone" type="tel" /></label><label>VIN<input name="vin" minlength="17" maxlength="17" required /></label><label>License plate number<input name="licensePlate" required /></label><label>Make<input name="make" required /></label><label>Model<input name="model" required /></label><label>Model year<input name="modelYear" type="number" min="1886" max="2100" /></label><label>Engine<input name="engine" /></label><label>Transmission<input name="transmission" /></label><label>Fuel type<input name="fuelType" /></label><label>Color<input name="color" /></label><label>Car mileage<input name="mileage" type="number" min="0" step="1" value="0" required /></label><div class="dialog-actions"><button class="secondary-button" type="button" id="cancel-vehicle-button">Cancel</button><button class="primary-button" value="default">Create vehicle</button></div></form></dialog>
|
||||||
<dialog id="user-dialog"><form method="dialog" id="user-form"><div class="dialog-heading"><div><p class="eyebrow">Access control</p><h2 id="user-dialog-title">Add user</h2></div><button class="close-button" type="button" id="close-user-button" aria-label="Close">×</button></div><label>Username<input name="username" required /></label><label>Display name<input name="displayName" required /></label><label>Email<input name="email" type="email" /></label><label>Role<select name="role"><option value="viewer">Viewer</option><option value="technician">Technician</option><option value="service_advisor">Service advisor</option><option value="admin">Administrator</option></select></label><fieldset><legend>Rights</legend><label class="check-label"><input name="canRead" type="checkbox" checked /> Read records</label><label class="check-label"><input name="canAdd" type="checkbox" /> Add records</label><label class="check-label"><input name="canDelete" type="checkbox" /> Delete records</label></fieldset><div class="dialog-actions"><button class="secondary-button" type="button" id="cancel-user-button">Cancel</button><button class="primary-button" id="user-submit-button" value="default">Create user</button></div></form></dialog>
|
<dialog id="user-dialog"><form method="dialog" id="user-form"><div class="dialog-heading"><div><p class="eyebrow">Access control</p><h2 id="user-dialog-title">Add user</h2></div><button class="close-button" type="button" id="close-user-button" aria-label="Close">×</button></div><label>Username<input name="username" required /></label><label>Display name<input name="displayName" required /></label><label>Email<input name="email" type="email" /></label><label>Password<input name="password" type="password" minlength="8" /><small class="form-hint">Required for new users. Leave blank when editing to keep the current password.</small></label><label>Role<select name="role"><option value="viewer">Viewer</option><option value="technician">Technician</option><option value="service_advisor">Service advisor</option><option value="admin">Administrator</option></select></label><fieldset><legend>Rights</legend><label class="check-label"><input name="canRead" type="checkbox" checked /> Read records</label><label class="check-label"><input name="canAdd" type="checkbox" /> Add records</label><label class="check-label"><input name="canDelete" type="checkbox" /> Delete records</label></fieldset><div class="dialog-actions"><button class="secondary-button" type="button" id="cancel-user-button">Cancel</button><button class="primary-button" id="user-submit-button" value="default">Create user</button></div></form></dialog>
|
||||||
|
<dialog id="login-dialog"><form method="dialog" id="login-form"><div class="dialog-heading"><div><p class="eyebrow">Workshop access</p><h2>Sign in</h2></div><button class="close-button" type="button" id="close-login-button" aria-label="Close">×</button></div><p class="form-hint">Without an account, the dashboard remains read-only.</p><label>Username<input name="username" autocomplete="username" required /></label><label>Password<input name="password" type="password" autocomplete="current-password" required /></label><div class="dialog-actions"><button class="secondary-button" type="button" id="cancel-login-button">Cancel</button><button class="primary-button" value="default">Sign in</button></div></form></dialog>
|
||||||
<dialog id="record-dialog"><form method="dialog" id="record-form"><div class="dialog-heading"><div><p class="eyebrow">Workshop floor</p><h2 id="record-dialog-title">Add service record</h2></div><button class="close-button" type="button" id="close-record-button" aria-label="Close">×</button></div><p class="form-hint">Enter a known VIN or license plate to autofill the vehicle and customer. Use customer email when names are shared.</p><label>Service date<input name="serviceDate" type="date" required /></label><label>VIN<input name="vin" minlength="17" maxlength="17" /></label><label>License plate number<input name="licensePlate" /></label><label>Make<input name="make" required /></label><label>Model<input name="model" required /></label><label>Model year<input name="modelYear" type="number" min="1886" max="2100" /></label><label>Engine<input name="engine" placeholder="e.g. 2.0 TDI" /></label><label>Transmission<input name="transmission" placeholder="e.g. Automatic" /></label><label>Fuel type<input name="fuelType" placeholder="e.g. Petrol, diesel, electric" /></label><label>Color<input name="color" /></label><label>Customer name<input name="customerName" placeholder="First and last name" required /></label><label>Customer email<input name="customerEmail" type="email" /></label><label>Customer phone<input name="customerPhone" type="tel" /></label><label>Car mileage<input name="mileage" type="number" min="0" step="1" required /></label><label>Technician<input name="technicianName" required /></label><label>Complaint or service requested<textarea name="complaint" rows="3" required></textarea></label><section class="items-section"><div class="items-heading"><div><p class="eyebrow">Optional billing</p><h3>Used parts and labor</h3></div><div class="item-add-actions"><button class="secondary-button" type="button" id="add-part-button">+ Part</button><button class="secondary-button" type="button" id="add-labor-button">+ Labor</button></div></div><div id="service-items"></div><strong class="items-total">Total: <span id="service-items-total">€0.00</span></strong></section><div class="dialog-actions"><button class="secondary-button" type="button" id="cancel-record-button">Cancel</button><button class="primary-button" id="record-submit-button" value="default">Create service record</button></div></form></dialog>
|
<dialog id="record-dialog"><form method="dialog" id="record-form"><div class="dialog-heading"><div><p class="eyebrow">Workshop floor</p><h2 id="record-dialog-title">Add service record</h2></div><button class="close-button" type="button" id="close-record-button" aria-label="Close">×</button></div><p class="form-hint">Enter a known VIN or license plate to autofill the vehicle and customer. Use customer email when names are shared.</p><label>Service date<input name="serviceDate" type="date" required /></label><label>VIN<input name="vin" minlength="17" maxlength="17" /></label><label>License plate number<input name="licensePlate" /></label><label>Make<input name="make" required /></label><label>Model<input name="model" required /></label><label>Model year<input name="modelYear" type="number" min="1886" max="2100" /></label><label>Engine<input name="engine" placeholder="e.g. 2.0 TDI" /></label><label>Transmission<input name="transmission" placeholder="e.g. Automatic" /></label><label>Fuel type<input name="fuelType" placeholder="e.g. Petrol, diesel, electric" /></label><label>Color<input name="color" /></label><label>Customer name<input name="customerName" placeholder="First and last name" required /></label><label>Customer email<input name="customerEmail" type="email" /></label><label>Customer phone<input name="customerPhone" type="tel" /></label><label>Car mileage<input name="mileage" type="number" min="0" step="1" required /></label><label>Technician<input name="technicianName" required /></label><label>Complaint or service requested<textarea name="complaint" rows="3" required></textarea></label><section class="items-section"><div class="items-heading"><div><p class="eyebrow">Optional billing</p><h3>Used parts and labor</h3></div><div class="item-add-actions"><button class="secondary-button" type="button" id="add-part-button">+ Part</button><button class="secondary-button" type="button" id="add-labor-button">+ Labor</button></div></div><div id="service-items"></div><strong class="items-total">Total: <span id="service-items-total">€0.00</span></strong></section><div class="dialog-actions"><button class="secondary-button" type="button" id="cancel-record-button">Cancel</button><button class="primary-button" id="record-submit-button" value="default">Create service record</button></div></form></dialog>
|
||||||
<script src="/app.js"></script>
|
<script src="/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -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); }
|
.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); }
|
.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; }
|
.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; } }
|
@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); }
|
.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; }
|
.form-hint { margin: -4px 0 4px; color: var(--muted); font-size: 0.85rem; }
|
||||||
|
|||||||
@@ -1,10 +1,44 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
|
const crypto = require('node:crypto');
|
||||||
const { Pool } = require('pg');
|
const { Pool } = require('pg');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const port = Number(process.env.PORT || 3000);
|
const port = Number(process.env.PORT || 3000);
|
||||||
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
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) {
|
function normalizeItems(items) {
|
||||||
if (!Array.isArray(items)) return [];
|
if (!Array.isArray(items)) return [];
|
||||||
@@ -26,6 +60,34 @@ async function insertItems(client, serviceOrderId, items) {
|
|||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use(express.static(path.join(__dirname, 'public')));
|
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 = [
|
const dataTables = [
|
||||||
{ name: 'customers', columns: ['customer_id', 'first_name', 'last_name', 'email', 'phone', 'created_at'] },
|
{ 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'] },
|
{ 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) => {
|
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'];
|
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 {
|
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]);
|
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.' }); }
|
} 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) => {
|
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'];
|
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.' });
|
if (!displayName || !allowedRoles.includes(role)) return response.status(400).json({ error: 'Display name and a valid role are required.' });
|
||||||
try {
|
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.' });
|
if (!result.rowCount) return response.status(404).json({ error: 'User account not found.' });
|
||||||
response.json(result.rows[0]);
|
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.' }); }
|
} 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(`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 DEFAULT CURRENT_DATE`);
|
||||||
await pool.query(`ALTER TABLE service_orders ALTER COLUMN service_date SET NOT NULL`);
|
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(`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("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('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}`));
|
app.listen(port, () => console.log(`Car service web app listening on port ${port}`));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user