feat: add multi-user support for favorites management and room clock synchronization

- Implemented a new API endpoint for retrieving and managing user favorites in /api/users.
- Added functionality for admins to edit the shared "main" user's favorites.
- Created a one-shot DB smoke test script for verifying multi-user kiosk migrations.
- Introduced a RoomClock class for synchronizing server time across clients using WebSocket.
This commit is contained in:
Marco Mooren
2026-05-13 13:53:12 +02:00
parent f6cdfd975c
commit 29423288ca
41 changed files with 4229 additions and 275 deletions

View File

@@ -16,6 +16,7 @@
import { api } from '../shared/api.js';
import { el, clear } from '../shared/dom.js';
import { Player } from '../player.js';
import { mountDebugPane } from '../shared/debug.js';
const app = document.getElementById('app');
const state = {
@@ -50,6 +51,9 @@ async function bootstrap() {
return;
}
await refresh();
// Admin doesn't run a RoomClock or WS — the pane mostly shows the
// audition player state (useful for diagnosing broken stations).
mountDebugPane({ player: preview, clock: null, ws: null, role: 'admin' });
render();
}
@@ -932,13 +936,48 @@ function renderRooms(root) {
function renderUsers(root) {
root.appendChild(el('div', { class: 'bar' },
el('h2', { style: { margin: 0, flex: 1 } }, 'Users'),
el('button', { class: 'btn', onClick: openTrustDeviceDialog }, '🔑 Trust this device'),
' ',
el('button', { class: 'btn primary', onClick: openUserDialog }, '+ Add user')
));
root.appendChild(el('table', {},
el('thead', {}, el('tr', {}, el('th', {}, 'Username'), el('th', {}, 'Role'), el('th', {}, 'Created'), el('th', {}, ''))),
el('thead', {}, el('tr', {},
el('th', {}, 'Username'),
el('th', {}, 'Role'),
el('th', {}, 'Main'),
el('th', {}, 'Avatar'),
el('th', {}, 'Created'),
el('th', {}, ''))),
el('tbody', {}, ...state.users.map((u) => el('tr', {},
el('td', {}, u.username),
el('td', {}, u.role),
el('td', {}, u.is_main ? '★ main' : el('button', {
class: 'btn', title: 'Promote to main (shared) user',
onClick: async () => {
if (!confirm(`Make ${u.username} the shared/main user?`)) return;
await api.patch(`/api/auth/users/${u.id}`, { is_main: true });
await refresh(); render();
}
}, 'Make main')),
el('td', {},
el('span', {
style: {
display: 'inline-grid', placeItems: 'center', width: '24px', height: '24px',
background: u.avatar_color || '#ff7a3d', color: '#1a0a00', fontWeight: '800', fontSize: '12px'
}
}, u.avatar_emoji || u.username.slice(0, 1).toUpperCase()),
' ',
el('button', {
class: 'btn', onClick: async () => {
const emoji = prompt(`Avatar emoji / letter for ${u.username} (leave empty to clear):`, u.avatar_emoji || '');
if (emoji === null) return;
const color = prompt(`Avatar color (hex, e.g. #ff7a3d):`, u.avatar_color || '#ff7a3d');
if (color === null) return;
await api.patch(`/api/auth/users/${u.id}`, { avatar_emoji: emoji, avatar_color: color });
await refresh(); render();
}
}, 'Edit')
),
el('td', {}, u.created_at),
el('td', {},
el('button', {
@@ -966,6 +1005,61 @@ function renderUsers(root) {
));
}
async function openTrustDeviceDialog() {
// Snapshot of current state to pre-check existing whitelist.
let info = { trusted: false, users: [], label: '' };
try { info = await api.get('/api/auth/devices/me'); } catch { }
const existing = new Set((info.users || []).map((u) => u.id));
const dlg = el('dialog');
dlg.appendChild(el('form', {
method: 'dialog', onSubmit: async (e) => {
e.preventDefault();
const fd = new FormData(e.target);
const ids = [...fd.getAll('user_id')].map(Number);
const label = fd.get('label') || null;
try {
if (info.trusted) {
await api.patch('/api/auth/devices/me', { label, user_ids: ids });
alert('Device whitelist updated.');
} else {
await api.post('/api/auth/devices/trust', { label, user_ids: ids });
alert('This device is now trusted. Fast-switching is enabled for the selected users.');
}
} catch (err) {
alert(err.message || 'Failed');
return;
}
dlg.close();
}
},
el('h2', {}, info.trusted ? 'Edit trusted device' : 'Trust this device'),
el('p', { style: { color: '#8a90a0', fontSize: '13px' } },
'Listed users can fast-switch on this device without a password. ',
'The cookie is HttpOnly and lasts 1 year.'),
el('div', { class: 'row' },
el('label', {}, 'Device label'),
el('input', { name: 'label', value: info.label || '', placeholder: 'e.g. Kitchen kiosk' })),
el('div', { style: { maxHeight: '300px', overflowY: 'auto', border: '1px solid #262b36', padding: '8px' } },
...state.users.map((u) => el('label', {
style: { display: 'flex', alignItems: 'center', gap: '8px', padding: '4px 6px', cursor: 'pointer' }
},
el('input', {
type: 'checkbox', name: 'user_id', value: String(u.id),
checked: existing.has(u.id) || u.id === state.user.id
}),
el('span', {}, u.username),
u.is_main ? el('span', { style: { color: '#ffb37a', fontSize: '11px' } }, ' ★ main') : null,
u.id === state.user.id ? el('span', { style: { color: '#5d6373', fontSize: '11px' } }, ' (you)') : null,
))),
el('div', { class: 'actions' },
el('button', { class: 'btn', type: 'button', onClick: () => dlg.close() }, 'Cancel'),
el('button', { class: 'btn primary', type: 'submit' }, info.trusted ? 'Update' : 'Trust device'))
));
document.body.appendChild(dlg);
dlg.showModal();
dlg.addEventListener('close', () => dlg.remove());
}
function openUserDialog() {
const dlg = el('dialog');
dlg.appendChild(el('form', {