From 83b568ee2ddc007b7d981fd4b46b69810f0165c3 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Wed, 17 Jun 2026 22:58:17 +0200 Subject: [PATCH] fix(events): publish-from-draft email carries the real password (#627) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, publishing a password-protected DRAFT gallery sent the gallery_created email with the literal sentinel "(set at creation)", which the email processor localised to "The password you set when creating the gallery" / "Das bei der Erstellung der Galerie gesetzte Passwort". Root cause: at draft creation only the bcrypt hash is stored (no plaintext column, by design); the publish endpoint had nowhere to pull the actual password from. Create-and-publish-in-one-step worked because the plaintext is still in memory at email-queue time. Fix: the Publish action now opens a small PublishGalleryDialog that prompts the admin to (re-)type the gallery password. The publish endpoint accepts an optional `password` body, re-hashes + writes `password_hash` so the stored hash matches what was just emailed (admins who mistype at creation get a self-healing publish flow), and puts the plaintext into the gallery_password email field. When the publish call is made without a password (API-only consumers), behaviour falls back to the legacy sentinel — no breaking change. The window.confirm() publish flow is gone; the dialog handles the no- password case too (plain confirm + Publish button). I18n: EN + DE entries for the dialog. Other locales fall through to the EN defaults via the t() default-value pattern. No schema changes. No plaintext at rest. --- backend/src/routes/adminEvents.js | 43 +++++- .../components/admin/PublishGalleryDialog.tsx | 136 ++++++++++++++++++ frontend/src/components/admin/index.ts | 1 + frontend/src/i18n/locales/de.json | 9 ++ frontend/src/i18n/locales/en.json | 9 ++ frontend/src/pages/admin/EventDetailsPage.tsx | 37 +++-- frontend/src/services/events.service.ts | 13 +- 7 files changed, 228 insertions(+), 20 deletions(-) create mode 100644 frontend/src/components/admin/PublishGalleryDialog.tsx diff --git a/backend/src/routes/adminEvents.js b/backend/src/routes/adminEvents.js index bfe5a76c..448cf552 100644 --- a/backend/src/routes/adminEvents.js +++ b/backend/src/routes/adminEvents.js @@ -1062,9 +1062,25 @@ router.get('/:id', adminAuth, requirePermission('events.view'), async (req, res) }); // Publish a draft event (set is_draft=false and queue creation email) -router.post('/:id/publish', adminAuth, requirePermission('events.edit'), requireEventOwnership, async (req, res) => { +router.post('/:id/publish', adminAuth, requirePermission('events.edit'), requireEventOwnership, [ + // Optional password the admin re-types in the publish dialog so the + // gallery_created email can carry the actual plaintext (#627). When the + // event is password-protected and the body carries a password, picpeak + // re-hashes + writes `password_hash` (the admin may have mistyped at + // creation; this guarantees the email content matches the live login + // password). When omitted, behaviour is the legacy sentinel for backward + // compat with API-only consumers. + body('password').optional().isString().isLength({ min: 6 }) + .withMessage('Password must be at least 6 characters long'), +], async (req, res) => { try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + const { id } = req.params; + const { password } = req.body; const event = await db('events').where('id', id).first(); if (!event) { @@ -1075,8 +1091,15 @@ router.post('/:id/publish', adminAuth, requirePermission('events.edit'), require return res.status(400).json({ error: 'Event is already published' }); } - // Set is_draft to false - await db('events').where('id', id).update({ is_draft: formatBoolean(false) }); + const requirePassword = parseBooleanInput(event.require_password, true); + const publishUpdates = { is_draft: formatBoolean(false) }; + if (requirePassword && password) { + // Re-hash so the stored hash matches what the email carries — even if + // the admin mistypes vs. what was set at draft creation, the gallery + // password the customer receives is the one that actually works. + publishUpdates.password_hash = await bcrypt.hash(password, getBcryptRounds()); + } + await db('events').where('id', id).update(publishUpdates); // Queue creation email const customerEmail = event.customer_email || event.host_email; @@ -1085,6 +1108,18 @@ router.post('/:id/publish', adminAuth, requirePermission('events.edit'), require const frontendBase = await getFrontendBaseUrl(); const { shareUrl } = await buildShareLinkVariants({ slug: event.slug, shareToken: event.share_token }); + let galleryPasswordForEmail; + if (!requirePassword) { + galleryPasswordForEmail = 'No password required'; + } else if (password) { + // Admin re-typed the password in the publish dialog — put it straight + // into the email so the customer can actually log in (#627). + galleryPasswordForEmail = password; + } else { + // Legacy fallback for API-only publishes that don't carry the password. + galleryPasswordForEmail = '(set at creation)'; + } + const emailData = { customer_name: customerName, customer_email: customerEmail, @@ -1092,7 +1127,7 @@ router.post('/:id/publish', adminAuth, requirePermission('events.edit'), require event_name: event.event_name, event_date: event.event_date, gallery_link: shareUrl || `${frontendBase}/gallery/${event.slug}`, - gallery_password: parseBooleanInput(event.require_password, true) ? '(set at creation)' : 'No password required', + gallery_password: galleryPasswordForEmail, expiry_date: event.expires_at ? new Date(event.expires_at).toISOString() : null, welcome_message: event.welcome_message || '' }; diff --git a/frontend/src/components/admin/PublishGalleryDialog.tsx b/frontend/src/components/admin/PublishGalleryDialog.tsx new file mode 100644 index 00000000..d0421602 --- /dev/null +++ b/frontend/src/components/admin/PublishGalleryDialog.tsx @@ -0,0 +1,136 @@ +import React, { useState } from 'react'; +import { X, Send, Lock, Eye, EyeOff } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { Button, Card, Input } from '../common'; + +interface PublishGalleryDialogProps { + eventName: string; + requirePassword: boolean; + customerEmail?: string | null; + isPublishing: boolean; + onConfirm: (password?: string) => void; + onClose: () => void; +} + +/** + * Confirmation dialog for the "Publish & Notify" action on a draft gallery. + * + * When the gallery is password-protected, the admin re-types the password + * here so the gallery_created email can carry the real plaintext instead of + * the "(set at creation)" sentinel (#627). The backend also re-hashes what + * the admin types so the stored hash matches what was just emailed — admins + * who mistype at creation get a self-healing publish flow. + * + * For galleries without a password, the dialog is a plain confirm + Publish + * button (mirrors the previous window.confirm() flow). + */ +export const PublishGalleryDialog: React.FC = ({ + eventName, + requirePassword, + customerEmail, + isPublishing, + onConfirm, + onClose, +}) => { + const { t } = useTranslation(); + const [password, setPassword] = useState(''); + const [showPassword, setShowPassword] = useState(false); + const [error, setError] = useState(undefined); + + const handleSubmit = () => { + if (requirePassword) { + if (!password || password.trim().length < 6) { + setError(t('events.publishDialog.errorMinLength', 'Password must be at least 6 characters long.')); + return; + } + } + setError(undefined); + onConfirm(requirePassword ? password : undefined); + }; + + return ( +
+ +
+

+ {t('events.publishDialog.title', 'Publish gallery')} +

+ +
+ +

+ {customerEmail + ? t('events.publishDialog.descriptionWithEmail', { + eventName, + customerEmail, + defaultValue: + 'Publishing "{{eventName}}" makes the gallery accessible and sends the notification email to {{customerEmail}}.', + }) + : t('events.publishDialog.descriptionNoEmail', { + eventName, + defaultValue: + 'Publishing "{{eventName}}" makes the gallery accessible. No customer email is set, so no notification will be sent.', + })} +

+ + {requirePassword && customerEmail && ( +
+ { + setPassword(e.target.value); + if (error) setError(undefined); + }} + error={error} + helperText={t( + 'events.publishDialog.passwordHelp', + 'Re-type the password set at creation (or pick a new one). The email includes this exact text; the backend re-hashes it so the gallery login still works.', + )} + leftIcon={} + rightIcon={ + + } + /> +
+ )} + +
+ + +
+
+
+ ); +}; diff --git a/frontend/src/components/admin/index.ts b/frontend/src/components/admin/index.ts index c1bb95a2..d274aab2 100644 --- a/frontend/src/components/admin/index.ts +++ b/frontend/src/components/admin/index.ts @@ -17,6 +17,7 @@ export { AdminPhotoGrid } from './AdminPhotoGrid'; export { AdminPhotoViewer } from './AdminPhotoViewer'; export { PhotoFilters } from './PhotoFilters'; export { PasswordResetModal } from './PasswordResetModal'; +export { PublishGalleryDialog } from './PublishGalleryDialog'; export { AdminAuthenticatedImage } from './AdminAuthenticatedImage'; export { AdminAuthenticatedVideo } from './AdminAuthenticatedVideo'; export { ThemeCustomizerEnhanced } from './ThemeCustomizerEnhanced'; diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index e63ffee1..eb1fda5d 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -1018,6 +1018,15 @@ "publishAndNotify": "Veröffentlichen & Kunden benachrichtigen", "publishConfirm": "Dadurch wird die Galerie zugänglich und die Benachrichtigungs-E-Mail an den Kunden gesendet. Fortfahren?", "publishSuccess": "Galerie veröffentlicht und Kunde benachrichtigt!", + "publishDialog": { + "title": "Galerie veröffentlichen", + "descriptionWithEmail": "Die Galerie \"{{eventName}}\" wird zugänglich gemacht und die Benachrichtigungs-E-Mail an {{customerEmail}} gesendet.", + "descriptionNoEmail": "Die Galerie \"{{eventName}}\" wird zugänglich gemacht. Es ist keine Kunden-E-Mail hinterlegt – es wird keine Benachrichtigung gesendet.", + "passwordLabel": "Galerie-Passwort", + "passwordPlaceholder": "Galerie-Passwort eingeben", + "passwordHelp": "Gib das bei der Erstellung gesetzte Passwort erneut ein (oder wähle ein neues). Die E-Mail enthält genau diesen Text; das Backend hasht es erneut, sodass die Galerie-Anmeldung weiterhin funktioniert.", + "errorMinLength": "Das Passwort muss mindestens 6 Zeichen lang sein." + }, "draftBanner": "Diese Galerie befindet sich im Entwurfsmodus. Laden Sie Ihre Fotos hoch und veröffentlichen Sie, wenn Sie bereit sind.", "subtitle": "Verwalten Sie Ihre Fotogalerien und Veranstaltungen", "failedToLoadEvents": "Veranstaltungen konnten nicht geladen werden", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 51de701e..6151d286 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -565,6 +565,15 @@ "publishAndNotify": "Publish & Notify Client", "publishConfirm": "This will make the gallery accessible and send the notification email to the client. Continue?", "publishSuccess": "Gallery published and client notified!", + "publishDialog": { + "title": "Publish gallery", + "descriptionWithEmail": "Publishing \"{{eventName}}\" makes the gallery accessible and sends the notification email to {{customerEmail}}.", + "descriptionNoEmail": "Publishing \"{{eventName}}\" makes the gallery accessible. No customer email is set, so no notification will be sent.", + "passwordLabel": "Gallery password", + "passwordPlaceholder": "Enter the gallery password", + "passwordHelp": "Re-type the password set at creation (or pick a new one). The email includes this exact text; the backend re-hashes it so the gallery login still works.", + "errorMinLength": "Password must be at least 6 characters long." + }, "draftBanner": "This gallery is in draft mode. Upload your photos, then publish when ready.", "subtitle": "Manage your photo galleries and events", "failedToLoadEvents": "Failed to load events", diff --git a/frontend/src/pages/admin/EventDetailsPage.tsx b/frontend/src/pages/admin/EventDetailsPage.tsx index 640e4349..1ddf4332 100644 --- a/frontend/src/pages/admin/EventDetailsPage.tsx +++ b/frontend/src/pages/admin/EventDetailsPage.tsx @@ -59,7 +59,7 @@ import { toast } from 'react-toastify'; import { useLocalizedDate } from '../../hooks/useLocalizedDate'; import { Button, Input, Card, Loading, MarkdownContent, LocalizedDateInput } from '../../components/common'; -import { EventCategoryManager, AdminPhotoGrid, AdminPhotoViewer, PhotoFilters, PasswordResetModal, ThemeCustomizerEnhanced, ThemeDisplay, HeroPhotoSelector, FocalPointPicker, PhotoUploadModal, FeedbackSettings, FeedbackModerationPanel, EventRenameDialog, PhotoFilterPanel, PhotoExportMenu, AdminGuestsList } from '../../components/admin'; +import { EventCategoryManager, AdminPhotoGrid, AdminPhotoViewer, PhotoFilters, PasswordResetModal, PublishGalleryDialog, ThemeCustomizerEnhanced, ThemeDisplay, HeroPhotoSelector, FocalPointPicker, PhotoUploadModal, FeedbackSettings, FeedbackModerationPanel, EventRenameDialog, PhotoFilterPanel, PhotoExportMenu, AdminGuestsList } from '../../components/admin'; import { CustomerAccountPicker } from '../../components/admin/CustomerAccountPicker'; import { EventReminderOverrideCard } from '../../components/admin/EventReminderOverrideCard'; import { useFeatureFlags } from '../../contexts/FeatureFlagsContext'; @@ -373,6 +373,7 @@ export const EventDetailsPage: React.FC = () => { const [showPasswordReset, setShowPasswordReset] = useState(false); const [showNewPassword, setShowNewPassword] = useState(false); const [showRenameDialog, setShowRenameDialog] = useState(false); + const [showPublishDialog, setShowPublishDialog] = useState(false); const [logoUploading, setLogoUploading] = useState(false); const [currentTheme, setCurrentTheme] = useState(null); const [currentPresetName, setCurrentPresetName] = useState('default'); @@ -533,13 +534,16 @@ export const EventDetailsPage: React.FC = () => { }, }); - // Publish mutation (Draft mode) + // Publish mutation (Draft mode). Accepts the admin-typed password so the + // gallery_created email can carry the real plaintext (#627). const publishMutation = useMutation({ - mutationFn: () => eventsService.publishEvent(parseInt(id!)), + mutationFn: (password?: string) => + eventsService.publishEvent(parseInt(id!), password ? { password } : undefined), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['admin-event', id] }); queryClient.invalidateQueries({ queryKey: ['admin-events'] }); toast.success(t('events.publishSuccess')); + setShowPublishDialog(false); }, onError: () => { toast.error(t('errors.somethingWentWrong')); @@ -1040,11 +1044,7 @@ export const EventDetailsPage: React.FC = () => { variant="primary" size="sm" leftIcon={} - onClick={() => { - if (confirm(t('events.publishConfirm'))) { - publishMutation.mutate(); - } - }} + onClick={() => setShowPublishDialog(true)} isLoading={publishMutation.isPending} > {t('events.publishAndNotify')} @@ -2161,11 +2161,7 @@ export const EventDetailsPage: React.FC = () => {