From ff50c74e1912ccba60f7ccdbead92b76de91388b Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Sun, 3 May 2026 22:38:06 +0200 Subject: [PATCH] fix(events): admin-set password on reset, full-URL gallery_link in all emails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `` 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//` instead of the full `https://example.com/gallery//` 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). --- backend/src/routes/adminEvents.js | 41 +++- backend/src/services/expirationChecker.js | 6 +- .../components/admin/PasswordResetModal.tsx | 187 +++++++++++++----- frontend/src/pages/admin/EventDetailsPage.tsx | 6 +- frontend/src/services/events.service.ts | 14 +- 5 files changed, 194 insertions(+), 60 deletions(-) diff --git a/backend/src/routes/adminEvents.js b/backend/src/routes/adminEvents.js index a5f0effd..d57bcb4b 100644 --- a/backend/src/routes/adminEvents.js +++ b/backend/src/routes/adminEvents.js @@ -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//`). + // 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 || '', diff --git a/backend/src/services/expirationChecker.js b/backend/src/services/expirationChecker.js index e0015e51..cdf3f242 100644 --- a/backend/src/services/expirationChecker.js +++ b/backend/src/services/expirationChecker.js @@ -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}}' }); diff --git a/frontend/src/components/admin/PasswordResetModal.tsx b/frontend/src/components/admin/PasswordResetModal.tsx index edda10d0..208ed564 100644 --- a/frontend/src/components/admin/PasswordResetModal.tsx +++ b/frontend/src/components/admin/PasswordResetModal.tsx @@ -1,53 +1,92 @@ import React, { useState } from 'react'; -import { X, Key, Copy, CheckCircle, Mail } from 'lucide-react'; +import { X, Key, Copy, CheckCircle, Mail, Lock, Eye, EyeOff } from 'lucide-react'; import { toast } from 'react-toastify'; -import { Button, Card } from '../common'; +import { Button, Card, Input, PasswordGenerator } from '../common'; interface PasswordResetModalProps { eventName: string; - onConfirm: (sendEmail: boolean) => Promise<{ newPassword: string; emailSent: boolean }>; + eventDate?: string; + eventType?: string; + onConfirm: (sendEmail: boolean, password?: string) => Promise<{ newPassword: string; emailSent: boolean }>; onClose: () => void; } export const PasswordResetModal: React.FC = ({ eventName, + eventDate, + eventType, onConfirm, onClose }) => { - const [isResetting, setIsResetting] = useState(false); + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [showPassword, setShowPassword] = useState(false); const [sendEmail, setSendEmail] = useState(true); - const [newPassword, setNewPassword] = useState(null); + const [isResetting, setIsResetting] = useState(false); + const [errors, setErrors] = useState<{ password?: string; confirmPassword?: string }>({}); + const [resultPassword, setResultPassword] = useState(null); + const [resultWasGenerated, setResultWasGenerated] = useState(false); const [copied, setCopied] = useState(false); + const validate = (): boolean => { + const next: typeof errors = {}; + // Empty is allowed → server auto-generates. Only validate when typed. + if (password) { + if (password.length < 6) { + next.password = 'Password must be at least 6 characters'; + } + if (password !== confirmPassword) { + next.confirmPassword = 'Passwords do not match'; + } + } + setErrors(next); + return Object.keys(next).length === 0; + }; + const handleReset = async () => { + if (!validate()) return; setIsResetting(true); try { - const result = await onConfirm(sendEmail); - setNewPassword(result.newPassword); - toast.success('Password reset successfully'); - } catch (error) { - toast.error('Failed to reset password'); - onClose(); + const supplied = password.length > 0 ? password : undefined; + const result = await onConfirm(sendEmail, supplied); + setResultPassword(result.newPassword); + setResultWasGenerated(!supplied); + if (supplied) { + toast.success('Password reset successfully'); + } + } catch (error: any) { + const serverError = error?.response?.data; + if (serverError?.error === 'Password does not meet security requirements') { + setErrors({ password: serverError.feedback?.join?.(' ') || 'Password does not meet security requirements' }); + } else { + toast.error(serverError?.error || 'Failed to reset password'); + } } finally { setIsResetting(false); } }; const handleCopy = async () => { - if (newPassword) { - await navigator.clipboard.writeText(newPassword); + if (resultPassword) { + await navigator.clipboard.writeText(resultPassword); setCopied(true); toast.success('Password copied to clipboard'); setTimeout(() => setCopied(false), 2000); } }; + const handlePasswordGenerated = (generated: string) => { + setPassword(generated); + setConfirmPassword(generated); + setErrors({}); + }; + return (

- {newPassword ? 'New Password' : 'Reset Gallery Password'} + {resultPassword ? 'New Password' : 'Reset Gallery Password'}

- {!newPassword ? ( + {!resultPassword ? ( <> -

- Are you sure you want to reset the password for {eventName}? - This will generate a new password for gallery access. +

+ Set a new password for {eventName}, or leave both fields empty to have one auto-generated.

-
+
+
+ { + setPassword(e.target.value); + if (errors.password) setErrors((prev) => ({ ...prev, password: undefined })); + }} + error={errors.password} + helperText="Use 6+ characters, or leave blank to auto-generate" + leftIcon={} + rightIcon={ + + } + /> + +
+ +
+
+ + {password.length > 0 && ( + { + setConfirmPassword(e.target.value); + if (errors.confirmPassword) setErrors((prev) => ({ ...prev, confirmPassword: undefined })); + }} + error={errors.confirmPassword} + leftIcon={} + /> + )} +
+ +
-
- -
- - -
-
+ {resultWasGenerated && ( + <> +
+ +
+ + +
+
-
-

- Important: Save this password securely. It cannot be recovered once you close this window. -

-
+
+

+ Important: Save this password securely. It cannot be recovered once you close this window. +

+
+ + )}
); -}; \ No newline at end of file +}; diff --git a/frontend/src/pages/admin/EventDetailsPage.tsx b/frontend/src/pages/admin/EventDetailsPage.tsx index 644a1ecb..244dc2f2 100644 --- a/frontend/src/pages/admin/EventDetailsPage.tsx +++ b/frontend/src/pages/admin/EventDetailsPage.tsx @@ -2194,8 +2194,10 @@ export const EventDetailsPage: React.FC = () => { {showPasswordReset && ( { - const result = await eventsService.resetPassword(event.id, sendEmail); + eventDate={event.event_date} + eventType={event.event_type} + onConfirm={async (sendEmail, password) => { + const result = await eventsService.resetPassword(event.id, sendEmail, password); return result; }} onClose={() => setShowPasswordReset(false)} diff --git a/frontend/src/services/events.service.ts b/frontend/src/services/events.service.ts index b57c9fa6..bb5777c2 100644 --- a/frontend/src/services/events.service.ts +++ b/frontend/src/services/events.service.ts @@ -162,9 +162,17 @@ export const eventsService = { return response.data || []; }, - // Reset event password - async resetPassword(eventId: number, sendEmail: boolean = true): Promise<{ message: string; newPassword: string; emailSent: boolean }> { - const response = await api.post(`/admin/events/${eventId}/reset-password`, { sendEmail }); + // Reset event password. Pass `password` to set a specific value (validated + // server-side with the same rules as create-event); omit it to have the + // server auto-generate one. + async resetPassword( + eventId: number, + sendEmail: boolean = true, + password?: string + ): Promise<{ message: string; newPassword: string; emailSent: boolean }> { + const body: { sendEmail: boolean; password?: string } = { sendEmail }; + if (password) body.password = password; + const response = await api.post(`/admin/events/${eventId}/reset-password`, body); return response.data; },