* feat(events): publish without notifying, and send the gallery email later (#1235) Publishing queued the gallery_created email whenever any customer email existed, with no opt-out. A photographer working with a client who has no address yet — the Instagram-team case in discussion #1086 — had to type their OWN address into the required field, publish, receive the client-facing email themselves, and hand the link over by DM. Turning off `event_require_customer_email` is not the answer either: that is global, and the same photographer usually does collect addresses. Two halves, because a checkbox alone is only half a workflow: - `notify_customer` on publish, default TRUE. Absent means notify, so the v1 API, an older frontend and any script keep behaving exactly as before. When false the gallery goes live and nothing is queued — not the gallery_created email, not the assigned-customer-account notice, not WhatsApp. Publishing still logs activity and still fires the event.published webhook, because those describe a state change rather than a message to a customer. - POST /:id/send-gallery-email for an already-published gallery. Deliberately not restricted to galleries published quietly: re-sending is a normal thing to want (spam folder, wrong address since corrected) and refusing would push people to unpublish and republish, changing gallery state to work around a mail problem. Refused for a draft, whose link would not work yet, and for an event with no recipient. The email composition is now one helper shared by both, so an email sent a week later is identical to one sent at publish. UI: a checkbox in the publish dialog (checked by default, hidden when nobody would be notified anyway), and a "Send gallery email" action on published galleries that have a recipient. The password field follows the checkbox — unchecking it means nothing is being sent, so there is no plaintext to carry and no reason to demand it. EN + DE strings. 7 integration tests. Two fail without the change, verified by forcing notifyCustomer true and re-running; the rest pin the default, the draft and no-recipient refusals, and that a gallery with no recipient still publishes. * fix(events): make the publish dialog description follow the checkbox (#1235) Caught by screenshotting it. With "Send the gallery email now" unchecked, the paragraph above still read "...and sends the notification email to [email protected]" while the control directly beneath it said nothing would be sent — the dialog contradicted itself at exactly the moment the admin is deciding whether anything goes out. It now reads "No email will be sent — you can send it later from this page." when the box is clear. EN + DE. * fix(events): close six gaps in publish-quietly found by external review (#1235) TWO CORRECTIONS TO MY OWN VERIFICATION FIRST. `npx tsc --noEmit` in frontend/ is a NO-OP — the root tsconfig is solution-style with references and no include, so it checks nothing. Every "tsc clean" I claimed on this branch came from that. The real check, `tsc -p tsconfig.app.json`, showed two TS2339s I had introduced: `event.host_email` does not exist on the frontend Event type, which the admin API normalises away. Both recipient checks now use `customer_email`. PASSWORD ON SEND-LATER. The action promised to send the link and password but always called the endpoint without one, so a protected gallery got the "(set at creation)" sentinel — unusable — and this is most needed right after a quiet publish, the path that never collects a password. New SendGalleryEmailDialog asks for it, same shape and reasoning as the publish dialog (#627). Galleries with no password skip the field. WHATSAPP-ONLY GALLERIES COULD NOT PUBLISH QUIETLY. willNotify ignored customer_phone, so a phone-only gallery hid the opt-out AND told the admin nothing would be sent — while publish queued the WhatsApp anyway. Phone now counts, with its own description line. ASSIGNED-ACCOUNT NOTICES COULD NOT BE SENT LATER. The dialog promised it; the endpoint rejected anything without an inline recipient. It now falls through to the same customer-account path publish uses. EDITORS COULD NOT SEE THE ACTION. The send button was nested inside the events.archive gate, so the default editor role — events.edit, no archive — never saw a button for an endpoint it is allowed to call. Separate gates now. DEAD LINKS. The endpoint only checked is_draft, so an archived, inactive or expired gallery would send a link the gallery middleware rejects. All three are refused with a reason. 9 backend tests (2 new), 22 across the event suites. eslint clean on every changed frontend file; crud.js keeps its 2 pre-existing errors. * fix(events): persist the send-later password, and fix a long-standing isGalleryPublic misuse (#1235) Round 2 of external review. THE EMAIL COULD CARRY A PASSWORD THE GALLERY REJECTS. The send-later dialog invites "or pick a new one", but the route queued that plaintext without touching password_hash — so the customer got credentials that do not open the gallery. Worse than the sentinel it replaced, because it looks usable. The route now hashes and persists first, exactly as publish does. isGalleryPublic TAKES A VALUE, NOT AN EVENT — and this is pre-existing. normalizeRequirePassword returns its default for anything that is not a boolean/number/string, so isGalleryPublic(event) is ALWAYS false and `requirePassword` was always true. The publish dialog on main has demanded a password for public galleries for exactly this reason. Both call sites now pass event.require_password. Fixing the older one alongside mine rather than leaving a broken copy one line above a fixed one. ASSIGNED-ACCOUNT GALLERIES HAD NO BUTTON. The route falls through to the customer-account notice when there is no inline email, and the publish dialog promises that notice can be sent later — but the button only appeared with a customer_email, making the promise unkeepable. WHATSAPP CLAIM SOFTENED. Publish only queues WhatsApp when the config exists and is enabled, which the dialog cannot see. It now says the customer is notified there "if WhatsApp is configured" rather than asserting a send. 10 backend tests (1 new, covering the rehash). eslint clean on every changed frontend file; crud.js keeps its 2 pre-existing errors. * fix(events): don't reset the password for an account-only notice, hide unusable actions (#1235) Round 3 of external review. The first is a harm my own round-2 fix introduced. PASSWORD RESET FOR NOTHING. Round 2 persisted the supplied password before knowing which mail would go out. For a protected gallery with no inline email but assigned accounts, the dialog still demands a password, the hash was rewritten, and then the fallback sent customer_gallery_assigned — which links to the customer portal and never mentions a password. Net effect: the live gallery password silently changed and everyone holding the old one was locked out, in exchange for nothing. It is now persisted only when the mail that carries it is actually being sent. BUTTONS THE BACKEND WOULD REFUSE. The send action rendered for expired and inactive galleries, and counted assigned accounts the endpoint filters out as inactive — walking the admin through a dialog to reach a generic error toast. The card now mirrors the endpoint's eligibility rules, and only active accounts count toward having a recipient. 11 backend tests (1 new, pinning that the hash is untouched on the account path), 24 across the event suites. tsc and eslint clean on the changed files. * fix(events): make the send-later action agree with what the endpoint will do Three findings from an external review round, all the same shape: the UI predicted the endpoint's behaviour and got it wrong. GET /admin/events/:id mapped customer_accounts without is_active, so the "only ACTIVE accounts count" filter in OverviewTab compared undefined and excluded nothing. A gallery whose only assignments were deactivated showed the send action, and the endpoint then filtered every recipient and returned 400. is_active is exposed now, and the count applies the same predicate the fallback uses — active AND holding an address. is_active is coerced through toBoolean rather than compared with === false. On the default SQLite backend it comes back as 0, and 0 === false is false, so an inactive gallery kept offering a send that parseBooleanInput then rejected. Same class as #1028. The password prompt is gated on there being an inline recipient. With no customer_email the backend takes the account fallback, which sends customer_gallery_assigned — a portal link that never mentions a password — and deliberately skips the rehash. Asking for one there blocked the send behind a six-character value nothing consumes, and the dialog's promise that it would be rehashed was false. Frontend suite: 291 passed. tsc and eslint clean. * fix(events): don't mail a portal link to a customer who cannot sign in Round-2 finding from the external review. A passive customer — created directly and never invited — is an active account with a real address whose password_hash IS NULL. The account fallback happily mailed it customer_gallery_assigned, which links to /customer/dashboard, and customerAuth rejects login without a hash: the link goes to a door that will not open. Worse than failing, the route counted it and reported success, so the admin believed the customer had been told. getAssignmentsForEvent now derives can_sign_in (the predicate, never the hash) and the three call sites share one canReceiveGalleryNotice helper — publish, send-later, and the payload the UI predicts from all have to agree or the button appears and then 400s. The UI mirrors it. Sending passive customers an invitation instead of skipping them is the better product answer, and a separate feature. Refusing visibly beats a silent non-delivery in the meantime. Test asserts the refusal; it fails without the can_sign_in arm. --------- Co-authored-by: Paul Nothaft <[email protected]>
This commit is contained in:
co-authored by
Paul Nothaft
parent
bb2f709fdd
commit
1ef2b3c85b
@@ -7,10 +7,12 @@ interface PublishGalleryDialogProps {
|
||||
eventName: string;
|
||||
requirePassword: boolean;
|
||||
customerEmail?: string | null;
|
||||
/** WhatsApp recipient — publish notifies this too, so it counts as "someone gets told". */
|
||||
customerPhone?: string | null;
|
||||
/** Assigned customer accounts — notified via the account "your galleries" email when there's no inline email. */
|
||||
assignedCustomerCount?: number;
|
||||
isPublishing: boolean;
|
||||
onConfirm: (password?: string) => void;
|
||||
onConfirm: (password?: string, notifyCustomer?: boolean) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
@@ -30,23 +32,32 @@ export const PublishGalleryDialog: React.FC<PublishGalleryDialogProps> = ({
|
||||
eventName,
|
||||
requirePassword,
|
||||
customerEmail,
|
||||
customerPhone,
|
||||
assignedCustomerCount = 0,
|
||||
isPublishing,
|
||||
onConfirm,
|
||||
onClose,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
// Someone gets notified if there's an inline email OR an assigned account
|
||||
// (the latter via the account "your galleries" email).
|
||||
const willNotify = !!customerEmail || assignedCustomerCount > 0;
|
||||
// Someone gets notified if there's an inline email, an assigned account (the
|
||||
// account "your galleries" email), OR a phone — publish queues a WhatsApp
|
||||
// for that last one. Leaving the phone out hid the opt-out on phone-only
|
||||
// galleries AND told the admin nothing would be sent, while the WhatsApp
|
||||
// went out anyway.
|
||||
const willNotify = !!customerEmail || !!customerPhone || assignedCustomerCount > 0;
|
||||
const [password, setPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [error, setError] = useState<string | undefined>(undefined);
|
||||
// Defaults to notifying — that is what publish has always done, and the
|
||||
// quiet path is the exception (#1235).
|
||||
const [notifyCustomer, setNotifyCustomer] = useState(true);
|
||||
// The password is only collected (and required) on the inline-email path,
|
||||
// because the gallery_created email carries it. With no inline email the field
|
||||
// is hidden and the existing hash is kept — so don't gate submit on it, or a
|
||||
// password-protected gallery without an email could never be published.
|
||||
const needsPassword = requirePassword && !!customerEmail;
|
||||
const [password, setPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [error, setError] = useState<string | undefined>(undefined);
|
||||
// Unchecking "notify" hides it for the same reason: nothing is being sent,
|
||||
// so there is no plaintext to carry and no reason to demand it.
|
||||
const needsPassword = requirePassword && !!customerEmail && notifyCustomer;
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (needsPassword) {
|
||||
@@ -56,7 +67,7 @@ export const PublishGalleryDialog: React.FC<PublishGalleryDialogProps> = ({
|
||||
}
|
||||
}
|
||||
setError(undefined);
|
||||
onConfirm(needsPassword ? password : undefined);
|
||||
onConfirm(needsPassword ? password : undefined, notifyCustomer);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -76,7 +87,16 @@ export const PublishGalleryDialog: React.FC<PublishGalleryDialogProps> = ({
|
||||
</div>
|
||||
|
||||
<p className="text-neutral-600 dark:text-neutral-400 mb-4">
|
||||
{customerEmail
|
||||
{/* Follows the checkbox. Left static it contradicted itself — the
|
||||
text promised an email to the customer while the box beneath it
|
||||
said none would be sent. */}
|
||||
{willNotify && !notifyCustomer
|
||||
? t('events.publishDialog.descriptionQuiet', {
|
||||
eventName,
|
||||
defaultValue:
|
||||
'Publishing "{{eventName}}" makes the gallery accessible. No email will be sent — you can send it later from this page.',
|
||||
})
|
||||
: customerEmail
|
||||
? t('events.publishDialog.descriptionWithEmail', {
|
||||
eventName,
|
||||
customerEmail,
|
||||
@@ -90,6 +110,12 @@ export const PublishGalleryDialog: React.FC<PublishGalleryDialogProps> = ({
|
||||
defaultValue:
|
||||
'Publishing "{{eventName}}" makes the gallery accessible. The assigned customer account(s) will be notified by email (in their language) that it is available.',
|
||||
})
|
||||
: customerPhone
|
||||
? t('events.publishDialog.descriptionWhatsapp', {
|
||||
eventName,
|
||||
defaultValue:
|
||||
'Publishing "{{eventName}}" makes the gallery accessible. If WhatsApp is configured, the customer is notified there.',
|
||||
})
|
||||
: t('events.publishDialog.descriptionNoEmail', {
|
||||
eventName,
|
||||
defaultValue:
|
||||
@@ -97,6 +123,31 @@ export const PublishGalleryDialog: React.FC<PublishGalleryDialogProps> = ({
|
||||
})}
|
||||
</p>
|
||||
|
||||
{willNotify && (
|
||||
<label className="flex items-start gap-3 mb-4 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={notifyCustomer}
|
||||
onChange={(e) => {
|
||||
setNotifyCustomer(e.target.checked);
|
||||
if (error) setError(undefined);
|
||||
}}
|
||||
className="mt-1 h-4 w-4 rounded border-neutral-300 dark:border-neutral-600"
|
||||
/>
|
||||
<span className="text-sm">
|
||||
<span className="font-medium text-neutral-900 dark:text-neutral-100">
|
||||
{t('events.publishDialog.notifyLabel', 'Send the gallery email now')}
|
||||
</span>
|
||||
<span className="block text-neutral-600 dark:text-neutral-400">
|
||||
{t(
|
||||
'events.publishDialog.notifyHelp',
|
||||
'Uncheck to publish quietly — the gallery goes live and nothing is sent. You can send the email later from this page.',
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{needsPassword && (
|
||||
<div className="space-y-3 mb-4">
|
||||
<Input
|
||||
@@ -151,9 +202,11 @@ export const PublishGalleryDialog: React.FC<PublishGalleryDialogProps> = ({
|
||||
onClick={handleSubmit}
|
||||
disabled={isPublishing}
|
||||
isLoading={isPublishing}
|
||||
leftIcon={willNotify ? <Send className="w-4 h-4" /> : undefined}
|
||||
leftIcon={willNotify && notifyCustomer ? <Send className="w-4 h-4" /> : undefined}
|
||||
>
|
||||
{willNotify ? t('events.publishAndNotify') : t('events.publishDialog.justPublish', 'Publish')}
|
||||
{willNotify && notifyCustomer
|
||||
? t('events.publishAndNotify')
|
||||
: t('events.publishDialog.justPublish', 'Publish')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import React, { useState } from 'react';
|
||||
import { X, Mail, Lock, Eye, EyeOff } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Card, Input } from '../common';
|
||||
|
||||
interface SendGalleryEmailDialogProps {
|
||||
eventName: string;
|
||||
recipient: string;
|
||||
requirePassword: boolean;
|
||||
isSending: boolean;
|
||||
onConfirm: (password?: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the gallery email for an already-published gallery (#1235).
|
||||
*
|
||||
* It asks for the password for the same reason the publish dialog does (#627):
|
||||
* `password_hash` is a hash, so the plaintext only exists in the request the
|
||||
* admin types it into. Without it the email carries the "(set at creation)"
|
||||
* sentinel — and this action is most useful right after a quiet publish, which
|
||||
* is exactly the path that never collected a password. An email whose password
|
||||
* line reads "(set at creation)" cannot get the customer into the gallery, so
|
||||
* asking here is what makes the button do what its label promises.
|
||||
*
|
||||
* Galleries with no password skip the field entirely — there is nothing to
|
||||
* carry, and the email says so.
|
||||
*/
|
||||
export const SendGalleryEmailDialog: React.FC<SendGalleryEmailDialogProps> = ({
|
||||
eventName,
|
||||
recipient,
|
||||
requirePassword,
|
||||
isSending,
|
||||
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.sendGalleryEmail.title', 'Send gallery email')}
|
||||
</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">
|
||||
{t('events.sendGalleryEmail.description', {
|
||||
eventName,
|
||||
recipient,
|
||||
defaultValue: 'Sends the gallery link for "{{eventName}}" to {{recipient}}.',
|
||||
})}
|
||||
</p>
|
||||
|
||||
{requirePassword && (
|
||||
<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.sendGalleryEmail.passwordHelp',
|
||||
'The email includes this exact text. Re-type the gallery password (or pick a new one) — the backend re-hashes it so the 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 flex-col-reverse gap-3">
|
||||
<Button variant="outline" onClick={onClose} disabled={isSending}>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleSubmit}
|
||||
disabled={isSending}
|
||||
isLoading={isSending}
|
||||
leftIcon={<Mail className="w-4 h-4" />}
|
||||
>
|
||||
{t('events.sendGalleryEmail.button', 'Send gallery email')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -17,6 +17,7 @@ export { AdminPhotoViewer } from './AdminPhotoViewer';
|
||||
export { PhotoFilters } from './PhotoFilters';
|
||||
export { PasswordResetModal } from './PasswordResetModal';
|
||||
export { PublishGalleryDialog } from './PublishGalleryDialog';
|
||||
export { SendGalleryEmailDialog } from './SendGalleryEmailDialog';
|
||||
export { DuplicateEventDialog } from './DuplicateEventDialog';
|
||||
export { ExportPreviewModal } from './ExportPreviewModal';
|
||||
export { AdminAuthenticatedImage } from './AdminAuthenticatedImage';
|
||||
|
||||
@@ -1373,7 +1373,11 @@
|
||||
"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."
|
||||
"errorMinLength": "Das Passwort muss mindestens 6 Zeichen lang sein.",
|
||||
"notifyLabel": "Galerie-E-Mail jetzt senden",
|
||||
"notifyHelp": "Abwählen, um still zu veröffentlichen — die Galerie geht online, es wird nichts versendet. Die E-Mail lässt sich später auf dieser Seite senden.",
|
||||
"descriptionQuiet": "Durch das Veröffentlichen wird \"{{eventName}}\" zugänglich. Es wird keine E-Mail versendet — Sie können sie später auf dieser Seite senden.",
|
||||
"descriptionWhatsapp": "Durch das Veröffentlichen wird \"{{eventName}}\" zugänglich. Sofern WhatsApp eingerichtet ist, wird der Kunde dort benachrichtigt."
|
||||
},
|
||||
"duplicateEvent": "Galerie duplizieren",
|
||||
"duplicateDialog": {
|
||||
@@ -1513,6 +1517,16 @@
|
||||
"title": "Heldenbild als Vorschau für geteilte Links verwenden",
|
||||
"help": "Beim Teilen der Galerie-URL auf WhatsApp, Facebook, Slack usw. wird das oben gewählte Heldenbild als Link-Vorschau angezeigt. Das Thumbnail wird von Link-Preview-Crawlern ohne Authentifizierung abgerufen — wer die URL teilt, macht damit faktisch dieses Bild öffentlich. Standardmäßig aus; wähle erst ein Heldenbild, das du bewusst öffentlich zeigen möchtest, bevor du diese Option aktivierst.",
|
||||
"heroRequired": "Wähle zuerst oben ein Heldenbild — diese Option verwendet es als WhatsApp- / Facebook- / Slack-Vorschaubild."
|
||||
},
|
||||
"publishQuietSuccess": "Galerie veröffentlicht. Es wurde keine E-Mail versendet.",
|
||||
"sendGalleryEmail": {
|
||||
"button": "Galerie-E-Mail senden",
|
||||
"help": "Sendet den Galerie-Link an den Kunden. Bei Galerien mit Passwort bestätigen Sie es vorher.",
|
||||
"confirm": "Galerie-E-Mail an {{recipient}} senden?",
|
||||
"success": "Galerie-E-Mail an {{recipient}} eingereiht.",
|
||||
"title": "Galerie-E-Mail senden",
|
||||
"description": "Sendet den Galerie-Link für \"{{eventName}}\" an {{recipient}}.",
|
||||
"passwordHelp": "Die E-Mail enthält genau diesen Text. Geben Sie das Galerie-Passwort erneut ein (oder wählen Sie ein neues) — das Backend hasht es neu, damit der Login weiter funktioniert."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
|
||||
@@ -903,7 +903,11 @@
|
||||
"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."
|
||||
"errorMinLength": "Password must be at least 6 characters long.",
|
||||
"notifyLabel": "Send the gallery email now",
|
||||
"notifyHelp": "Uncheck to publish quietly — the gallery goes live and nothing is sent. You can send the email later from this page.",
|
||||
"descriptionQuiet": "Publishing \"{{eventName}}\" makes the gallery accessible. No email will be sent — you can send it later from this page.",
|
||||
"descriptionWhatsapp": "Publishing \"{{eventName}}\" makes the gallery accessible. If WhatsApp is configured, the customer is notified there."
|
||||
},
|
||||
"duplicateEvent": "Duplicate gallery",
|
||||
"duplicateDialog": {
|
||||
@@ -1054,6 +1058,16 @@
|
||||
"title": "Use hero photo as social-share preview",
|
||||
"help": "When this gallery URL is shared on WhatsApp, Facebook, Slack, etc., the link preview will show the hero photo above. The thumbnail is fetched unauthenticated by link-preview crawlers — anyone with the URL effectively makes this image public. Off by default; pick a hero you are comfortable surfacing publicly before enabling.",
|
||||
"heroRequired": "Pick a hero photo above first — this option uses it as the WhatsApp / Facebook / Slack preview image."
|
||||
},
|
||||
"publishQuietSuccess": "Gallery published. No email was sent.",
|
||||
"sendGalleryEmail": {
|
||||
"button": "Send gallery email",
|
||||
"help": "Sends the gallery link to the customer. You confirm the password first if the gallery has one.",
|
||||
"confirm": "Send the gallery email to {{recipient}}?",
|
||||
"success": "Gallery email queued to {{recipient}}.",
|
||||
"title": "Send gallery email",
|
||||
"description": "Sends the gallery link for \"{{eventName}}\" to {{recipient}}.",
|
||||
"passwordHelp": "The email includes this exact text. Re-type the gallery password (or pick a new one) — the backend re-hashes it so the login still works."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { toast } from 'react-toastify';
|
||||
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||
|
||||
import { Loading } from '../../components/common';
|
||||
import { PasswordResetModal, PublishGalleryDialog, DuplicateEventDialog, EventRenameDialog, AdminGuestsList } from '../../components/admin';
|
||||
import { PasswordResetModal, PublishGalleryDialog, SendGalleryEmailDialog, DuplicateEventDialog, EventRenameDialog, AdminGuestsList } from '../../components/admin';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { eventsService } from '../../services/events.service';
|
||||
import { usePublicSettings } from '../../hooks/usePublicSettings';
|
||||
@@ -60,6 +60,7 @@ export const EventDetailsPage: React.FC = () => {
|
||||
const [showNewPassword, setShowNewPassword] = useState(false);
|
||||
const [showRenameDialog, setShowRenameDialog] = useState(false);
|
||||
const [showPublishDialog, setShowPublishDialog] = useState(false);
|
||||
const [showSendEmailDialog, setShowSendEmailDialog] = useState(false);
|
||||
const [showDuplicateDialog, setShowDuplicateDialog] = useState(false);
|
||||
const [currentTheme, setCurrentTheme] = useState<ThemeConfig | null>(null);
|
||||
const [currentPresetName, setCurrentPresetName] = useState<string>('default');
|
||||
@@ -245,12 +246,22 @@ export const EventDetailsPage: React.FC = () => {
|
||||
// Publish mutation (Draft mode). Accepts the admin-typed password so the
|
||||
// gallery_created email can carry the real plaintext (#627).
|
||||
const publishMutation = useMutation({
|
||||
mutationFn: (password?: string) =>
|
||||
eventsService.publishEvent(parseInt(id!), password ? { password } : undefined),
|
||||
onSuccess: () => {
|
||||
mutationFn: (vars: { password?: string; notifyCustomer?: boolean }) =>
|
||||
eventsService.publishEvent(parseInt(id!), {
|
||||
password: vars.password,
|
||||
notifyCustomer: vars.notifyCustomer,
|
||||
}),
|
||||
onSuccess: (result) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-event', id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-events'] });
|
||||
toast.success(t('events.publishSuccess'));
|
||||
// Say which of the two happened — "published" and "published and
|
||||
// emailed your customer" are different enough that a single message
|
||||
// would leave the admin unsure whether anything went out (#1235).
|
||||
toast.success(
|
||||
result?.notified_customer === false
|
||||
? t('events.publishQuietSuccess', 'Gallery published. No email was sent.')
|
||||
: t('events.publishSuccess'),
|
||||
);
|
||||
setShowPublishDialog(false);
|
||||
},
|
||||
onError: () => {
|
||||
@@ -258,6 +269,25 @@ export const EventDetailsPage: React.FC = () => {
|
||||
},
|
||||
});
|
||||
|
||||
// Send the gallery email after the fact (#1235). Pairs with publishing
|
||||
// quietly: the address usually arrives later than the gallery does.
|
||||
const sendGalleryEmailMutation = useMutation({
|
||||
mutationFn: (password?: string) =>
|
||||
eventsService.sendGalleryEmail(parseInt(id!), password ? { password } : undefined),
|
||||
onSuccess: (result) => {
|
||||
toast.success(
|
||||
t('events.sendGalleryEmail.success', {
|
||||
recipient: result.recipient,
|
||||
defaultValue: 'Gallery email queued to {{recipient}}.',
|
||||
}),
|
||||
);
|
||||
setShowSendEmailDialog(false);
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t('errors.somethingWentWrong'));
|
||||
},
|
||||
});
|
||||
|
||||
// Duplicate mutation (#626). Backend creates a draft inheriting branding +
|
||||
// behaviour + categories from the source; we navigate to the new event so
|
||||
// the admin can finish configuring + publish.
|
||||
@@ -627,6 +657,8 @@ export const EventDetailsPage: React.FC = () => {
|
||||
setShowPasswordReset={setShowPasswordReset}
|
||||
setShowPublishDialog={setShowPublishDialog}
|
||||
setShowDuplicateDialog={setShowDuplicateDialog}
|
||||
onSendGalleryEmail={() => setShowSendEmailDialog(true)}
|
||||
isSendingGalleryEmail={sendGalleryEmailMutation.isPending}
|
||||
onArchive={() => archiveMutation.mutate()}
|
||||
isArchiving={archiveMutation.isPending}
|
||||
isPublishing={publishMutation.isPending}
|
||||
@@ -706,17 +738,41 @@ export const EventDetailsPage: React.FC = () => {
|
||||
{showPublishDialog && (
|
||||
<PublishGalleryDialog
|
||||
eventName={event.event_name}
|
||||
requirePassword={isGalleryPublic(event) ? false : true}
|
||||
requirePassword={!isGalleryPublic(event.require_password)}
|
||||
customerEmail={event.customer_email}
|
||||
customerPhone={event.customer_phone}
|
||||
assignedCustomerCount={((event as { customer_accounts?: Array<{ id: number }> }).customer_accounts || []).length}
|
||||
isPublishing={publishMutation.isPending}
|
||||
onConfirm={(password) => publishMutation.mutate(password)}
|
||||
onConfirm={(password, notifyCustomer) => publishMutation.mutate({ password, notifyCustomer })}
|
||||
onClose={() => {
|
||||
if (!publishMutation.isPending) setShowPublishDialog(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Send Gallery Email Dialog (#1235) — asks for the password for the
|
||||
same reason publish does: the plaintext only exists in this request,
|
||||
and this action is most useful right after a quiet publish, which
|
||||
never collected one. */}
|
||||
{showSendEmailDialog && (
|
||||
<SendGalleryEmailDialog
|
||||
eventName={event.event_name}
|
||||
recipient={event.customer_email}
|
||||
// Only the inline-email path carries the password. With no
|
||||
// customer_email the backend takes the account fallback, which sends
|
||||
// customer_gallery_assigned — a portal link that never mentions a
|
||||
// password — and deliberately skips the rehash (crud.js). Asking for
|
||||
// one there blocks the send behind a value nothing consumes, and the
|
||||
// dialog's promise that it will be rehashed would be false.
|
||||
requirePassword={!!event.customer_email && !isGalleryPublic(event.require_password)}
|
||||
isSending={sendGalleryEmailMutation.isPending}
|
||||
onConfirm={(password) => sendGalleryEmailMutation.mutate(password)}
|
||||
onClose={() => {
|
||||
if (!sendGalleryEmailMutation.isPending) setShowSendEmailDialog(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Duplicate Event Dialog (#626) — admin types a new event name/date
|
||||
(+ optional customer); backend clones the source gallery's config
|
||||
and we navigate to the new draft. */}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Archive, Send, Copy } from 'lucide-react';
|
||||
import { Archive, Send, Copy, Mail } from 'lucide-react';
|
||||
import type { Event } from '../../../types';
|
||||
import { Button, Card } from '../../../components/common';
|
||||
import { PermissionGate } from '../../../components/admin/PermissionGate';
|
||||
import { toBoolean } from '../../../utils/parsers';
|
||||
|
||||
interface EventActionsCardProps {
|
||||
event: Event;
|
||||
@@ -13,6 +14,11 @@ interface EventActionsCardProps {
|
||||
isPublishing: boolean;
|
||||
setShowDuplicateDialog: (show: boolean) => void;
|
||||
isDuplicating: boolean;
|
||||
/** Send the gallery email for an already-published gallery (#1235). */
|
||||
onSendGalleryEmail: () => void;
|
||||
isSendingGalleryEmail: boolean;
|
||||
/** Assigned customer accounts — a recipient even with no inline email. */
|
||||
assignedCustomerCount?: number;
|
||||
}
|
||||
|
||||
export const EventActionsCard: React.FC<EventActionsCardProps> = ({
|
||||
@@ -22,10 +28,22 @@ export const EventActionsCard: React.FC<EventActionsCardProps> = ({
|
||||
setShowPublishDialog,
|
||||
isPublishing,
|
||||
setShowDuplicateDialog,
|
||||
isDuplicating
|
||||
isDuplicating,
|
||||
onSendGalleryEmail,
|
||||
isSendingGalleryEmail,
|
||||
assignedCustomerCount = 0
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Mirror the endpoint's own eligibility rules. Showing a button the backend
|
||||
// is guaranteed to reject just walks the admin through a dialog to reach a
|
||||
// generic error toast — the archived case is already handled by the caller,
|
||||
// which does not render this card at all for archived events.
|
||||
const hasRecipient = !!event.customer_email || assignedCustomerCount > 0;
|
||||
const isExpired = !!event.expires_at && new Date(event.expires_at) <= new Date();
|
||||
const isInactive = !toBoolean(event.is_active, true);
|
||||
const canSendGalleryEmail = hasRecipient && !isExpired && !isInactive;
|
||||
|
||||
return (
|
||||
<Card padding="md">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('events.actions')}</h2>
|
||||
@@ -47,7 +65,38 @@ export const EventActionsCard: React.FC<EventActionsCardProps> = ({
|
||||
</p>
|
||||
</PermissionGate>
|
||||
) : (
|
||||
<PermissionGate permission="events.archive">
|
||||
<>
|
||||
{/* Send the gallery email after publishing (#1235). The pair to
|
||||
publishing quietly — the address often arrives later than the
|
||||
gallery — and it doubles as a re-send when the first one was
|
||||
lost. Hidden without a recipient, since there is nowhere to
|
||||
send it.
|
||||
|
||||
Its own gate, NOT nested inside the archive one below: the
|
||||
default editor role has events.edit but not events.archive, so
|
||||
nesting hid this action from exactly the people allowed to use
|
||||
the endpoint behind it. */}
|
||||
{/* Assigned accounts count as a recipient: the route falls through
|
||||
to the customer-account notice when there is no inline email,
|
||||
and the publish dialog promises that notice can be sent later —
|
||||
so hiding the button here made that promise unkeepable. */}
|
||||
{canSendGalleryEmail && (
|
||||
<PermissionGate permission="events.edit">
|
||||
<Button
|
||||
variant="outline"
|
||||
leftIcon={<Mail className="w-4 h-4" />}
|
||||
onClick={onSendGalleryEmail}
|
||||
isLoading={isSendingGalleryEmail}
|
||||
className="w-full justify-center"
|
||||
>
|
||||
{t('events.sendGalleryEmail.button', 'Send gallery email')}
|
||||
</Button>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 text-center mb-3">
|
||||
{t('events.sendGalleryEmail.help', 'Sends the gallery link to the customer. You confirm the password first if the gallery has one.')}
|
||||
</p>
|
||||
</PermissionGate>
|
||||
)}
|
||||
<PermissionGate permission="events.archive">
|
||||
<Button
|
||||
variant="outline"
|
||||
leftIcon={<Archive className="w-4 h-4" />}
|
||||
@@ -64,7 +113,8 @@ export const EventActionsCard: React.FC<EventActionsCardProps> = ({
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 text-center">
|
||||
{t('events.archivingInfo')}
|
||||
</p>
|
||||
</PermissionGate>
|
||||
</PermissionGate>
|
||||
</>
|
||||
)}
|
||||
{/* Duplicate (#626) — visible in both draft and live mode.
|
||||
Creates a new draft inheriting this gallery's config. */}
|
||||
|
||||
@@ -20,6 +20,7 @@ import { EventActionsCard } from './EventActionsCard';
|
||||
import { PhotoStatisticsCard } from './PhotoStatisticsCard';
|
||||
import { EventThemeSection } from './EventThemeSection';
|
||||
import { ArchiveStatusCard } from './ArchiveStatusCard';
|
||||
import { toBoolean } from '../../../utils/parsers';
|
||||
|
||||
interface OverviewTabProps {
|
||||
event: Event;
|
||||
@@ -40,6 +41,8 @@ interface OverviewTabProps {
|
||||
setActiveTab: (tab: EventDetailsTab) => void;
|
||||
setShowPasswordReset: (show: boolean) => void;
|
||||
setShowPublishDialog: (show: boolean) => void;
|
||||
onSendGalleryEmail: () => void;
|
||||
isSendingGalleryEmail: boolean;
|
||||
setShowDuplicateDialog: (show: boolean) => void;
|
||||
onArchive: () => void;
|
||||
isArchiving: boolean;
|
||||
@@ -72,6 +75,8 @@ export const OverviewTab: React.FC<OverviewTabProps> = ({
|
||||
setActiveTab,
|
||||
setShowPasswordReset,
|
||||
setShowPublishDialog,
|
||||
onSendGalleryEmail,
|
||||
isSendingGalleryEmail,
|
||||
setShowDuplicateDialog,
|
||||
onArchive,
|
||||
isArchiving,
|
||||
@@ -177,6 +182,25 @@ export const OverviewTab: React.FC<OverviewTabProps> = ({
|
||||
isPublishing={isPublishing}
|
||||
setShowDuplicateDialog={setShowDuplicateDialog}
|
||||
isDuplicating={isDuplicating}
|
||||
onSendGalleryEmail={onSendGalleryEmail}
|
||||
isSendingGalleryEmail={isSendingGalleryEmail}
|
||||
assignedCustomerCount={
|
||||
((event as {
|
||||
customer_accounts?: Array<{
|
||||
id: number; email?: string; is_active?: unknown; can_sign_in?: unknown
|
||||
}>
|
||||
}).customer_accounts || [])
|
||||
// Only accounts the endpoint would actually mail count, or
|
||||
// the button appears and then 400s. Mirrors
|
||||
// canReceiveGalleryNotice in crud.js: active, holding an
|
||||
// address, and able to sign in — a PASSIVE customer
|
||||
// (never invited, so no password) would get a portal link
|
||||
// to a door that will not open. toBoolean rather than
|
||||
// `!== false` because SQLite returns 0/1.
|
||||
.filter((c) => toBoolean(c.is_active, true)
|
||||
&& toBoolean(c.can_sign_in, true)
|
||||
&& !!c.email).length
|
||||
}
|
||||
/>
|
||||
</PermissionGate>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { api } from '../config/api';
|
||||
import type { Event } from '../types';
|
||||
import { normalizeRequirePassword } from '../utils/accessControl';
|
||||
import { toBoolean } from '../utils/parsers';
|
||||
|
||||
const normalizeEvent = (event: Event): Event => {
|
||||
const legacyHostName = (event as any)?.host_name;
|
||||
@@ -14,6 +15,10 @@ const normalizeEvent = (event: Event): Event => {
|
||||
customer_name: customerName,
|
||||
customer_email: customerEmail,
|
||||
require_password: normalizeRequirePassword((event as any)?.require_password, true),
|
||||
// SQLite hands these back as 0/1, so a strict `=== false` consumer reads
|
||||
// an inactive gallery as active (the #1028 class). Coerced once here with
|
||||
// the same default the backend's parseBooleanInput uses.
|
||||
is_active: toBoolean((event as any)?.is_active, true),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -272,11 +277,30 @@ export const eventsService = {
|
||||
// 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; notifyCustomer?: boolean },
|
||||
): Promise<{ message: string; is_draft: boolean; notified_customer?: boolean }> {
|
||||
// Only send what was actually chosen. Omitting notify_customer entirely
|
||||
// when it is true keeps the request identical to the pre-#1235 shape.
|
||||
const body: Record<string, unknown> = {};
|
||||
if (options?.password) body.password = options.password;
|
||||
if (options?.notifyCustomer === false) body.notify_customer = false;
|
||||
const response = await api.post(
|
||||
`/admin/events/${eventId}/publish`,
|
||||
Object.keys(body).length ? body : undefined,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Send the gallery email for an already-published gallery (#1235). The other
|
||||
// half of publishing quietly: the address often arrives after the gallery
|
||||
// does. Also covers an ordinary re-send when the first one was lost.
|
||||
async sendGalleryEmail(
|
||||
eventId: number,
|
||||
options?: { password?: string },
|
||||
): Promise<{ message: string; is_draft: boolean }> {
|
||||
): Promise<{ message: string; recipient: string }> {
|
||||
const body = options?.password ? { password: options.password } : undefined;
|
||||
const response = await api.post(`/admin/events/${eventId}/publish`, body);
|
||||
const response = await api.post(`/admin/events/${eventId}/send-gallery-email`, body);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user