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:
@@ -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', {
|
||||
|
||||
Reference in New Issue
Block a user