openvibers/openvibe.network Commits
Admin: owner can edit user emails and send password reset links
Changes
@@@ -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:`);@@@ -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;nnew file mode 100644@@@ -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 };@@@ -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);
@@ -1018,6 +1018,13 @@ async function loadUsers() {
@@ -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-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>`
`<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>
</td>
</tr>
</tr>
`).join('')}</tbody>
`).join('')}</tbody>
@@ -1040,6 +1047,31 @@ async function changeUserRole(userId, role) {
@@ -1040,6 +1047,31 @@ async function changeUserRole(userId, role) {
} catch (e) { toast(e.message, 'error'); loadUsers(); }
} 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) {
async function toggleBan(userId, username, ban) {
if (ban) {
if (ban) {
const reason = prompt(`Ban ${username}? Enter reason:`);
const reason = prompt(`Ban ${username}? Enter reason:`);
@@ -15,6 +15,7 @@ const childProcess = require('child_process');
@@ -15,6 +15,7 @@ const childProcess = require('child_process');
const urlRegistry = require('../url-registry');
const urlRegistry = require('../url-registry');
const { URL_DEFINITIONS } = require('openvibe-shared/url-resolver');
const { URL_DEFINITIONS } = require('openvibe-shared/url-resolver');
const { isOwner, requireOwner, isSensitiveSettingKey, maskSecret } = require('../auth/owner-guard');
const { isOwner, requireOwner, isSensitiveSettingKey, maskSecret } = require('../auth/owner-guard');
+const { issueResetToken } = require('../auth/reset-tokens');
const { checkAdminLimit, recordAdminAction } = require('../auth/admin-limits');
const { checkAdminLimit, recordAdminAction } = require('../auth/admin-limits');
const LOCAL_REFRESH_SERVICES = new Set(['network']);
const LOCAL_REFRESH_SERVICES = new Set(['network']);
@@ -559,6 +560,91 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
@@ -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) => {
router.put('/users/:id/role', (req, res) => {
try {
try {
const { role } = req.body;
const { role } = req.body;
new file mode 100644
new file mode 100644
@@ -0,0 +1,47 @@
@@ -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 };
@@ -12,20 +12,13 @@ const jwt = require('jsonwebtoken');
@@ -12,20 +12,13 @@ const jwt = require('jsonwebtoken');
const { v4: uuidv4 } = require('uuid');
const { v4: uuidv4 } = require('uuid');
const router = express.Router();
const router = express.Router();
-const RESET_TOKEN_TTL_MS = 60 * 60 * 1000;
// ── Helpers ──────────────────────────────────────────────────
// ── Helpers ──────────────────────────────────────────────────
function getDb(req) { return req.app.locals.db; }
function getDb(req) { return req.app.locals.db; }
function getConfig(req) { return req.app.locals.config; }
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) {
function findValidResetToken(db, token) {
return db.prepare(`
return db.prepare(`
@@ -249,20 +242,13 @@ router.post('/forgot-password', async (req, res) => {
@@ -249,20 +242,13 @@ router.post('/forgot-password', async (req, res) => {
return res.json(genericResponse);
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({
await emailService.sendPasswordResetEmail({
to: user.email,
to: user.email,
username: user.display_name || user.username,
username: user.display_name || user.username,
resetUrl,
resetUrl,
- expiresMinutes: Math.round(RESET_TOKEN_TTL_MS / 60000),
+ expiresMinutes,
});
});
res.json(genericResponse);
res.json(genericResponse);