fix(events): publish-from-draft email carries the real password (#627)

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.
This commit is contained in:
Paul Nothaft
2026-06-17 22:58:17 +02:00
parent ea6245cfde
commit 83b568ee2d
7 changed files with 228 additions and 20 deletions
+39 -4
View File
@@ -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 || ''
};
@@ -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<PublishGalleryDialogProps> = ({
eventName,
requirePassword,
customerEmail,
isPublishing,
onConfirm,
onClose,
}) => {
const { t } = useTranslation();
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState<string | undefined>(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 (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<Card className="max-w-md w-full">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100">
{t('events.publishDialog.title', 'Publish gallery')}
</h2>
<button
onClick={onClose}
className="text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300"
aria-label={t('common.close', 'Close')}
>
<X className="w-5 h-5" />
</button>
</div>
<p className="text-neutral-600 dark:text-neutral-400 mb-4">
{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.',
})}
</p>
{requirePassword && customerEmail && (
<div className="space-y-3 mb-4">
<Input
type={showPassword ? 'text' : 'password'}
label={t('events.publishDialog.passwordLabel', 'Gallery password')}
placeholder={t('events.publishDialog.passwordPlaceholder', 'Enter the gallery password')}
value={password}
onChange={(e) => {
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={<Lock className="w-5 h-5" />}
rightIcon={
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="p-1"
aria-label={showPassword ? t('events.passwordReset.hide', 'Hide') : t('events.passwordReset.show', 'Show')}
>
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button>
}
/>
</div>
)}
<div className="flex gap-3">
<Button
variant="outline"
onClick={onClose}
disabled={isPublishing}
className="flex-1"
>
{t('common.cancel', 'Cancel')}
</Button>
<Button
variant="primary"
onClick={handleSubmit}
disabled={isPublishing}
isLoading={isPublishing}
leftIcon={<Send className="w-4 h-4" />}
className="flex-1"
>
{t('events.publishAndNotify')}
</Button>
</div>
</Card>
</div>
);
};
+1
View File
@@ -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';
+9
View File
@@ -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",
+9
View File
@@ -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",
+24 -13
View File
@@ -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<ThemeConfig | null>(null);
const [currentPresetName, setCurrentPresetName] = useState<string>('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={<Send className="w-4 h-4" />}
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 = () => {
<Button
variant="primary"
leftIcon={<Send className="w-4 h-4" />}
onClick={() => {
if (confirm(t('events.publishConfirm'))) {
publishMutation.mutate();
}
}}
onClick={() => setShowPublishDialog(true)}
isLoading={publishMutation.isPending}
className="w-full justify-center"
>
@@ -2604,6 +2600,21 @@ export const EventDetailsPage: React.FC = () => {
onValidate={(newName) => eventsService.validateRename(event.id, newName)}
/>
{/* Publish Gallery Dialog (#627) prompts for the password so the
gallery_created email carries the real plaintext, not the sentinel. */}
{showPublishDialog && (
<PublishGalleryDialog
eventName={event.event_name}
requirePassword={isGalleryPublic(event) ? false : true}
customerEmail={event.customer_email}
isPublishing={publishMutation.isPending}
onConfirm={(password) => publishMutation.mutate(password)}
onClose={() => {
if (!publishMutation.isPending) setShowPublishDialog(false);
}}
/>
)}
</div>
);
};
+10 -3
View File
@@ -219,9 +219,16 @@ export const eventsService = {
return response.data;
},
// Publish a draft event
async publishEvent(eventId: number): Promise<{ message: string; is_draft: boolean }> {
const response = await api.post(`/admin/events/${eventId}/publish`);
// Publish a draft event. `password` is optional; when the event is
// password-protected, supplying the password here makes the gallery_created
// email carry the actual plaintext instead of the "set at creation" sentinel
// (#627) — the backend also re-hashes it so the stored hash matches.
async publishEvent(
eventId: number,
options?: { password?: string },
): Promise<{ message: string; is_draft: boolean }> {
const body = options?.password ? { password: options.password } : undefined;
const response = await api.post(`/admin/events/${eventId}/publish`, body);
return response.data;
},