openvibers/openvibe.network Commits

Admin: owner can edit user emails and send password reset links

OP OpenVibers committed 18 Aug 2026 · 89b6513477 · 4 files, +169 −18

Adds two owner-only endpoints to the users tab:
  PUT  /api/admin/users/:id/email      change or clear an address
  POST /api/admin/users/:id/send-reset email a reset link to the account holder

Both are requireOwner rather than requireAdmin. Controlling an account's email
address means controlling the account, so these are takeover-capable and belong
with the other owner-gated powers, not with day-to-day moderation. Every use
writes an audit_log row; the email change records both the old and new address.

The reset link is emailed to the account holder and deliberately never returned
in the API response or shown in the UI — that is what keeps this a recovery tool
rather than a way to seize an account.

Extracted the token minting into auth/reset-tokens.js so the self-service forgot
password flow and this one mint identically (hashed at rest, one live token per
user, same 1h TTL). Duplicating it invited the kind of drift where one path
hashes tokens and the other does not.

Validation: format and length checked; users.email is UNIQUE so a collision is
detected up front and reported as "already used by @x" instead of surfacing a
raw SQLite constraint error.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Xw6Sf5zzSALw8m93XNu5JE

Changes

ddiff --git a/public/admin.html b/public/admin.html
iindex a2681bd1..505e2645 100644
---- a/public/admin.html
++++ b/public/admin.html
@@@ -1018,6 +1018,13 @@ async function loadUsers() {
`<button class="btn btn-sm btn-success" onclick="toggleBan('${u.id}','${esc(u.username)}',false)"><i class="fa-solid fa-user-check"></i></button>` :
`<button class="btn btn-sm btn-danger" onclick="toggleBan('${u.id}','${esc(u.username)}',true)"><i class="fa-solid fa-ban"></i></button>`
}
++ ${currentUser?.is_owner ? `
++ <button class="btn btn-sm btn-outline" title="Edit email address"
++ onclick="editUserEmail('${u.id}','${esc(u.username)}','${esc(u.email || '')}')"><i class="fa-solid fa-envelope"></i></button>
++ <button class="btn btn-sm btn-outline" title="${u.email ? 'Send a password reset link' : 'No email address on file'}"
++ ${u.email ? '' : 'disabled'}
++ onclick="sendUserReset('${u.id}','${esc(u.username)}')"><i class="fa-solid fa-key"></i></button>
++ ` : ''}
</td>
</tr>
`).join('')}</tbody>
@@@ -1040,6 +1047,31 @@ async function changeUserRole(userId, role) {
} catch (e) { toast(e.message, 'error'); loadUsers(); }
}
++// ── Owner-only account recovery ──────────────────────────────────────────────
++// The server enforces owner-only on both of these (requireOwner); hiding the buttons
++// for non-owners is purely so admins are not shown controls they cannot use.
++async function editUserEmail(userId, username, current) {
++ const next = prompt(`Email address for @${username}\n\nLeave blank to remove it.`, current || '');
++ if (next === null) return; // cancelled
++ const email = next.trim();
++ if (email === (current || '').trim()) return; // unchanged
++ try {
++ const r = await api(`/api/admin/users/${userId}/email`, { method: 'PUT', body: { email } });
++ toast(r.email ? `Email for @${username} set to ${r.email}` : `Email removed from @${username}`, 'success');
++ loadUsers();
++ } catch (e) { toast(e.message, 'error'); }
++}
++
++async function sendUserReset(userId, username) {
++ // The link is emailed to the account holder and never shown here — that is what keeps
++ // this a recovery tool rather than a way to take over an account.
++ if (!confirm(`Send a password reset link to @${username}?\n\nIt goes to their email address on file and expires in 1 hour. Any previous reset link for this account stops working.`)) return;
++ try {
++ const r = await api(`/api/admin/users/${userId}/send-reset`, { method: 'POST' });
++ toast(`Reset link sent to ${r.sentTo} (expires in ${r.expiresMinutes} min)`, 'success');
++ } catch (e) { toast(e.message, 'error'); }
++}
++
async function toggleBan(userId, username, ban) {
if (ban) {
const reason = prompt(`Ban ${username}? Enter reason:`);
ddiff --git a/server/admin/routes.js b/server/admin/routes.js
iindex 676ceb4d..5aa876c3 100644
---- a/server/admin/routes.js
++++ b/server/admin/routes.js
@@@ -15,6 +15,7 @@ const childProcess = require('child_process');
const urlRegistry = require('../url-registry');
const { URL_DEFINITIONS } = require('openvibe-shared/url-resolver');
const { isOwner, requireOwner, isSensitiveSettingKey, maskSecret } = require('../auth/owner-guard');
++const { issueResetToken } = require('../auth/reset-tokens');
const { checkAdminLimit, recordAdminAction } = require('../auth/admin-limits');
const LOCAL_REFRESH_SERVICES = new Set(['network']);
@@@ -559,6 +560,91 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
}
});
++ // ── Owner-only account recovery ──────────────────────────────────────────
++ // Editing an email and issuing a password reset are account-TAKEOVER primitives:
++ // whoever controls the address controls the account. So both are requireOwner, not
++ // requireAdmin, matching how the admin role itself is gated below.
++
++ router.put('/users/:id/email', requireOwner, (req, res) => {
++ try {
++ const userId = parseInt(req.params.id, 10);
++ if (!Number.isFinite(userId)) return res.status(400).json({ ok: false, error: 'Invalid user id' });
++
++ const raw = String(req.body?.email ?? '').trim();
++ // Allow clearing an address, but anything non-empty must look like an email.
++ const email = raw === '' ? null : raw;
++ if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(email)) {
++ return res.status(400).json({ ok: false, error: 'That does not look like a valid email address' });
++ }
++ if (email && email.length > 254) {
++ return res.status(400).json({ ok: false, error: 'Email address is too long' });
++ }
++
++ const target = db.prepare('SELECT id, username, email FROM users WHERE id = ?').get(userId);
++ if (!target) return res.status(404).json({ ok: false, error: 'User not found' });
++
++ // users.email is UNIQUE, so a collision would throw a raw SQLite error —
++ // check first and return something the admin can actually act on.
++ if (email) {
++ const clash = db.prepare('SELECT id, username FROM users WHERE LOWER(email) = LOWER(?) AND id != ?').get(email, userId);
++ if (clash) {
++ return res.status(409).json({ ok: false, error: `That email is already used by @${clash.username}` });
++ }
++ }
++
++ db.prepare('UPDATE users SET email = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(email, userId);
++ db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(
++ req.user.id, 'user_email_change',
++ // Record both sides: this is the audit trail for a takeover-capable action.
++ JSON.stringify({ targetId: userId, targetUsername: target.username, from: target.email || null, to: email })
++ );
++ res.json({ ok: true, email });
++ } catch (err) {
++ res.status(500).json({ ok: false, error: err.message });
++ }
++ });
++
++ router.post('/users/:id/send-reset', requireOwner, async (req, res) => {
++ try {
++ const userId = parseInt(req.params.id, 10);
++ if (!Number.isFinite(userId)) return res.status(400).json({ ok: false, error: 'Invalid user id' });
++
++ const target = db.prepare('SELECT id, username, display_name, email FROM users WHERE id = ?').get(userId);
++ if (!target) return res.status(404).json({ ok: false, error: 'User not found' });
++ if (!target.email) return res.status(400).json({ ok: false, error: 'That account has no email address set' });
++
++ const emailService = req.app.locals.emailService;
++ if (!emailService?.isEnabled) {
++ return res.status(503).json({ ok: false, error: 'Email delivery is not configured' });
++ }
++
++ // Same minting path as the self-service flow, so a link issued here behaves
++ // identically — hashed at rest, one live token per user, same TTL.
++ const { resetUrl, expiresMinutes } = issueResetToken(db, req, userId);
++ const sent = await emailService.sendPasswordResetEmail({
++ to: target.email,
++ username: target.display_name || target.username,
++ resetUrl,
++ expiresMinutes,
++ });
++ if (!sent) {
++ // The token was already written; leave it — it simply goes unused and
++ // expires. Better than reporting success on an email that never left.
++ return res.status(502).json({ ok: false, error: 'Email service failed to send the reset link' });
++ }
++
++ db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(
++ req.user.id, 'user_password_reset_sent',
++ JSON.stringify({ targetId: userId, targetUsername: target.username, to: target.email })
++ );
++ // Deliberately NOT returning the link: it goes to the account holder's inbox,
++ // which is what makes this a recovery tool rather than a takeover tool.
++ res.json({ ok: true, sentTo: target.email, expiresMinutes });
++ } catch (err) {
++ res.status(500).json({ ok: false, error: err.message });
++ }
++ });
++
router.put('/users/:id/role', (req, res) => {
try {
const { role } = req.body;
ddiff --git a/server/auth/reset-tokens.js b/server/auth/reset-tokens.js
nnew file mode 100644
iindex 00000000..9e906ba1
---- /dev/null
++++ b/server/auth/reset-tokens.js
@@@ -0,0 +1,47 @@
++'use strict';
++
++// ═══════════════════════════════════════════════════════════════
++// Password-reset token helpers.
++//
++// Extracted from auth/routes.js so the self-service "forgot password" flow and
++// the owner-initiated admin flow mint tokens exactly the same way. Duplicating
++// this would be the kind of drift that ends with one path hashing tokens and the
++// other storing them in the clear.
++// ═══════════════════════════════════════════════════════════════
++
++const crypto = require('crypto');
++
++const RESET_TOKEN_TTL_MS = 60 * 60 * 1000;
++
++/** Tokens are stored hashed — a database leak must not yield usable reset links. */
++function hashResetToken(token) {
++ return crypto.createHash('sha256').update(String(token)).digest('hex');
++}
++
++function buildBaseUrl(req) {
++ return (process.env.OV_NETWORK_BASE_URL || `${req.protocol}://${req.get('host')}`).replace(/\/$/, '');
++}
++
++/**
++ * Invalidate any outstanding tokens for a user and mint a fresh one.
++ * Returns { rawToken, resetUrl, expiresMinutes } — rawToken is the only time the
++ * plaintext exists, so it must go straight into the email and never be stored.
++ */
++function issueResetToken(db, req, userId) {
++ const rawToken = crypto.randomBytes(32).toString('hex');
++ const expiresAt = new Date(Date.now() + RESET_TOKEN_TTL_MS).toISOString();
++ // One live token per user: issuing a new link revokes the previous one.
++ db.prepare('DELETE FROM password_reset_tokens WHERE user_id = ?').run(userId);
++ db.prepare(`
++ INSERT INTO password_reset_tokens (user_id, token_hash, expires_at, requested_ip, requested_user_agent)
++ VALUES (?, ?, ?, ?, ?)
++ `).run(userId, hashResetToken(rawToken), expiresAt, req.ip || null, req.headers['user-agent'] || null);
++
++ return {
++ rawToken,
++ resetUrl: `${buildBaseUrl(req)}/reset-password?token=${encodeURIComponent(rawToken)}`,
++ expiresMinutes: Math.round(RESET_TOKEN_TTL_MS / 60000),
++ };
++}
++
++module.exports = { RESET_TOKEN_TTL_MS, hashResetToken, buildBaseUrl, issueResetToken };
ddiff --git a/server/auth/routes.js b/server/auth/routes.js
iindex 40729c07..a54bade7 100644
---- a/server/auth/routes.js
++++ b/server/auth/routes.js
@@@ -12,20 +12,13 @@ const jwt = require('jsonwebtoken');
const { v4: uuidv4 } = require('uuid');
const router = express.Router();
--const RESET_TOKEN_TTL_MS = 60 * 60 * 1000;
// ── Helpers ──────────────────────────────────────────────────
function getDb(req) { return req.app.locals.db; }
function getConfig(req) { return req.app.locals.config; }
--function hashResetToken(token) {
-- return crypto.createHash('sha256').update(String(token)).digest('hex');
--}
--
--function buildToolsBaseUrl(req) {
-- return (process.env.OV_NETWORK_BASE_URL || `${req.protocol}://${req.get('host')}`).replace(/\/$/, '');
--}
++const { hashResetToken, issueResetToken } = require('./reset-tokens');
function findValidResetToken(db, token) {
return db.prepare(`
@@@ -249,20 +242,13 @@ router.post('/forgot-password', async (req, res) => {
return res.json(genericResponse);
}
-- const rawToken = crypto.randomBytes(32).toString('hex');
-- const expiresAt = new Date(Date.now() + RESET_TOKEN_TTL_MS).toISOString();
-- db.prepare('DELETE FROM password_reset_tokens WHERE user_id = ?').run(user.id);
-- db.prepare(`
-- INSERT INTO password_reset_tokens (user_id, token_hash, expires_at, requested_ip, requested_user_agent)
-- VALUES (?, ?, ?, ?, ?)
-- `).run(user.id, hashResetToken(rawToken), expiresAt, req.ip || null, req.headers['user-agent'] || null);
--
-- const resetUrl = `${buildToolsBaseUrl(req)}/reset-password?token=${encodeURIComponent(rawToken)}`;
++ // Shared with the owner-initiated admin flow so both mint tokens identically.
++ const { resetUrl, expiresMinutes } = issueResetToken(db, req, user.id);
await emailService.sendPasswordResetEmail({
to: user.email,
username: user.display_name || user.username,
resetUrl,
-- expiresMinutes: Math.round(RESET_TOKEN_TTL_MS / 60000),
++ expiresMinutes,
});
res.json(genericResponse);