Admin password reset + widget visibility fix

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: <email>"
  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 <noreply@anthropic.com>
This commit is contained in:
ScreenTinker 2026-04-29 20:45:25 -05:00
parent dec56506f9
commit 388e9e6ab8
11 changed files with 154 additions and 6 deletions

View file

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

View file

@ -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',

View file

@ -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',

View file

@ -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',

View file

@ -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',

View file

@ -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',

View file

@ -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 = `
<div class="table-wrap">
@ -74,7 +75,8 @@ async function loadUsers() {
${plans.map(p => `<option value="${p.id}" ${u.plan_id === p.id ? 'selected' : ''}>${p.display_name}</option>`).join('')}
</select>
</td>
<td style="padding:8px">
<td style="padding:8px;white-space:nowrap">
${u.auth_provider === 'local' && u.id !== currentUser.id ? `<button class="btn btn-secondary btn-sm" data-reset-pw-user="${u.id}" data-user-email="${u.email}" style="margin-right:4px">${t('admin.reset_password')}</button>` : ''}
${u.role !== 'superadmin' ? `<button class="btn btn-danger btn-sm" data-delete-user="${u.id}">${t('admin.remove')}</button>` : `<span style="color:var(--text-muted);font-size:11px">${t('admin.owner')}</span>`}
</td>
</tr>
@ -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 () => {

View file

@ -430,7 +430,8 @@ async function loadUsers() {
${plans.map(p => `<option value="${p.id}" ${u.plan_id === p.id ? 'selected' : ''}>${p.display_name}</option>`).join('')}
</select>
</td>
<td style="padding:10px 12px">
<td style="padding:10px 12px;white-space:nowrap">
${u.auth_provider === 'local' && u.id !== currentUser.id ? `<button class="btn btn-secondary btn-sm reset-user-pw-btn" data-user-id="${u.id}" data-user-email="${u.email}" style="margin-right:4px">${t('settings.user.reset_password')}</button>` : ''}
${u.id !== currentUser.id ? `<button class="btn btn-danger btn-sm delete-user-btn" data-user-id="${u.id}">${t('settings.user.remove')}</button>` : `<span style="color:var(--text-muted);font-size:11px">${t('settings.user.you')}</span>`}
</td>
</tr>
@ -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;

View file

@ -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;

View file

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

View file

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