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:
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user