mirror of
https://github.com/screentinker/screentinker.git
synced 2026-05-15 07:32:23 -06:00
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:
parent
dec56506f9
commit
388e9e6ab8
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
Loading…
Reference in a new issue