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
@@ -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';