From 388e9e6ab8ccadecf821aa3910a38033662c7956 Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Wed, 29 Apr 2026 20:45:25 -0500 Subject: [PATCH] Admin password reset + widget visibility fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Password reset for other users: - New PUT /api/auth/users/:id/password endpoint - Superadmin can reset any local user; admin can reset role=user members of teams they own only (cannot reset other admins or superadmins, cannot self-reset — that goes through PUT /me with current_password) - OAuth users are excluded (no password to reset) - Rate-limited 20 req/min/IP to cap blast radius if an admin session is compromised - Explicit audit log entry "password_reset_for_user / target: " on every reset; activity logger's summarizeAction never reads the password field, so the password value is not stored anywhere Frontend: Reset Password button in the Admin user table and Settings > User Management table. Shown only for local-auth users that aren't the current user; prompts for an 8+ char password. Widgets visibility fix: - routes/widgets.js had `const isAdmin = req.user.role === 'superadmin'` which mislabeled superadmin as admin and silently restricted real admins (role=admin) to seeing only their own widgets. Now matches /auth/users behavior: superadmin sees all, admin sees own + public + widgets owned by members of teams they own, user sees own + public. 7 new i18n keys (admin.reset_password, admin.prompt_reset_password, admin.toast.password_min_8, admin.toast.password_reset, and the matching settings.user.* / settings.toast.* trio). 1024 keys total, parity 100% across en/es/fr/de/pt. Co-Authored-By: Claude Opus 4.7 --- frontend/js/api.js | 4 +++ frontend/js/i18n/de.js | 7 +++++ frontend/js/i18n/en.js | 7 +++++ frontend/js/i18n/es.js | 7 +++++ frontend/js/i18n/fr.js | 7 +++++ frontend/js/i18n/pt.js | 7 +++++ frontend/js/views/admin.js | 18 ++++++++++++- frontend/js/views/settings.js | 19 +++++++++++++- server/routes/auth.js | 49 +++++++++++++++++++++++++++++++++++ server/routes/widgets.js | 31 +++++++++++++++++++--- server/server.js | 4 +++ 11 files changed, 154 insertions(+), 6 deletions(-) diff --git a/frontend/js/api.js b/frontend/js/api.js index 22da7a8..f69cda5 100644 --- a/frontend/js/api.js +++ b/frontend/js/api.js @@ -153,6 +153,10 @@ export const api = { // Admin - Users getUsers: () => request('/auth/users'), deleteUser: (id) => request(`/auth/users/${id}`, { method: 'DELETE' }), + resetUserPassword: (id, password) => request(`/auth/users/${id}/password`, { + method: 'PUT', + body: JSON.stringify({ password }), + }), assignPlan: (user_id, plan_id) => request('/subscription/assign', { method: 'POST', body: JSON.stringify({ user_id, plan_id }) diff --git a/frontend/js/i18n/de.js b/frontend/js/i18n/de.js index 3f430ae..da19944 100644 --- a/frontend/js/i18n/de.js +++ b/frontend/js/i18n/de.js @@ -440,6 +440,9 @@ export default { 'settings.user.confirm': 'Bestätigen?', 'settings.user.count_one': '1 Benutzer registriert', 'settings.user.count_other': '{n} Benutzer registriert', + 'settings.user.reset_password': 'Passwort zurücksetzen', + 'settings.user.prompt_reset_password': 'Geben Sie ein neues Passwort für {email} ein (mindestens 8 Zeichen):', + 'settings.toast.password_reset_for_user': 'Passwort zurückgesetzt', // Widgets 'widget.title': 'Widgets', @@ -768,6 +771,10 @@ export default { 'admin.toast.role_updated': 'Rolle aktualisiert', 'admin.toast.plan_updated': 'Plan aktualisiert', 'admin.toast.user_removed': 'Benutzer entfernt', + 'admin.reset_password': 'Passwort zurücksetzen', + 'admin.prompt_reset_password': 'Geben Sie ein neues Passwort für {email} ein (mindestens 8 Zeichen):', + 'admin.toast.password_min_8': 'Passwort muss mindestens 8 Zeichen lang sein', + 'admin.toast.password_reset': 'Passwort zurückgesetzt', // Schedule 'schedule.title': 'Zeitplan', diff --git a/frontend/js/i18n/en.js b/frontend/js/i18n/en.js index 67ffcd1..da019fc 100644 --- a/frontend/js/i18n/en.js +++ b/frontend/js/i18n/en.js @@ -467,6 +467,9 @@ export default { 'settings.user.confirm': 'Confirm?', 'settings.user.count_one': '1 user registered', 'settings.user.count_other': '{n} users registered', + 'settings.user.reset_password': 'Reset Password', + 'settings.user.prompt_reset_password': 'Enter a new password for {email} (minimum 8 characters):', + 'settings.toast.password_reset_for_user': 'Password reset', // Widgets 'widget.title': 'Widgets', @@ -804,6 +807,10 @@ export default { 'admin.toast.role_updated': 'Role updated', 'admin.toast.plan_updated': 'Plan updated', 'admin.toast.user_removed': 'User removed', + 'admin.reset_password': 'Reset Password', + 'admin.prompt_reset_password': 'Enter a new password for {email} (minimum 8 characters):', + 'admin.toast.password_min_8': 'Password must be at least 8 characters', + 'admin.toast.password_reset': 'Password reset', // Schedule 'schedule.title': 'Schedule', diff --git a/frontend/js/i18n/es.js b/frontend/js/i18n/es.js index cbcbd20..26b4b10 100644 --- a/frontend/js/i18n/es.js +++ b/frontend/js/i18n/es.js @@ -439,6 +439,9 @@ export default { 'settings.user.confirm': '¿Confirmar?', 'settings.user.count_one': '1 usuario registrado', 'settings.user.count_other': '{n} usuarios registrados', + 'settings.user.reset_password': 'Restablecer contraseña', + 'settings.user.prompt_reset_password': 'Ingresa una nueva contraseña para {email} (mínimo 8 caracteres):', + 'settings.toast.password_reset_for_user': 'Contraseña restablecida', // Widgets 'widget.title': 'Widgets', @@ -767,6 +770,10 @@ export default { 'admin.toast.role_updated': 'Rol actualizado', 'admin.toast.plan_updated': 'Plan actualizado', 'admin.toast.user_removed': 'Usuario eliminado', + 'admin.reset_password': 'Restablecer contraseña', + 'admin.prompt_reset_password': 'Ingresa una nueva contraseña para {email} (mínimo 8 caracteres):', + 'admin.toast.password_min_8': 'La contraseña debe tener al menos 8 caracteres', + 'admin.toast.password_reset': 'Contraseña restablecida', // Schedule 'schedule.title': 'Horario', diff --git a/frontend/js/i18n/fr.js b/frontend/js/i18n/fr.js index 114c230..258060b 100644 --- a/frontend/js/i18n/fr.js +++ b/frontend/js/i18n/fr.js @@ -440,6 +440,9 @@ export default { 'settings.user.confirm': 'Confirmer ?', 'settings.user.count_one': '1 utilisateur inscrit', 'settings.user.count_other': '{n} utilisateurs inscrits', + 'settings.user.reset_password': 'Réinitialiser le mot de passe', + 'settings.user.prompt_reset_password': 'Saisissez un nouveau mot de passe pour {email} (8 caractères minimum) :', + 'settings.toast.password_reset_for_user': 'Mot de passe réinitialisé', // Widgets 'widget.title': 'Widgets', @@ -768,6 +771,10 @@ export default { 'admin.toast.role_updated': 'Rôle mis à jour', 'admin.toast.plan_updated': 'Plan mis à jour', 'admin.toast.user_removed': 'Utilisateur retiré', + 'admin.reset_password': 'Réinitialiser le mot de passe', + 'admin.prompt_reset_password': 'Saisissez un nouveau mot de passe pour {email} (8 caractères minimum) :', + 'admin.toast.password_min_8': 'Le mot de passe doit comporter au moins 8 caractères', + 'admin.toast.password_reset': 'Mot de passe réinitialisé', // Schedule 'schedule.title': 'Calendrier', diff --git a/frontend/js/i18n/pt.js b/frontend/js/i18n/pt.js index f2ba3d8..f9abd54 100644 --- a/frontend/js/i18n/pt.js +++ b/frontend/js/i18n/pt.js @@ -440,6 +440,9 @@ export default { 'settings.user.confirm': 'Confirmar?', 'settings.user.count_one': '1 usuário registrado', 'settings.user.count_other': '{n} usuários registrados', + 'settings.user.reset_password': 'Redefinir senha', + 'settings.user.prompt_reset_password': 'Digite uma nova senha para {email} (mínimo 8 caracteres):', + 'settings.toast.password_reset_for_user': 'Senha redefinida', // Widgets 'widget.title': 'Widgets', @@ -768,6 +771,10 @@ export default { 'admin.toast.role_updated': 'Função atualizada', 'admin.toast.plan_updated': 'Plano atualizado', 'admin.toast.user_removed': 'Usuário removido', + 'admin.reset_password': 'Redefinir senha', + 'admin.prompt_reset_password': 'Digite uma nova senha para {email} (mínimo 8 caracteres):', + 'admin.toast.password_min_8': 'A senha deve ter no mínimo 8 caracteres', + 'admin.toast.password_reset': 'Senha redefinida', // Schedule 'schedule.title': 'Agenda', diff --git a/frontend/js/views/admin.js b/frontend/js/views/admin.js index cb6d5b3..3e695d6 100644 --- a/frontend/js/views/admin.js +++ b/frontend/js/views/admin.js @@ -44,6 +44,7 @@ async function loadUsers() { const el = document.getElementById('allUsersTable'); try { const [users, plans] = await Promise.all([API('/auth/users'), fetch('/api/subscription/plans').then(r => r.json())]); + const currentUser = JSON.parse(localStorage.getItem('user') || '{}'); el.innerHTML = `
@@ -74,7 +75,8 @@ async function loadUsers() { ${plans.map(p => ``).join('')} - + + ${u.auth_provider === 'local' && u.id !== currentUser.id ? `` : ''} ${u.role !== 'superadmin' ? `` : `${t('admin.owner')}`} @@ -103,6 +105,20 @@ async function loadUsers() { }; }); + // Reset password handlers + el.querySelectorAll('[data-reset-pw-user]').forEach(btn => { + btn.onclick = async () => { + const email = btn.dataset.userEmail; + const pw = prompt(t('admin.prompt_reset_password', { email })); + if (pw === null) return; + if (pw.length < 8) { showToast(t('admin.toast.password_min_8'), 'error'); return; } + try { + await api.resetUserPassword(btn.dataset.resetPwUser, pw); + showToast(t('admin.toast.password_reset'), 'success'); + } catch (err) { showToast(err.message, 'error'); } + }; + }); + el.querySelectorAll('[data-delete-user]').forEach(btn => { let confirming = false; btn.onclick = async () => { diff --git a/frontend/js/views/settings.js b/frontend/js/views/settings.js index 1720dd1..fea70c5 100644 --- a/frontend/js/views/settings.js +++ b/frontend/js/views/settings.js @@ -430,7 +430,8 @@ async function loadUsers() { ${plans.map(p => ``).join('')} - + + ${u.auth_provider === 'local' && u.id !== currentUser.id ? `` : ''} ${u.id !== currentUser.id ? `` : `${t('settings.user.you')}`} @@ -456,6 +457,22 @@ async function loadUsers() { }); }); + // Reset password handlers + el.querySelectorAll('.reset-user-pw-btn').forEach(btn => { + btn.addEventListener('click', async () => { + const email = btn.dataset.userEmail; + const pw = prompt(t('settings.user.prompt_reset_password', { email })); + if (pw === null) return; + if (pw.length < 8) { showToast(t('settings.toast.new_password_min_8'), 'error'); return; } + try { + await api.resetUserPassword(btn.dataset.userId, pw); + showToast(t('settings.toast.password_reset_for_user'), 'success'); + } catch (err) { + showToast(err.message, 'error'); + } + }); + }); + // Delete user handlers el.querySelectorAll('.delete-user-btn').forEach(btn => { let confirming = false; diff --git a/server/routes/auth.js b/server/routes/auth.js index 0731827..c2a4577 100644 --- a/server/routes/auth.js +++ b/server/routes/auth.js @@ -6,6 +6,7 @@ const { v4: uuidv4 } = require('uuid'); const { OAuth2Client } = require('google-auth-library'); const { db } = require('../db/database'); const { generateToken, requireAuth, requireAdmin, requireSuperAdmin } = require('../middleware/auth'); +const { logActivity } = require('../services/activity'); const config = require('../config'); function logFailedLogin(email, ip, reason) { @@ -303,6 +304,54 @@ router.put('/users/:id/role', requireAuth, requireSuperAdmin, (req, res) => { res.json({ success: true }); }); +// Admin password reset for another user. +// Superadmins: can reset any local user. Admins: can reset members of teams +// they own (and never a superadmin). Self-reset routes through PUT /me with +// current_password — this endpoint is the override path. +router.put('/users/:id/password', requireAuth, requireAdmin, (req, res) => { + const { password } = req.body; + if (!password || password.length < 8) { + return res.status(400).json({ error: 'Password must be at least 8 characters' }); + } + if (req.params.id === req.user.id) { + return res.status(400).json({ error: 'Use Settings > Change Password for your own account' }); + } + const target = db.prepare('SELECT id, email, role, auth_provider FROM users WHERE id = ?').get(req.params.id); + if (!target) return res.status(404).json({ error: 'User not found' }); + if (target.auth_provider !== 'local') { + return res.status(400).json({ error: `User signs in via ${target.auth_provider} — password reset does not apply` }); + } + + if (req.user.role !== 'superadmin') { + // Admin path: must own a team that includes the target, and target must + // be a regular user (cannot reset another admin's or a superadmin's + // password — that would be a lateral-takeover vector). + if (target.role !== 'user') { + return res.status(403).json({ error: 'Admins can only reset passwords for regular users' }); + } + const sharedOwnedTeam = db.prepare(` + SELECT 1 FROM team_members tm_admin + JOIN team_members tm_target ON tm_admin.team_id = tm_target.team_id + WHERE tm_admin.user_id = ? AND tm_admin.role = 'owner' + AND tm_target.user_id = ? + LIMIT 1 + `).get(req.user.id, req.params.id); + if (!sharedOwnedTeam) { + return res.status(403).json({ error: 'You can only reset passwords for members of teams you own' }); + } + } + + const hash = bcrypt.hashSync(password, 10); + db.prepare("UPDATE users SET password_hash = ?, updated_at = strftime('%s','now') WHERE id = ?") + .run(hash, req.params.id); + + // Explicit audit entry — the generic activity logger captures the route + // and target id, but a labeled detail string makes the audit log readable. + // Never include the password; just who reset whose password. + logActivity(req.user.id, 'password_reset_for_user', `target: ${target.email}`, null, req.ip); + res.json({ success: true }); +}); + // Get auth config (public - tells frontend which providers are available) router.get('/config', (req, res) => { const userCount = db.prepare('SELECT COUNT(*) as count FROM users').get().count; diff --git a/server/routes/widgets.js b/server/routes/widgets.js index 7967c3f..e7cb4c6 100644 --- a/server/routes/widgets.js +++ b/server/routes/widgets.js @@ -57,12 +57,35 @@ function safeUrl(url) { } catch { return 'about:blank'; } } -// List widgets +// List widgets. +// Visibility model: +// superadmin: all widgets +// admin: own + public (null owner) + widgets owned by members of teams +// this admin owns (matches /auth/users visibility) +// user: own + public (null owner) router.get('/', (req, res) => { - const isAdmin = req.user.role === 'superadmin'; + if (req.user.role === 'superadmin') { + const widgets = db.prepare('SELECT * FROM widgets ORDER BY created_at DESC').all(); + return res.json(widgets); + } + if (req.user.role === 'admin') { + const widgets = db.prepare(` + SELECT DISTINCT w.* FROM widgets w + LEFT JOIN team_members tm_target ON w.user_id = tm_target.user_id + LEFT JOIN team_members tm_admin + ON tm_admin.team_id = tm_target.team_id + AND tm_admin.user_id = ? + AND tm_admin.role = 'owner' + WHERE w.user_id = ? + OR w.user_id IS NULL + OR tm_admin.team_id IS NOT NULL + ORDER BY w.created_at DESC + `).all(req.user.id, req.user.id); + return res.json(widgets); + } const widgets = db.prepare( - `SELECT * FROM widgets ${isAdmin ? '' : 'WHERE user_id = ? OR user_id IS NULL'} ORDER BY created_at DESC` - ).all(...(isAdmin ? [] : [req.user.id])); + 'SELECT * FROM widgets WHERE user_id = ? OR user_id IS NULL ORDER BY created_at DESC' + ).all(req.user.id); res.json(widgets); }); diff --git a/server/server.js b/server/server.js index e09b336..82468d5 100644 --- a/server/server.js +++ b/server/server.js @@ -207,6 +207,10 @@ function rateLimit(windowMs, maxRequests) { // Auth routes (public, rate limited) app.use('/api/auth/login', rateLimit(60000, 10)); // 10 attempts per minute app.use('/api/auth/register', rateLimit(60000, 5)); // 5 registrations per minute +// Admin password-reset endpoint: even if an admin's session is compromised, +// cap the blast radius to 20 resets/min/IP. Express matches the longest +// path prefix first, so this fires before /api/auth catches the request. +app.use('/api/auth/users', rateLimit(60000, 20)); app.use('/api/auth', require('./routes/auth')); // Rate limit pairing to prevent brute force (5 attempts per minute per IP) app.use('/api/provision/pair', rateLimit(60000, 5));