- 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.
181 lines
7.8 KiB
JavaScript
181 lines
7.8 KiB
JavaScript
import { Router } from 'express';
|
|
import {
|
|
verifyPassword, createSession, destroySession, setSessionCookie, clearSessionCookie,
|
|
hashPassword, requireAdmin, requireUser,
|
|
getMainUser, getTrustedDevice, listDeviceUsers, trustDevice, setDeviceCookie,
|
|
isUserAllowedOnDevice
|
|
} from '../auth.js';
|
|
import { getDb } from '../db/index.js';
|
|
|
|
export const router = Router();
|
|
|
|
router.post('/login', (req, res) => {
|
|
const { username, password } = req.body || {};
|
|
if (!username || !password) return res.status(400).json({ error: 'username + password required' });
|
|
const user = getDb().prepare('SELECT * FROM users WHERE username = ?').get(username);
|
|
if (!user || !verifyPassword(password, user.password_hash)) {
|
|
return res.status(401).json({ error: 'invalid credentials' });
|
|
}
|
|
const { token, expires } = createSession(user.id);
|
|
setSessionCookie(res, token, expires);
|
|
res.json({ id: user.id, username: user.username, role: user.role });
|
|
});
|
|
|
|
router.post('/logout', (req, res) => {
|
|
destroySession(req.session?.token);
|
|
clearSessionCookie(res);
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
router.get('/me', (req, res) => {
|
|
if (!req.user) return res.status(401).json({ error: 'not signed in' });
|
|
const row = getDb().prepare(
|
|
'SELECT id, username, role, is_main, avatar_color, avatar_emoji FROM users WHERE id = ?'
|
|
).get(req.user.id);
|
|
res.json(row || req.user);
|
|
});
|
|
|
|
// Public list of users (for the favorite-tabs strip and avatar picker).
|
|
// Returns only safe cosmetic fields, no roles or timestamps.
|
|
router.get('/users/public', requireUser, (_req, res) => {
|
|
const rows = getDb().prepare(`
|
|
SELECT id, username, is_main, avatar_color, avatar_emoji
|
|
FROM users
|
|
ORDER BY is_main DESC, username COLLATE NOCASE
|
|
`).all();
|
|
res.json(rows);
|
|
});
|
|
|
|
// The designated "main" user (the shared/house identity). Anyone signed in
|
|
// may read it so the UI can pin the main tab and offer "Follow main".
|
|
router.get('/main', requireUser, (_req, res) => {
|
|
const main = getMainUser();
|
|
if (!main) return res.status(404).json({ error: 'no main user configured' });
|
|
res.json(main);
|
|
});
|
|
|
|
// ----- Trusted-device fast-switching -----
|
|
|
|
// What does *this* device know about itself? Returns the whitelist of users
|
|
// that can be switched to without a password. Empty object if untrusted.
|
|
router.get('/devices/me', (req, res) => {
|
|
const dev = getTrustedDevice(req);
|
|
if (!dev) return res.json({ trusted: false, users: [] });
|
|
res.json({
|
|
trusted: true,
|
|
id: dev.id,
|
|
label: dev.label,
|
|
users: listDeviceUsers(dev.id)
|
|
});
|
|
});
|
|
|
|
// Trust this device with a user whitelist. Admin-only; sets a long-lived
|
|
// `oradio_device` cookie. The current admin is auto-included.
|
|
router.post('/devices/trust', requireAdmin, (req, res) => {
|
|
const { label, user_ids } = req.body || {};
|
|
const ids = Array.isArray(user_ids) ? user_ids.map(Number).filter(Boolean) : [];
|
|
if (!ids.includes(req.user.id)) ids.push(req.user.id);
|
|
// Validate
|
|
const placeholders = ids.map(() => '?').join(',');
|
|
const valid = getDb().prepare(
|
|
`SELECT id FROM users WHERE id IN (${placeholders})`
|
|
).all(...ids).map((r) => r.id);
|
|
if (!valid.length) return res.status(400).json({ error: 'no valid users' });
|
|
const { id, token } = trustDevice({ label: label?.trim() || null, userIds: valid });
|
|
setDeviceCookie(res, token);
|
|
res.status(201).json({ id, label: label || null, users: listDeviceUsers(id) });
|
|
});
|
|
|
|
// Update the whitelist of the current trusted device (admin only).
|
|
router.patch('/devices/me', requireAdmin, (req, res) => {
|
|
const dev = getTrustedDevice(req);
|
|
if (!dev) return res.status(404).json({ error: 'device not trusted' });
|
|
const { label, user_ids } = req.body || {};
|
|
const db = getDb();
|
|
if (typeof label === 'string') {
|
|
db.prepare('UPDATE kiosk_devices SET label = ? WHERE id = ?').run(label.trim() || null, dev.id);
|
|
}
|
|
if (Array.isArray(user_ids)) {
|
|
const ids = user_ids.map(Number).filter(Boolean);
|
|
db.transaction(() => {
|
|
db.prepare('DELETE FROM kiosk_device_users WHERE device_id = ?').run(dev.id);
|
|
const stmt = db.prepare('INSERT OR IGNORE INTO kiosk_device_users (device_id, user_id) VALUES (?, ?)');
|
|
for (const uid of ids) stmt.run(dev.id, uid);
|
|
})();
|
|
}
|
|
res.json({ id: dev.id, users: listDeviceUsers(dev.id) });
|
|
});
|
|
|
|
// Fast switch: trade a trusted-device cookie + whitelisted username for a
|
|
// fresh session. No password required. The previous session is destroyed.
|
|
router.post('/switch', (req, res) => {
|
|
const dev = getTrustedDevice(req);
|
|
if (!dev) return res.status(403).json({ error: 'device not trusted' });
|
|
const username = String(req.body?.username || '').trim();
|
|
if (!username) return res.status(400).json({ error: 'username required' });
|
|
const user = getDb().prepare('SELECT id, username, role FROM users WHERE username = ?').get(username);
|
|
if (!user) return res.status(404).json({ error: 'unknown user' });
|
|
if (!isUserAllowedOnDevice(dev.id, user.id)) {
|
|
return res.status(403).json({ error: 'user not allowed on this device' });
|
|
}
|
|
// Kill the previous session before issuing a new one.
|
|
if (req.session?.token) destroySession(req.session.token);
|
|
const { token, expires } = createSession(user.id);
|
|
setSessionCookie(res, token, expires);
|
|
res.json({ id: user.id, username: user.username, role: user.role });
|
|
});
|
|
|
|
// Admin-only user management
|
|
router.get('/users', requireAdmin, (_req, res) => {
|
|
const users = getDb().prepare(
|
|
'SELECT id, username, role, is_main, avatar_color, avatar_emoji, created_at FROM users ORDER BY username'
|
|
).all();
|
|
res.json(users);
|
|
});
|
|
|
|
router.post('/users', requireAdmin, (req, res) => {
|
|
const { username, password, role = 'user' } = req.body || {};
|
|
if (!username || !password) return res.status(400).json({ error: 'username + password required' });
|
|
if (!['admin', 'user'].includes(role)) return res.status(400).json({ error: 'bad role' });
|
|
try {
|
|
const info = getDb().prepare('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)')
|
|
.run(username, hashPassword(password), role);
|
|
getDb().prepare('INSERT INTO profiles (user_id, display_name) VALUES (?, ?)')
|
|
.run(info.lastInsertRowid, username);
|
|
res.status(201).json({ id: info.lastInsertRowid, username, role });
|
|
} catch (err) {
|
|
if (String(err).includes('UNIQUE')) return res.status(409).json({ error: 'username taken' });
|
|
throw err;
|
|
}
|
|
});
|
|
|
|
router.patch('/users/:id', requireAdmin, (req, res) => {
|
|
const id = Number(req.params.id);
|
|
const { password, role, avatar_color, avatar_emoji, is_main } = req.body || {};
|
|
const db = getDb();
|
|
if (password) db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(hashPassword(password), id);
|
|
if (role && ['admin', 'user'].includes(role)) {
|
|
db.prepare('UPDATE users SET role = ? WHERE id = ?').run(role, id);
|
|
}
|
|
if (typeof avatar_color === 'string') {
|
|
db.prepare('UPDATE users SET avatar_color = ? WHERE id = ?').run(avatar_color || null, id);
|
|
}
|
|
if (typeof avatar_emoji === 'string') {
|
|
db.prepare('UPDATE users SET avatar_emoji = ? WHERE id = ?').run(avatar_emoji || null, id);
|
|
}
|
|
if (is_main === true) {
|
|
db.transaction(() => {
|
|
db.prepare('UPDATE users SET is_main = 0 WHERE is_main = 1 AND id != ?').run(id);
|
|
db.prepare('UPDATE users SET is_main = 1 WHERE id = ?').run(id);
|
|
})();
|
|
}
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
router.delete('/users/:id', requireAdmin, (req, res) => {
|
|
const id = Number(req.params.id);
|
|
if (id === req.user.id) return res.status(400).json({ error: 'cannot delete self' });
|
|
getDb().prepare('DELETE FROM users WHERE id = ?').run(id);
|
|
res.json({ ok: true });
|
|
});
|