feat(security): opt-in recoverable gallery passwords (#1341)

* feat(security): opt-in recoverable gallery passwords

Gallery passwords are bcrypt hashes, so an admin who needs to hand a
password to a client a second time has to reset it, which invalidates
what the client already has. This adds a security setting,
security_gallery_password_recoverable, off by default, that keeps an
AES-256-GCM encrypted copy of each gallery password and client PIN next
to the hash. The key is derived from GALLERY_PASSWORD_ENCRYPTION_KEY or
JWT_SECRET.

While the setting is on:
- create, publish, send-later, edit, reset and the v1 API write the copy
  alongside the hash; turning a gallery's password requirement off
  clears it
- GET /api/admin/events/:id/password returns the copy to admins with
  events.edit and ownership, and writes a gallery_password_viewed
  activity entry on every real reveal
- resend-email uses the stored password instead of the "set at creation"
  sentinel, so the client receives what already works

Switching the setting off purges every stored copy. Login and hash
verification are untouched; the copy is never read on the gallery side.

The Security tab carries the toggle with a warning that stays visible,
and the event page shows "Show password" with copy buttons only while
the setting is on and the gallery has a secret.

Relates to issue 1271

* fix(security): close the write-versus-switch-off race in the password vault

The recoverable setting is read while an event insert is assembled and the
client-PIN hash awaits after that, so a settings request that switched the
feature off and purged in that gap was overtaken by the insert. Every write
site now re-reads the setting right after its statement and clears its own
row when the setting is off; the settings writer flips the value before it
purges, so either the purge or the re-check catches the row.

* fix(security): resend carries the stored client PIN and link; deterministic tamper test

The creation mail includes the client-access link and PIN; a resend only
sent the gallery password even when a stored PIN was available. The
ciphertext tamper assertion replaced the last two characters with a
constant, which was a no-op roughly once in 4096 runs.

* fix(security): drop the revealed password after Send gallery email

The send-later route can replace the password; the share card keys its
revealed copy on the event query's refetch time, so invalidate the event
after the send like the other password-changing mutations do.

* fix(security): purge leftovers before the setting write when turning recovery on

Switching on wrote the setting first and purged after, so a password write
that read the new "on" in between stored a copy the purge then deleted.
Turning on now purges before the write; turning off keeps purging after it,
which together with the write-site re-check leaves the vault holding
exactly what was written while the setting was on.

* chore(security): drop the duplicate rateLimitService import left by the rebase

* chore(usage): register the password recovery routes in the v5 coverage inventory

The inventory moved from v4 to v5 on main; the entry added by this branch
followed it.

---------

Co-authored-by: Paul Nothaft <[email protected]>
This commit is contained in:
Paul Nothaft
2026-09-07 20:36:48 +02:00
committed by GitHub
co-authored by Paul Nothaft
parent 16b79ee119
commit fb9da72f14
21 changed files with 905 additions and 39 deletions
@@ -55,6 +55,8 @@ export interface SecuritySettings {
enable_recaptcha: boolean;
recaptcha_site_key: string;
recaptcha_secret_key: string;
// #1271 — opt-in reversible storage of gallery passwords and client PINs
gallery_password_recoverable: boolean;
}
/** The general per-IP API rate limiter (#1337). Keys match app_settings. */
@@ -190,7 +192,8 @@ export function useSettingsState() {
lockout_duration_minutes: 30,
enable_recaptcha: false,
recaptcha_site_key: '',
recaptcha_secret_key: ''
recaptcha_secret_key: '',
gallery_password_recoverable: false
});
// Rate limiter state. The fallbacks mirror the backend's defaults, but the
@@ -309,7 +312,8 @@ export function useSettingsState() {
lockout_duration_minutes: toNumber(settings.security_lockout_duration_minutes, 30),
enable_recaptcha: toBoolean(settings.security_enable_recaptcha, false),
recaptcha_site_key: settings.security_recaptcha_site_key ?? '',
recaptcha_secret_key: settings.security_recaptcha_secret_key ?? ''
recaptcha_secret_key: settings.security_recaptcha_secret_key ?? '',
gallery_password_recoverable: toBoolean(settings.security_gallery_password_recoverable, false)
});
setRateLimitSettings({
@@ -458,6 +462,8 @@ export function useSettingsState() {
onSuccess: () => {
toast.success(t('toast.settingsSaved'));
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
// the event page's "Show password" availability follows this tab (#1271)
queryClient.invalidateQueries({ queryKey: ['admin-event-password-status'] });
},
onError: (error: unknown) => {
toast.error(t(error instanceof Error && error.message === 'RATE_LIMIT_INVALID'
@@ -1,5 +1,5 @@
import React from 'react';
import { Save, Key, AlertCircle, ShieldCheck } from 'lucide-react';
import { Save, Key, AlertCircle, AlertTriangle, ShieldCheck } from 'lucide-react';
import { Button, Card, Input } from '../../../components/common';
import { useTranslation } from 'react-i18next';
import type { SecuritySettings, RateLimitSettings } from '../hooks/useSettingsState';
@@ -214,6 +214,38 @@ export const SecurityTab: React.FC<SecurityTabProps> = ({
</div>
</Card>
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('settings.security.galleryPasswordsTitle')}</h2>
<div className="space-y-4">
<label className="flex items-start">
<input
type="checkbox"
checked={securitySettings.gallery_password_recoverable}
onChange={(e) => setSecuritySettings(prev => ({ ...prev, gallery_password_recoverable: e.target.checked }))}
className="w-4 h-4 mt-0.5 text-primary-600 rounded focus:ring-primary-500"
/>
<span className="ml-2 text-sm text-neutral-700 dark:text-neutral-300">
<span className="block font-medium text-neutral-900 dark:text-neutral-100">{t('settings.security.galleryPasswordRecoverable')}</span>
<span className="block mt-1">{t('settings.security.galleryPasswordRecoverableHelp')}</span>
</span>
</label>
{/* #1271 — reversible storage is a deliberate trade of security for
convenience; the warning stays visible whether or not it is on. */}
<div className="p-4 bg-amber-50 dark:bg-amber-900/30 border border-amber-200 dark:border-amber-800 rounded-lg">
<div className="flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-amber-600 dark:text-amber-400 flex-shrink-0" />
<div className="text-sm text-amber-800 dark:text-amber-200 space-y-1">
<p className="font-medium">{t('settings.security.galleryPasswordRecoverableWarningTitle')}</p>
<p>{t('settings.security.galleryPasswordRecoverableWarning')}</p>
<p>{t('settings.security.galleryPasswordRecoverableOffNote')}</p>
</div>
</div>
</div>
</div>
</Card>
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('settings.security.recaptchaSettings')}</h2>
+14
View File
@@ -1911,6 +1911,14 @@
"creationEmailResent": "Die Erstellungs-E-Mail wurde zur Warteschlange hinzugefügt",
"emailQueuedHint": "Der Warteschlangen-Prozessor versendet sie — prüfen Sie den Systemzustand, falls sie nicht ankommt.",
"failedToResendEmail": "Fehler beim erneuten Senden der Erstellungs-E-Mail",
"showGalleryPassword": "Passwort anzeigen",
"hideGalleryPassword": "Passwort verbergen",
"galleryPasswordLabel": "Galerie-Passwort",
"clientPinLabel": "Kunden-PIN",
"galleryPasswordNotStored": "Für diese Galerie ist kein Passwort hinterlegt. Gespeichert wird ab dem Zeitpunkt, an dem die Einstellung aktiviert wurde; setzen Sie das Passwort zurück, um ein neues zu hinterlegen.",
"failedToLoadPassword": "Das hinterlegte Passwort konnte nicht geladen werden",
"resendWithStoredPasswordHint": "Ist für diese Galerie ein Passwort hinterlegt, enthält die E-Mail es.",
"passwordCopied": "Kopiert",
"photoStatistics": "Fotostatistiken",
"managePhotos": "Fotos verwalten",
"actions": "Aktionen",
@@ -2377,6 +2385,12 @@
"rateLimitPublicOnlyHelp": "Wenn aktiv, zählen nur Anfragen an /api/public und /api/gallery.",
"rateLimitNatNote": "Die Einheit ist die Client-IP. Ein Büro, ein Haushalt oder das WLAN einer Location teilen sich eine Adresse und damit ein Budget. Hinter einem Reverse-Proxy stimmt die Client-IP nur, wenn TRUST_PROXY den Proxy abdeckt; sonst teilen sich alle Besucher das Budget des Proxys. Abgewiesene Anfragen stehen als „Rate limit exceeded“ im Backend-Log.",
"rateLimitInvalid": "Werte der Ratenbegrenzung außerhalb des Bereichs: Zeitfenster 160 Minuten, Anfragen 1010000, fehlgeschlagene Anmeldungen 1100. Es wurde nichts gespeichert.",
"galleryPasswordsTitle": "Galerie-Passwörter",
"galleryPasswordRecoverable": "Galerie-Passwörter wiederherstellbar speichern",
"galleryPasswordRecoverableHelp": "Speichert neben dem Hash eine verschlüsselte Kopie jedes Galerie-Passworts und jeder Kunden-PIN, damit Sie sie auf der Event-Seite anzeigen und die Zugangs-E-Mail erneut senden können, ohne ein neues Passwort zu erzeugen.",
"galleryPasswordRecoverableWarningTitle": "Das schwächt den Schutz Ihrer Galerien",
"galleryPasswordRecoverableWarning": "Die Kopie ist mit dem Server-Geheimnis verschlüsselt, aber wer Zugriff auf die Datenbank und die Server-Konfiguration hat oder hier Admin-Zugriff , kann jedes gespeicherte Passwort lesen. Jedes Anzeigen wird im Aktivitätsprotokoll festgehalten. Lassen Sie die Option aus, wenn Sie sie nicht brauchen.",
"galleryPasswordRecoverableOffNote": "Beim Ausschalten werden alle gespeicherten Kopien gelöscht. Es werden nur Passwörter behalten, die gesetzt wurden, während die Option aktiv war.",
"twoFactorTitle": "Zwei-Faktor-Authentifizierung",
"twoFactorNote": "Die Zwei-Faktor-Authentifizierung wird jetzt pro Admin unter Einstellungen → Allgemein → Admin-Konto verwaltet. Jeder Admin aktiviert sie für seine eigene Anmeldung."
},
+14
View File
@@ -1407,6 +1407,14 @@
"creationEmailResent": "Creation email has been queued for sending",
"emailQueuedHint": "The queue processor sends it — check System health if it does not arrive.",
"failedToResendEmail": "Failed to resend creation email",
"showGalleryPassword": "Show password",
"hideGalleryPassword": "Hide password",
"galleryPasswordLabel": "Gallery password",
"clientPinLabel": "Client PIN",
"galleryPasswordNotStored": "No stored password for this gallery. Passwords are kept from the moment the setting was switched on; reset the password to store a new one.",
"failedToLoadPassword": "Failed to load the stored password",
"resendWithStoredPasswordHint": "If a password is stored for this gallery, the email includes it.",
"passwordCopied": "Copied",
"photoStatistics": "Photo Statistics",
"totalPhotos": "Total Photos",
"managePhotos": "Manage Photos",
@@ -1875,6 +1883,12 @@
"rateLimitPublicOnlyHelp": "When on, only /api/public and /api/gallery requests count.",
"rateLimitNatNote": "The unit is the client IP. An office, a household or a venue's Wi-Fi share one address and therefore one budget. Behind a reverse proxy the client IP is only correct if TRUST_PROXY covers the proxy; otherwise every visitor shares the proxy's budget. Rejected requests are logged as \"Rate limit exceeded\" in the backend log.",
"rateLimitInvalid": "Rate limiter values are out of range: window 160 minutes, requests 1010000, failed logins 1100. Nothing was saved.",
"galleryPasswordsTitle": "Gallery passwords",
"galleryPasswordRecoverable": "Keep gallery passwords recoverable",
"galleryPasswordRecoverableHelp": "Stores an encrypted copy of each gallery password and client PIN next to the hash, so you can show it on the event page and resend the access email without generating a new password.",
"galleryPasswordRecoverableWarningTitle": "This weakens the protection of your galleries",
"galleryPasswordRecoverableWarning": "The copy is encrypted with the server secret, but anyone with access to the database and the server configuration — or with admin access here — can read every stored password. Every reveal is written to the activity log. Leave it off unless you need it.",
"galleryPasswordRecoverableOffNote": "Switching it off deletes all stored copies. Only passwords set while it is on are kept.",
"twoFactorTitle": "Two-factor authentication",
"twoFactorNote": "Two-factor authentication is now managed per admin from Settings → General → Admin Account. Each admin enables it for their own login."
},
+11 -1
View File
@@ -132,7 +132,11 @@ export const EventDetailsPage: React.FC = () => {
});
// Fetch event details
const { data: event, isLoading: eventLoading, isError: eventError, refetch: refetchEvent } = useQuery({
// dataUpdatedAt doubles as the "password may have changed" signal for the
// share card (#1271): every successful (re)fetch — after an edit, a PIN
// change, a publish, a reset — drops a revealed copy, even when the event
// comes back structurally equal and therefore reference-equal.
const { data: event, isLoading: eventLoading, isError: eventError, refetch: refetchEvent, dataUpdatedAt: eventUpdatedAt } = useQuery({
queryKey: ['admin-event', id],
queryFn: () => eventsService.getEvent(parseInt(id!)),
enabled: !!id,
@@ -325,6 +329,9 @@ export const EventDetailsPage: React.FC = () => {
})} ${t('events.emailQueuedHint', 'The queue processor sends it — check System health if it does not arrive.')}`,
);
setShowSendEmailDialog(false);
// The send may have replaced the password (#627); a refetch bumps the
// version the share card keys its revealed copy on (#1271).
queryClient.invalidateQueries({ queryKey: ['admin-event', id] });
},
onError: () => {
toast.error(t('errors.somethingWentWrong'));
@@ -696,6 +703,7 @@ export const EventDetailsPage: React.FC = () => {
<OverviewTab
event={event}
id={id}
passwordVersion={eventUpdatedAt}
isEditing={isEditing}
editForm={editForm}
setEditForm={setEditForm}
@@ -765,6 +773,8 @@ export const EventDetailsPage: React.FC = () => {
eventType={event.event_type}
onConfirm={async (sendEmail, password) => {
const result = await eventsService.resetPassword(event.id, sendEmail, password);
// refetch so the share card drops a revealed password (#1271)
queryClient.invalidateQueries({ queryKey: ['admin-event', id] });
return result;
}}
onClose={() => setShowPasswordReset(false)}
@@ -25,6 +25,7 @@ import { toBoolean } from '../../../utils/parsers';
interface OverviewTabProps {
event: Event;
id: string | undefined;
passwordVersion?: number;
isEditing: boolean;
editForm: EditFormState;
setEditForm: React.Dispatch<React.SetStateAction<EditFormState>>;
@@ -59,6 +60,7 @@ interface OverviewTabProps {
export const OverviewTab: React.FC<OverviewTabProps> = ({
event,
id,
passwordVersion,
isEditing,
editForm,
setEditForm,
@@ -114,7 +116,7 @@ export const OverviewTab: React.FC<OverviewTabProps> = ({
/>
{/* Share Link */}
<ShareLinkCard event={event} setShowPasswordReset={setShowPasswordReset} />
<ShareLinkCard event={event} setShowPasswordReset={setShowPasswordReset} passwordVersion={passwordVersion} />
{/* Branded short URLs (#699). Sits between the canonical share-link
card and the Client Access card — same "things you share with
@@ -1,13 +1,36 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { toast } from 'react-toastify';
import { Copy, CheckCircle, Key, Mail, QrCode, Download } from 'lucide-react';
import { Copy, CheckCircle, Key, Mail, QrCode, Download, Eye, EyeOff } from 'lucide-react';
import type { Event } from '../../../types';
import { Button, Card } from '../../../components/common';
import { eventsService } from '../../../services/events.service';
import { buildShareLinkUrl } from '../../../utils/url';
import { isGalleryPublic } from '../../../utils/accessControl';
// Clipboard with the textarea/execCommand fallback for non-HTTPS installs
// (the documented http://host:3000/admin setup has no navigator.clipboard).
const copyText = async (text: string) => {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
return;
}
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
textArea.style.top = '-999999px';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
const successful = document.execCommand('copy');
document.body.removeChild(textArea);
if (!successful) {
throw new Error('Copy failed');
}
};
const saveBlob = (blob: Blob, filename: string) => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
@@ -22,12 +45,60 @@ const saveBlob = (blob: Blob, filename: string) => {
interface ShareLinkCardProps {
event: Event;
setShowPasswordReset: (show: boolean) => void;
/** Bumped by the page after a password/PIN change (#1271). */
passwordVersion?: number;
}
export const ShareLinkCard: React.FC<ShareLinkCardProps> = ({ event, setShowPasswordReset }) => {
export const ShareLinkCard: React.FC<ShareLinkCardProps> = ({ event, setShowPasswordReset, passwordVersion = 0 }) => {
const { t, i18n } = useTranslation();
const [copiedLink, setCopiedLink] = useState(false);
const [qrPreviewUrl, setQrPreviewUrl] = useState<string | null>(null);
const [stored, setStored] = useState<{ password: string | null; client_password: string | null } | null>(null);
const [loadingStored, setLoadingStored] = useState(false);
const [copiedSecret, setCopiedSecret] = useState<string | null>(null);
// Generation of the current reveal: a reset that lands while a reveal is
// in flight must not have the late response bring the old password back.
const revealGeneration = useRef(0);
// #1271 — "Show password" only exists while the admin has opted into
// recoverable storage in Settings → Security. Off is the default; the
// button never renders for a plain install. Asked through the event
// (not the settings API) so editors get the same answer as admins.
const { data: recoverableStatus } = useQuery({
queryKey: ['admin-event-password-status', event.id],
queryFn: () => eventsService.getGalleryPasswordStatus(event.id),
});
const passwordRecoverable = recoverableStatus?.enabled === true;
const hasSecret = !isGalleryPublic(event.require_password) || Boolean(event.client_access_enabled);
// A password change (reset, edit) or an event switch drops the revealed
// values — the copy on screen may no longer be the one that works.
useEffect(() => { revealGeneration.current += 1; setStored(null); setLoadingStored(false); }, [event.id, passwordVersion]);
const handleShowPassword = async () => {
if (stored) { setStored(null); return; }
const generation = ++revealGeneration.current;
setLoadingStored(true);
try {
const result = await eventsService.getGalleryPassword(event.id);
if (generation !== revealGeneration.current) return;
setStored({ password: result.password, client_password: result.client_password });
} catch {
if (generation === revealGeneration.current) toast.error(t('events.failedToLoadPassword', 'Failed to load the stored password'));
} finally {
if (generation === revealGeneration.current) setLoadingStored(false);
}
};
const copySecret = async (label: string, value: string) => {
try {
await copyText(value);
setCopiedSecret(label);
setTimeout(() => setCopiedSecret(null), 2000);
} catch {
toast.error(t('errors.copyFailed', 'Failed to copy link. Please copy manually.'));
}
};
// QR preview (#836) — fetched as a blob because the admin API needs the
// Bearer token; a plain <img src> would come back 401. The `stale` flag
@@ -78,27 +149,7 @@ export const ShareLinkCard: React.FC<ShareLinkCardProps> = ({ event, setShowPass
return;
}
const shareUrl = buildShareLinkUrl(event.share_link);
// Try modern clipboard API first
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(shareUrl);
} else {
// Fallback for non-HTTPS contexts or older browsers
const textArea = document.createElement('textarea');
textArea.value = shareUrl;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
textArea.style.top = '-999999px';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
const successful = document.execCommand('copy');
document.body.removeChild(textArea);
if (!successful) {
throw new Error('Copy failed');
}
}
await copyText(buildShareLinkUrl(event.share_link));
setCopiedLink(true);
setTimeout(() => setCopiedLink(false), 2000);
@@ -177,6 +228,46 @@ export const ShareLinkCard: React.FC<ShareLinkCardProps> = ({ event, setShowPass
{!event.is_archived && (
<div className="mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-700 space-y-2">
{passwordRecoverable && hasSecret && (
<>
<Button
variant="outline"
size="sm"
leftIcon={stored ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
onClick={handleShowPassword}
isLoading={loadingStored}
className="w-full justify-center"
data-testid="show-gallery-password"
>
{stored ? t('events.hideGalleryPassword', 'Hide password') : t('events.showGalleryPassword', 'Show password')}
</Button>
{stored && (
<div className="rounded-lg border border-neutral-200 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-700/50 p-3 space-y-2 text-sm" data-testid="stored-gallery-password">
{!stored.password && !stored.client_password ? (
<p className="text-neutral-600 dark:text-neutral-400">{t('events.galleryPasswordNotStored')}</p>
) : (
([
['password', t('events.galleryPasswordLabel', 'Gallery password'), stored.password],
['client_password', t('events.clientPinLabel', 'Client PIN'), stored.client_password],
] as const).filter(([, , value]) => Boolean(value)).map(([key, label, value]) => (
<div key={key} className="flex items-center gap-2">
<span className="text-neutral-600 dark:text-neutral-400 shrink-0">{label}</span>
<code className="flex-1 min-w-0 truncate font-mono text-neutral-900 dark:text-neutral-100">{value}</code>
<button
type="button"
onClick={() => copySecret(key, value as string)}
className="p-1 text-neutral-500 hover:text-neutral-900 dark:hover:text-neutral-100"
aria-label={`${t('events.copy')} ${label}`}
>
{copiedSecret === key ? <CheckCircle className="w-4 h-4 text-green-600" /> : <Copy className="w-4 h-4" />}
</button>
</div>
))
)}
</div>
)}
</>
)}
<Button
variant="outline"
size="sm"
@@ -205,6 +296,9 @@ export const ShareLinkCard: React.FC<ShareLinkCardProps> = ({ event, setShowPass
>
{t('events.resendCreationEmail')}
</Button>
{passwordRecoverable && hasSecret && (
<p className="text-xs text-neutral-500 dark:text-neutral-400 text-center">{t('events.resendWithStoredPasswordHint')}</p>
)}
</div>
)}
</Card>
@@ -0,0 +1,105 @@
/**
* "Show password" on the event page (#1271) exists only while the admin has
* opted into recoverable storage in Settings → Security, and only for
* galleries that have a secret to show.
*/
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ShareLinkCard } from '../ShareLinkCard';
import type { Event } from '../../../../types';
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key, i18n: { language: 'en' } }),
// components/common barrel -> ErrorBoundary -> i18n/config calls
// .use(initReactI18next) at import time; same shim as ProductUsageTab.test.tsx
initReactI18next: { type: '3rdParty', init: () => {} },
}));
vi.mock('react-toastify', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
vi.mock('../../../../services/events.service', () => ({
eventsService: {
getQrBlob: vi.fn().mockRejectedValue(new Error('no qr in tests')),
getGalleryPassword: vi.fn(),
getGalleryPasswordStatus: vi.fn(),
resendCreationEmail: vi.fn(),
},
}));
import { eventsService } from '../../../../services/events.service';
const baseEvent = {
id: 7,
slug: 'ada-wedding',
event_name: 'Ada Wedding',
share_link: '/gallery/ada-wedding/tok',
require_password: true,
client_access_enabled: false,
is_archived: false,
} as unknown as Event;
function renderCard(event: Partial<Event> = {}, passwordVersion = 0) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const tree = (version: number) => (
<QueryClientProvider client={client}>
<ShareLinkCard event={{ ...baseEvent, ...event } as Event} setShowPasswordReset={() => {}} passwordVersion={version} />
</QueryClientProvider>
);
const utils = render(tree(passwordVersion));
return { ...utils, bump: (version: number) => utils.rerender(tree(version)) };
}
describe('ShareLinkCard — recoverable gallery password', () => {
beforeEach(() => {
vi.mocked(eventsService.getGalleryPasswordStatus).mockReset();
vi.mocked(eventsService.getGalleryPassword).mockReset();
});
it('shows no password button while the setting is off', async () => {
vi.mocked(eventsService.getGalleryPasswordStatus).mockResolvedValue({ enabled: false });
renderCard();
await waitFor(() => expect(eventsService.getGalleryPasswordStatus).toHaveBeenCalledWith(7));
expect(screen.queryByTestId('show-gallery-password')).toBeNull();
expect(screen.getByText('events.resetGalleryPassword')).toBeInTheDocument();
});
it('shows no password button for a public gallery without client access', async () => {
vi.mocked(eventsService.getGalleryPasswordStatus).mockResolvedValue({ enabled: true });
renderCard({ require_password: false, client_access_enabled: false });
await waitFor(() => expect(eventsService.getGalleryPasswordStatus).toHaveBeenCalled());
expect(screen.queryByTestId('show-gallery-password')).toBeNull();
});
it('reveals the stored password and client PIN on click, hides them again', async () => {
vi.mocked(eventsService.getGalleryPasswordStatus).mockResolvedValue({ enabled: true });
vi.mocked(eventsService.getGalleryPassword).mockResolvedValue({ enabled: true, password: 'Sunset-42!', client_password: '7788' });
renderCard({ client_access_enabled: true });
const button = await screen.findByTestId('show-gallery-password');
expect(screen.queryByText('Sunset-42!')).toBeNull();
fireEvent.click(button);
expect(await screen.findByText('Sunset-42!')).toBeInTheDocument();
expect(screen.getByText('7788')).toBeInTheDocument();
expect(eventsService.getGalleryPassword).toHaveBeenCalledWith(7);
fireEvent.click(screen.getByTestId('show-gallery-password'));
expect(screen.queryByText('Sunset-42!')).toBeNull();
});
it('explains when nothing is stored yet', async () => {
vi.mocked(eventsService.getGalleryPasswordStatus).mockResolvedValue({ enabled: true });
vi.mocked(eventsService.getGalleryPassword).mockResolvedValue({ enabled: true, password: null, client_password: null });
renderCard();
fireEvent.click(await screen.findByTestId('show-gallery-password'));
expect(await screen.findByText('events.galleryPasswordNotStored')).toBeInTheDocument();
});
it('drops a revealed password when the page reports a password change', async () => {
vi.mocked(eventsService.getGalleryPasswordStatus).mockResolvedValue({ enabled: true });
vi.mocked(eventsService.getGalleryPassword).mockResolvedValue({ enabled: true, password: 'Sunset-42!', client_password: null });
const { bump } = renderCard({}, 0);
fireEvent.click(await screen.findByTestId('show-gallery-password'));
expect(await screen.findByText('Sunset-42!')).toBeInTheDocument();
bump(1);
await waitFor(() => expect(screen.queryByText('Sunset-42!')).toBeNull());
});
});
+19
View File
@@ -268,6 +268,25 @@ export const eventsService = {
return response.data;
},
// Whether recoverable gallery passwords (#1271) are switched on. Answered
// per event so editors without settings access can ask too.
async getGalleryPasswordStatus(eventId: number): Promise<{ enabled: boolean }> {
const response = await api.get(`/admin/events/${eventId}/password-status`);
return response.data;
},
// Stored gallery password / client PIN (#1271). Only populated when the
// security setting "gallery_password_recoverable" is on; `enabled: false`
// means the feature is off and there is nothing to show.
async getGalleryPassword(eventId: number): Promise<{
enabled: boolean;
password: string | null;
client_password: string | null;
}> {
const response = await api.get(`/admin/events/${eventId}/password`);
return response.data;
},
// Validate rename
async validateRename(eventId: number, newEventName: string): Promise<{
valid: boolean;