fix(events): admin-set password on reset, full-URL gallery_link in all emails
Two related defects on the same gallery-email surface that PR #367 opened, addressed together: 1. Reset-password endpoint was a one-way auto-generate. `POST /admin/events/:id/reset-password` always called `generateReadablePassword()` and ignored any client-supplied value; the modal only offered a confirm + a forced auto-generated result. Admins who wanted to set a memorable customer-supplied password had no way to do it. Backend: route now reads optional `password` from the body. If present, validates with `validatePasswordInContext('gallery', …)` (same rules as create-event) and uses it; if absent, falls back to the existing generator, so old callers / cron stay functional. Switched the bcrypt rounds from a hard-coded `10` to `getBcryptRounds()` to match the create flow. Frontend: rebuilt `PasswordResetModal.tsx`. Typed input with show/hide, confirm-password field that appears on type, the same `<PasswordGenerator>` used by `CreateEventPage` (event-context- aware, fills both fields when used), send-email checkbox, client-side validation, server-side validation feedback inline. Submit empty → server auto-generates and the success screen shows the value with a copy button (legacy one-click flow preserved); submit with a typed password → success toast + close (no need to re-show what the admin already typed). Service layer: `events.service.resetPassword(id, sendEmail, password?)` only sends `password` in the body when set. Caller: `EventDetailsPage` now passes `eventDate` + `eventType` into the modal so the generator has event context. 2. `gallery_link` was the path-only `event.share_link` in three email-queue sites, so customer mail showed `/gallery/<slug>/<token>` instead of the full `https://example.com/gallery/<slug>/<token>` URL. - `adminEvents.js` reset-password queue (#1437) - `adminEvents.js` resend-creation-email queue (#1502) - `expirationChecker.js` expiration_warning queue (#82) All three now derive `shareUrl` from `buildShareLinkVariants` (the same helper already used by create-event, publish-from- draft, and event-rename). The other 4 callers (`adminEvents.js:651/913`, `events.js:187`, `eventRenameService.js:231`) already used the full URL — this closes the gap. Verified: TypeScript clean (`npx tsc --noEmit`), ESLint clean on every touched file (the 4 lint errors that remain in `adminEvents.js` are pre-existing and predate this branch).
This commit is contained in:
@@ -1369,7 +1369,7 @@ router.post('/:id/toggle-status', adminAuth, requirePermission('events.edit'), r
|
||||
router.post('/:id/reset-password', adminAuth, requirePermission('events.edit'), requireEventOwnership, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { sendEmail = true } = req.body;
|
||||
const { sendEmail = true, password: clientPassword } = req.body;
|
||||
|
||||
let eventQuery = db('events').where('id', id);
|
||||
// Editor role can only edit their own events
|
||||
@@ -1385,10 +1385,29 @@ router.post('/:id/reset-password', adminAuth, requirePermission('events.edit'),
|
||||
return res.status(400).json({ error: 'Cannot reset password for archived event' });
|
||||
}
|
||||
|
||||
// Generate new password
|
||||
const { generateReadablePassword } = require('../utils/passwordGenerator');
|
||||
const newPassword = generateReadablePassword();
|
||||
const passwordHash = await bcrypt.hash(newPassword, 10);
|
||||
// Use the admin-supplied password when provided; otherwise auto-generate
|
||||
// (preserves the previous one-click behaviour for callers/cron that don't
|
||||
// pass a body). Validation matches the create-event flow so the same
|
||||
// strength rules apply both ways.
|
||||
let newPassword;
|
||||
if (typeof clientPassword === 'string' && clientPassword.length > 0) {
|
||||
const passwordValidation = await validatePasswordInContext(clientPassword, 'gallery', {
|
||||
eventName: event.event_name
|
||||
});
|
||||
if (!passwordValidation.valid) {
|
||||
return res.status(400).json({
|
||||
error: 'Password does not meet security requirements',
|
||||
details: passwordValidation.errors,
|
||||
score: passwordValidation.score,
|
||||
feedback: passwordValidation.feedback
|
||||
});
|
||||
}
|
||||
newPassword = clientPassword;
|
||||
} else {
|
||||
const { generateReadablePassword } = require('../utils/passwordGenerator');
|
||||
newPassword = generateReadablePassword();
|
||||
}
|
||||
const passwordHash = await bcrypt.hash(newPassword, getBcryptRounds());
|
||||
|
||||
// Update event with new password
|
||||
await db('events')
|
||||
@@ -1408,6 +1427,9 @@ router.post('/:id/reset-password', adminAuth, requirePermission('events.edit'),
|
||||
if (sendEmail) {
|
||||
const recipientEmail = event.customer_email || event.host_email;
|
||||
const recipientName = event.customer_name || event.host_name || (recipientEmail ? recipientEmail.split('@')[0] : null);
|
||||
// event.share_link is the path-only form (`/gallery/<slug>/<token>`).
|
||||
// Use the full URL so customers can click straight from the email.
|
||||
const { shareUrl } = await buildShareLinkVariants({ slug: event.slug, shareToken: event.share_token });
|
||||
|
||||
await queueEmail(id, recipientEmail, 'gallery_created', {
|
||||
customer_name: recipientName,
|
||||
@@ -1415,13 +1437,13 @@ router.post('/:id/reset-password', adminAuth, requirePermission('events.edit'),
|
||||
host_name: recipientName,
|
||||
event_name: event.event_name,
|
||||
event_date: event.event_date, // Pass raw date - will be formatted by email processor
|
||||
gallery_link: event.share_link,
|
||||
gallery_link: shareUrl,
|
||||
gallery_password: newPassword,
|
||||
expiry_date: event.expires_at // Pass raw date - will be formatted by email processor
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
res.json({
|
||||
message: 'Password reset successfully',
|
||||
newPassword: newPassword,
|
||||
emailSent: sendEmail
|
||||
@@ -1473,6 +1495,9 @@ router.post('/:id/resend-email', adminAuth, requirePermission('events.edit'), re
|
||||
// Queue the email
|
||||
const recipientEmail = event.customer_email || event.host_email;
|
||||
const recipientName = event.customer_name || event.host_name || (recipientEmail ? recipientEmail.split('@')[0] : null);
|
||||
// event.share_link is the path-only form; use the full URL so the
|
||||
// customer's mail client renders a clickable absolute link.
|
||||
const { shareUrl } = await buildShareLinkVariants({ slug: event.slug, shareToken: event.share_token });
|
||||
|
||||
await queueEmail(id, recipientEmail, 'gallery_created', {
|
||||
customer_name: recipientName,
|
||||
@@ -1480,7 +1505,7 @@ router.post('/:id/resend-email', adminAuth, requirePermission('events.edit'), re
|
||||
host_name: recipientName,
|
||||
event_name: event.event_name,
|
||||
event_date: event.event_date, // Pass raw date - will be formatted by email processor
|
||||
gallery_link: event.share_link,
|
||||
gallery_link: shareUrl,
|
||||
gallery_password: galleryPassword,
|
||||
expiry_date: event.expires_at, // Pass raw date - will be formatted by email processor
|
||||
welcome_message: event.welcome_message || '',
|
||||
|
||||
@@ -2,6 +2,7 @@ const cron = require('node-cron');
|
||||
const { db } = require('../database/db');
|
||||
const { archiveEvent } = require('./archiveService');
|
||||
const { queueEmail, getSupportEmail } = require('./emailProcessor');
|
||||
const { buildShareLinkVariants } = require('./shareLinkService');
|
||||
const logger = require('../utils/logger');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
|
||||
@@ -62,6 +63,9 @@ async function queueExpirationWarning(event) {
|
||||
|
||||
const recipientEmail = event.customer_email || event.host_email;
|
||||
const recipientName = event.customer_name || event.host_name || (recipientEmail ? recipientEmail.split('@')[0] : null);
|
||||
// event.share_link is the path-only form; use the full URL so the
|
||||
// recipient's mail client renders a clickable absolute link.
|
||||
const { shareUrl } = await buildShareLinkVariants({ slug: event.slug, shareToken: event.share_token });
|
||||
|
||||
// Date formatting + language detection happen inside processTemplate using
|
||||
// the recipient's resolved language — pass the raw ISO date and let the
|
||||
@@ -79,7 +83,7 @@ async function queueExpirationWarning(event) {
|
||||
event_date: event.event_date,
|
||||
days_remaining: daysRemaining.toString(),
|
||||
expiry_date: event.expires_at,
|
||||
gallery_link: event.share_link,
|
||||
gallery_link: shareUrl,
|
||||
gallery_password: '{{password_security_message}}'
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user