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