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 }); });