Commit Graph
100 Commits
Author SHA1 Message Date
Paul Nothaft 28f14955e2 fix(gallery): clear the guest identity on gallery logout
The gallery password is one shared secret per event and does not
distinguish people. With the guest identity outliving the tab, logging out
and letting the next person enter that password greeted them by the
previous guest's name, with "forget me" - which erases that guest's
selections server-side - one click away. Logout is the leaving-this-device
signal, so it now drops the local identity too. Server row untouched.
2026-09-02 10:22:55 +02:00
Paul Nothaft f2f40893c1 fix(guests): drop a stored identity when a spent invite names someone else
A guest coming back through their own already-redeemed link is the
ordinary #1265 case, and the identity the device holds is theirs. The same
link opened on a shared device that holds another guest's identity is not:
the redemption 409s, ensureIdentity() falls through to the stored identity,
and the visitor's likes are filed under the previous person.

The two cases were indistinguishable client-side, so the 409/410 body now
carries the invite's guest_id. On a mismatch the stored identity is cleared
and the visitor is asked who they are. A response without guest_id keeps
the previous behaviour.
2026-09-02 10:22:55 +02:00
Paul Nothaft 7a1ea842e4 fix(guests): read identity from whichever store holds it, write it as a pair
Two defects in the storage fallback, both reproduced:

The quota fallback repointed reads at sessionStorage through module state,
which a reload discards. The next page load probed localStorage, passed
the one-byte probe, tried to promote the pair and was refused on the same
quota, swallowed that, and read an empty localStorage: the identity sat one
store over, unreadable, and the guest re-registered. Reads are now
read-through: primary store first, sessionStorage second, promoting into
the primary only when it will take the pair and leaving it where it fits
when it will not. No module state has to remember which store won.

The migration wrote the token before the profile, so a store that accepted
the first write and refused the second left a token with no profile:
x-guest-token was sent while the provider prompted to register, producing
a second row with two live tokens. Every write is now profile-first and
rolls back on failure, so a store holds the whole pair or none of it.
2026-09-02 10:22:55 +02:00
Paul Nothaft 7d51aa3db9 fix(guests): don't answer feedback with a stale identity mid-invite
Last open finding from codex round 3 on #1268.

Invite redemption is async and the gallery stays interactive while it runs, so
a like clicked in that window resolved against the persisted identity and was
filed under the wrong guest permanently. ensureIdentity() now waits on the
in-flight redemption and re-reads the result before falling back to the stored
identity or the prompt.
2026-09-02 10:22:55 +02:00
Paul Nothaft e9babf65e7 fix(guests): rebuild consumers on identity switch; repair fallback reads
Codex review round 3 on #1268. Three of these were defects in the round 1-2
fixes themselves.

Consumers holding local feedback state are now rebuilt on an identity switch.
Invalidating queries was not enough: six gallery layouts seed their liked set
behind a mount-only likedSeededRef ('so refetches don't clobber in-session
optimistic toggles') and PhotoLightbox keeps its own copy, so a refetch left
the previous guest's hearts on screen. The provider re-keys its subtree, which
covers all seven without touching them. Deliberately only on a switch away
from an established identity -- remounting on first sign-in would tear down
the gallery under the click that triggered the prompt and drop the pending
action.

The storage fallback now repoints reads. storeGuestIdentity wrote to
sessionStorage when localStorage rejected the real write but left
resolvedStorage on localStorage, so every later read missed: x-guest-token was
never sent and the identity vanished on reload. The fallback looked like it
worked while achieving nothing.

Clearing an identity now notifies this tab. Native storage events fire only in
other documents, so the interceptor dropping a server-rejected identity left
the provider still showing that guest and ensureIdentity() still handing it
out. A same-tab event completes the loop.

Cross-tab adoption resolves pending callers. A tab parked on the prompt
awaiting ensureIdentity() while another tab registers now completes exactly as
register() does, instead of hanging forever and registering a second guest if
the visitor submits the still-open prompt.
2026-09-02 10:22:55 +02:00
Paul Nothaft f3f37a8c77 fix(guests): invite wins over stored identity; clear server-rejected ones
Codex review round 2 on #1268. Four findings, all reachable only because the
identity now persists.

An explicit ?invite= now takes precedence. The redeem effect skipped when an
identity already existed, which was harmless while identity died with the tab.
Persisted, it means opening guest B's invite on a browser where guest A once
visited restores A, never redeems B's invite, and files B's likes under A. A
ref keeps it to one redemption per token.

Guest-scoped caches are invalidated when the identity changes. my-feedback,
gallery-photos and photo-feedback are keyed by slug and photo id, never by
guest, so they outlived an identity change and showed the previous guest's
likes while requests already carried the new token. Now reachable three ways:
another tab, 'Not you?', and an invite redeemed over an existing identity.

An identity the server has rejected is dropped. resolveGuest nulls req.guest
for a soft-deleted or merged-away row even when the JWT is validly signed and
unexpired, and the route answers GUEST_IDENTITY_REQUIRED — no client-side
expiry check can catch that. Self-limiting when identity died with the tab;
persisted, it would fail every like for up to 30 days while the footer still
showed the guest's name.

The write fallback now covers the real write, not just the probe. A one-byte
probe fits in a nearly-full store that still rejects a JWT plus profile, which
left the context believing it was signed in with nothing persisted.
2026-09-02 10:22:55 +02:00
Paul Nothaft 51db1e09e9 fix(guests): expire stale tokens, sync tabs, survive unwritable storage
Codex review round 1 on #1268. All three findings are consequences of the
storage move itself.

Expired tokens now read as absent. GUEST_TOKEN_TTL is 30 days and
sessionStorage almost never survived that long, so 'stored but expired' was
unreachable before; persisting the token makes it routine. Nothing else
clears it -- the 401 handler in config/api.ts only drops gallery_event_<slug>
-- so the visitor was shown as signed in while every like 401'd, and
ensureIdentity() short-circuited so recovery was never offered. The signature
is still the server's business; an unparseable token is left alone.

Tabs now stay in step. localStorage is shared where sessionStorage gave each
tab its own copy, so 'Not you?' or a registration in one tab silently changed
the token every other tab sends while they still displayed the old name --
their likes would land on the new guest, the exact misattribution this branch
set out to stop. A storage listener rehydrates the others.

Storage is probed for writability, not just readability. A store that reads
but throws on setItem (quota, private mode) sailed past the read-only guard,
and storeGuestIdentity threw after the server had created the guest: failed
registration, retry, duplicate row. Writes are also wrapped so a storage
failure degrades to a per-session identity instead of rejecting registration.
2026-09-02 10:22:55 +02:00
Paul Nothaft a21c4d3bf5 fix(guests): keep guest identity across a tab close
Closes #1265.

The guest JWT and profile lived in sessionStorage, so the practical lifetime
of an identity was "until this tab closes". GUEST_TOKEN_TTL was raised to 30
days in #1216 specifically to stop identity churn, but it governs how long the
token stays valid, not how long the browser keeps it -- so it was almost never
reached.

A guest who closed the tab and came back through the same emailed link got the
registration prompt again, and the ?invite= token in that link is single-use
and already redeemed, so it could not put them back. Typing the same name
inserted a second gallery_guests row: their earlier likes then belonged to an
identity they could no longer act as, and could not be removed.

Moved to localStorage, which is the reporter's suggestion and the one that
lines up with the TTL that already exists.

This does not reopen the objection #1216 raised. Deduplicating on a typed
email was rejected there because anyone knowing an address could claim that
person's identity, and answering differently for a known address leaks which
addresses are in the gallery. This grants nothing to anyone -- it only stops
the browser discarding a token it was already given. Gallery ACCESS stays in
sessionStorage (galleryAuthStorage.ts) and is untouched, so a returning
visitor still has to pass the gallery password before a stored identity means
anything.

Two things the storage swap alone would have got wrong:

- Anyone with a gallery open at upgrade time would be treated as a new guest
  on their next reload -- the exact duplicate-row bug this fixes, fired once
  per in-flight guest. getGuestToken/getGuestIdentity now move a pre-#1265
  sessionStorage entry across on first read. It moves rather than copies, and
  a fresh registration in the current tab always wins over a stale copy.
  clearGuestIdentity clears both stores, so "forget me" cannot be undone by a
  leftover being migrated back.

- Identity now surviving a tab close means a second person on a shared device
  can be greeted by the previous visitor's name. Their only exit was "Forget
  me", which soft-deletes the guest row and anonymizes their feedback -- it
  would erase the wrong person's selections. Added a non-destructive
  signOut() and a "Not you?" control next to it, which only clears the
  identity on this device.

Storage access already funnelled through one getStorage() accessor, so the
swap is a one-line change there; it falls back to sessionStorage when
localStorage throws (Safari private mode, blocked by policy) rather than
dropping identity entirely.

6 tests. 3 fail against the old implementation, including the core "survives a
tab close" case; the other 3 pin the migration and the both-stores clear.

Note: the new "Not you?" string is added to en and de. i18n:ci is already red
on main (11,405 missing keys) because the extractor there manages six locales
while only en/de are kept at parity; this adds 4 entries of that same class.
PR #1267 fixes the check itself.
2026-09-02 10:22:55 +02:00
Paul Nothaft 19e125d814 fix(security): raise the general limiter's fallback budget to 300
The general /api limiter had been inert since it was written, so its 100
requests per 15 minutes per IP was never exercised against real traffic.
Applying it for the first time with that budget would have 429'd a venue
wifi NAT after roughly twenty guests per window, since every call a
gallery landing page makes before the password is typed counts. 300 keeps
the protection and clears the realistic case. An explicit app_settings
value still wins over this fallback.
2026-09-02 09:43:11 +02:00
Paul Nothaft 23a433f411 docs(analytics): state the tracker proxy's trust model
The SSRF vetting is resolve-then-fetch and production-only. Say so, and
say why that is acceptable: the hostname is admin-controlled, the request
is confined to allowlisted paths and carries no PicPeak credentials, and
the S3/MinIO client already takes the same posture.
2026-09-02 09:43:11 +02:00
Paul Nothaft a912817ec8 refactor(archives): use the shared LIKE escape helpers
The local escapeLike copy and its comment predate 0ef51148, which stopped
escapeLikePattern() doubling single quotes. The comment was therefore
false and the helper byte-identical to the shared one. Use
escapeLikePattern() + likeWithEscape(), as every other search does.
2026-09-02 09:43:11 +02:00
Paul Nothaft 5a0c9f53b0 fix(security): rate-limit the password-change endpoints per IP too
POST /api/auth/admin/change-password and POST /api/customer/profile/password
both verify the current password before replacing it, which makes them a
credential check an attacker holding a hijacked session can drive at will:
the session's own JWT skips the general limiter as authenticated, and they
were not in the auth gate's table. Both join it. Only failures count, so
the one change a user legitimately makes costs nothing.
2026-09-02 09:43:11 +02:00
Paul Nothaft 4515632300 fix(migrations): judge each German field on its own in migration 195
repairGerman gated subject, body_html and body_text on body_html alone, the
same defect Codex found in migration 194: an admin who had translated only
the subject lost it the moment the HTML still matched English, and down()
is a deliberate no-op, so the loss was unrecoverable. Each field is now
judged independently for both the translations row and the legacy _de
columns, matching 194's corrected pattern. Two tests pin the two
directions (translated subject over English body, and the reverse).
2026-09-02 09:43:11 +02:00
Paul Nothaft a929affd7e fix(security): close the case-sensitivity bypass in the API rate limiter
Express's `case sensitive routing` is off by default, so /API/admin/events
reaches the same handler as /api/admin/events. Both the gate's `/api/` prefix
test and rateLimitService's public-endpoint classification compared the raw
path, so simply upper-casing a letter skipped the limiter entirely.

Verified against a real Express app before fixing: /api/admin/events routes
and hits the gate; /API/admin/events and /Api/Admin/Events route and miss it.

Both now match on a lower-cased path. The auth gate added alongside was
already immune -- its patterns carry the `i` flag for exactly this reason.

Not changed: rateLimitSecurity.hasValidAdminToken's /api/admin/ test has the
same shape, but there the case-sensitive comparison fails safe -- an
upper-cased path simply does not get the admin skip, so it is rate limited
rather than exempted. Making it case-insensitive would widen a skip, so it is
left alone. maintenance.js's isAdminRoute is fail-safe for the same reason.
2026-09-02 09:43:11 +02:00
Paul Nothaft 50e8ed6e58 fix(security): apply per-IP rate limiting to credential endpoints
The five authRateLimiter registrations were inert for the same reason the
general one was -- registered below the error handler. Auth endpoints have
never had an IP limit; the 5-attempt behaviour QA observed is the per-account
lockout in authSecurity.js, which is a different mechanism and is untouched.

They could not simply be activated: app.use('/api/auth', ...) is a prefix, so
a 5-per-window budget would have covered GET /api/auth/session and
POST /api/auth/password-strength, which the frontend calls far more than five
times per window. That locks users out.

The real surface was enumerated by loading the routers and walking
router.stack rather than grepping, which showed two of the five registrations
pointed at routes that do not exist: adminAuth.js has no /login (admin login
is POST /api/auth/admin/login) and there is no /api/gallery/:slug/verify
(gallery verify is POST /api/auth/gallery/verify).

Now limited, on exact method+path: admin login, admin MFA verify, gallery
password verify, share-login, client PIN, setup verify-token, setup admin,
customer login, customer password-reset. Deliberately unlimited: session
checks, password-strength, logouts, authenticated change-password, the SSO
round-trip (a 429 on the callback breaks login from shared corporate IPs),
and one-time invite/accept-invite links.

Two choices carry the design. skipSuccessfulRequests means only failed
attempts spend budget, which is what makes 5-per-IP survivable behind NAT --
ten guests on one venue wifi all typing the correct gallery password consume
nothing -- and means a legitimate admin cannot be locked out by their own
success. And the limiter keeps its own rateLimit() instance, hence its own
store and its own per-IP bucket, with the general gate's auth exemption left
in place: sharing a counter is exactly the lockout described above.

Patterns are case-insensitive because Express's case-sensitive routing is off
by default, so POST /api/auth/admin/LOGIN reaches the login handler and a
case-sensitive pattern would have been a free bypass.

max is now read per request, so the Settings UI's rate_limit_auth_max_requests
applies without a restart, matching the general limiter.

Tests prove both directions: each credential endpoint 429s on attempt 6 with
the response shape the four login pages already branch on, each benign
endpoint still returns 200 after 40 calls, the two buckets are independent in
both directions, and 30 consecutive successful logins consume no budget.

Refs testplan REPORT.md, rate-limiter gap.
2026-09-02 09:43:11 +02:00
Paul Nothaft 30ac4140af chore(backend): teach eslint the rest-sibling omission idiom
Adds varsIgnorePattern and ignoreRestSiblings to no-unused-vars, the config
recommendation left open when the lint backlog was cleared.

The "omit fields via rest spread" idiom is intentional and recurring --
adminEvents/helpers.js destructures password_hash and client_password_hash
purely to keep them out of `...rest` -- and without ignoreRestSiblings every
occurrence needs its own disable comment, which is noise that also suppresses
genuine findings on the same line. Removed the one such comment that now
exists; the explanatory comment above it stays, since the intent is not
obvious from the code.

Lint stays at 0 problems.

Refs testplan REPORT.md D1.
2026-09-02 09:43:11 +02:00
Paul Nothaft 4646d5de69 i18n: drop the keys this branch orphaned
Cleaning up after our own changes, not pre-existing dead keys.

- gallery.feedback.* (10 keys) -- StoryFeedbackSheet was their only consumer
  and it was removed in 3ef4bd8c as an unreachable duplicate of the lightbox.
- cssTemplates.title and settings.moderation.wordFilters -- orphaned by
  b80ce73e, which removed the component-side heading on the tabs that rendered
  a heading identical to the shell's.
- settings.analytics.customCspWarningText -- superseded by
  customOnlyCspWarningText in 9251745a, which was deliberately a new key so the
  stale pre-proxy string could not win over the new inline default. The sibling
  customCspWarning title is still in use and stays.

Each verified to have zero t() references in src before removal. Key-diff
against HEAD: en/de -13, the six partial locales -12 (they never had
customCspWarningText), 0 changed and 0 added in any of the eight.
removeUnusedKeys is false by design, so this had to be a deliberate pass.
2026-09-02 09:43:10 +02:00
Paul Nothaft 4c6ca49b17 fix(gallery): restore the download CTA under headerStyle "none"
The report asked whether this was intentional. It is collateral damage from
the #386 swap, not intent.

There are two header download affordances. GalleryView sets
showDownloadAll={false} unconditionally -- "replaced by the new
showHeaderDownload (#386)" -- and passes showHeaderDownload={allowDownloads}.
GalleryLayout renders HeaderDownloadButton in the standard, minimal and hero
branches, but the isNoHeader branch only ever had the now-dead showDownloadAll
button. Net effect: zero download CTA on headerStyle 'none'.

The comment claiming intent -- "Intentionally NOT shown in the no-header
variant where the gallery is fully chromeless by design" -- is factually wrong
about its own branch: isNoHeader renders the menu button, headerExtra (upload
button, countdown timer) and logout. It is a functional-controls bar, not
chromeless. The sentence predates the #386 swap, when showDownloadAll still
gave that bar a download button.

Renders HeaderDownloadButton in that branch in the same slot order as the
other three; it is icon-only below sm, so it fits the compact bar. Removed the
two now-false comments.

Beta themes are unaffected: gallery-premium and gallery-story return from an
earlier branch that never mounts GalleryLayout and get download-all via their
own onDownloadEverything prop, so there is no double CTA.

Refs testplan REPORT.md, headerStyle:none download-CTA warning.
2026-09-02 09:43:10 +02:00
Paul Nothaft 504a8b6fae fix(events): honour ?tab=, show a load error, and stop lying about uploads
Three warnings on the event-details surface.

?tab= deep links were ignored -- activeTab was hardcoded to 'overview' and
nothing read or wrote the search param, unlike Settings. Mirrors SettingsPage's
pattern exactly (module-level key list + type guard, seed useState from the
param, write-back and reflect-back effects), plus a snap-back for the `guests`
tab, which only renders when identity_mode is 'guest' -- a deep link to it on
any other event would otherwise show a tab bar with no content. The snap-back
is guarded on the query's isLoading so it cannot fire against undefined
settings and kill a legitimate deep link.

Worth recording: the two effects ping-pong infinitely if activeTab and a valid
URL tab disagree at mount, which is exactly the pre-fix state. The seeding is
what makes them agree, so the fix is also what makes the pair safe.

Offline Photos tab rendered the "no media uploaded yet" empty state on a
failed fetch, because `data: photos = []` makes a rejected query
indistinguishable from an empty one -- a user could reasonably think their
photos were gone. Threaded isError through and added a third branch, reusing
TaxReportPage's existing error-with-retry shape. Needed no new keys.

The spurious "Upload completed successfully" toast was in the host, not the
uploader: PhotosTab hung toast.success off PhotoUpload's onUploadComplete,
which is documented as a grid-refresh signal and fires as soon as the transfer
loop exits -- including when the request 400'd on the photo cap or every file
was rejected by magic-byte validation. PhotoUpload's own toasts were already
correct. Removed it, and added a real partial-success branch reporting the
actual split instead of a plain "Upload complete!".

The guest uploader had a variant of the same bug in a different place: its
toast is gated on successCount, but successCount++ fired on any resolved
request -- and the upload route answers 202 with count: 0 and an errors[]
entry when the file is refused. So a refused guest photo produced "Upload
completed successfully (1 photos)" and pushed a useless upload_id into the
processing poll. Now gated on count.

Refs testplan REPORT.md, ?tab= / offline-empty-state / spurious-toast warnings.
2026-09-02 09:43:10 +02:00
Paul Nothaft 15fdd70a08 fix(search): match the original filename, and honour the date-format setting
Two warnings, both of which turned out to be mis-stated.

Search: the name printed on every card is photos.original_filename (not
source_filename, which is the replacement-stable ingest key and is not in the
gallery payload at all), but search matched only the stored renamed filename.
So a substring the admin or guest can literally read on screen returned zero
results. Fixed on the admin Photos tab, which filters server-side -- grouped
OR, because the feedback AND/OR conditions are appended immediately below and
a bare orWhere would leak across them -- and on the Story theme's own scene
filter, which is a second independent client-side search box.

Dates: the warning read "Transfers uses DD/MM/YYYY while the rest of the app
uses long-form dot dates", but it is inverted. TransfersPage already routes
every date through useLocalizedDate and was correctly honouring the rig's own
configured general_date_format of {"format":"DD/MM/YYYY","locale":"en-GB"}.
The surfaces it was compared against are the ones ignoring the admin setting,
by passing an explicit format string that overrides it. Dropped the hardcoded
'MMM d, yyyy' from the two EventsListPage table dates so they follow the
setting like Transfers does.

AdminHeader's format(new Date(), 'PPPP') is left as-is: that is the decorative
"today" banner, where a long weekday form is a deliberate design choice rather
than a data date, and forcing it to DD/MM/YYYY would read worse.

Refs testplan REPORT.md, search-by-original-filename and transfers-date
warnings.
2026-09-02 09:43:10 +02:00
Paul Nothaft b0f33c1744 fix(security): actually apply the general API rate limiter
app.use('/api/', generalRateLimiter) lives inside initializeRateLimiters(),
which is defined at line 463 but not called until 1048 -- by which point the
routers (767+), the /api notFoundHandler (1002) and errorHandler (1029) are
already on the stack. All six app.use() calls in it therefore append BELOW the
error handler and can never see a request. generalRateLimiter had no other
registration path.

So the entire /api surface had no IP-based request limit, except the handful
of routes carrying their own inline rateLimit() (public quotes, contracts,
payment-check, transfers, the analytics proxy). The admin Settings
rate-limiting UI -- rate_limit_enabled, rate_limit_max_requests -- was writing
to a control that did nothing.

Fixed with a stable gate registered above the routers that resolves the
limiter per request, so there is no boot delay: it is a pass-through until
initializeRateLimiters() resolves, exactly matching prior behaviour.

Registered unmounted (app.use(gate), not app.use('/api', gate)) because
Express strips the mount path from req.url and rateLimitService's own logic is
written against the full path -- req.path.startsWith('/api/public/') and the
/api/(gallery|secure-images)/:slug regex it uses to find a gallery token to
skip on. Mounting it would have silently broken both.

Deliberately excluded, each for a concrete reason:
- /health and /api/health, mounted above the gate: a 2s probe is 450
  req/window and would 429 the container healthcheck.
- /api/public/transfer and transfer-upload: one request per file from a link
  holder with no JWT, so never skipped as authenticated; a large transfer
  would be cut off mid-way. Both already have tighter per-minute limiters.
- login and gallery-verify: the limiter returns authMaxRequests (5) as their
  budget but counts them into the SAME per-IP bucket as every other /api call,
  so the branding and settings fetches a login page makes before anyone types
  a password would 429 the login itself for a full window. Giving these a real
  per-IP limit means giving them their own bucket.

Bulk gallery and admin traffic is unaffected: skip_authenticated defaults true
and cookie tokens are promoted to Authorization before the gate runs, and
skipped requests do not increment the counter.

Also adds /api/health as an alias of /health -- one handler, identical
exposure -- which silences a ~2s probe warning. Registered above the API
middleware chain deliberately: left at its original position it would have
passed through apiRequestLogger and through maintenanceMiddleware, whose
skip-list contains /health but not /api/health, so it would have 503'd during
maintenance while /health returned 200.

The tests pin registration depth by source inspection as well as behaviour,
because depth is what was broken and no unit test of the gate can catch it.

Refs testplan REPORT.md, /api/health warning; rate-limiter gap found while
fixing it.
2026-09-02 09:43:10 +02:00
Paul Nothaft a89057df1d fix(search): stop escapeLikePattern corrupting bound search values
Verified against a real SQLite connection -- each of these returned zero rows
before and the right row after:

  "Sarah's"  before=[]  after=["Sarah's Birthday"]
  "100%"     before=[]  after=["Summer 100% Sale"]
  "Gala_"    before=[]  after=["Gala_Night"]

Two bugs in one helper. It did .replace(/'/g, "''"), which is SQL string-quote
doubling -- meaningless and actively corrupting for a value that is bound, so
any search containing an apostrophe matched nothing. And its \% escaping had
no ESCAPE clause on the LIKE, which is engine-dependent: honoured on Postgres,
a literal backslash on SQLite, so % and _ stayed wildcards there.

Now mirrors the correct implementation from 59666b59: escape \ % _ only, and
a new likeWithEscape(column) emits `col LIKE ? ESCAPE '\'`. Both call sites
move to whereRaw with the value still bound; the column argument is a literal,
documented in the JSDoc.

Callers checked before changing the contract: adminPhotos.js,
adminEvents/crud.js, and sqlSecurity's own addLikeCondition(), which has no
callers anywhere -- pre-existing dead export, updated to the new shape rather
than deleted.

Behavioural change: searches containing ' % _ or \ now return the right rows
instead of nothing. Case sensitivity is unchanged.

Refs testplan REPORT.md, escapeLikePattern finding.
2026-09-02 09:43:10 +02:00
Paul Nothaft 413290af3e i18n: fail safe on empty strings, normalise German to Sie
D2 -- returnEmptyString: false. i18next defaults it to true, so an empty
translation was returned as valid and rendered as blank UI instead of falling
back to English. Verified safe first: zero empty-string values across all 8
locales, no addResourceBundle or runtime resource injection, no public/locales
for the HTTP backend, and the three t(key, '') call sites resolve against key
families fully populated in en and de.

D3 -- German formality normalised to Sie throughout, 101 strings. There is no
deliberate du island: Sie outnumbered du roughly 6:1 (~390 vs 65 addressed
strings), every namespace with more than ten addressed strings was
Sie-dominant, and the guest gallery plus all public/billing surfaces were
already 100% Sie. Even customer.*, the reported offender, was internally mixed
rather than consistently du. Two detection passes: du-pronouns (now zero) and
du-imperatives without a pronoun (Klicke…, Aktiviere…, Wähle…). Placeholders
verified mechanically unchanged. Left alone: ten 1st-person progress labels
(Lade Benutzer…, Prüfe…, Teste Verbindung…) -- those are label style, not
address, and normalising two of ten would have made it worse.

C7 -- removeUnusedKeys stays false, but the comment now carries measured
evidence instead of an estimate. The honest attempt was made: 61 preserve
globs derived mechanically from all 82 dynamic key templates in src (far more
than the 5 families previously named) plus 17 constant-table prefixes cut
removals from 422 to 158. Two things still block it. 47 of the remainder are
the base form of a plural key that src does pass to t(); i18next tries the
_other suffix first so nothing visibly breaks, but covering them needs a
literal pattern per key and forgetting one silently deletes a live key --
exactly the failure the flag prevents. And pruning is not idempotent: run for
real, extract had to run three times before --ci --dry-run came back clean,
each pass uncovering another removal, so i18n:ci would fail on a correct tree
until someone ran extract enough times.

Also adds the three settings.analytics keys that 9251745a referenced in
AnalyticsTab without adding (proxiedNotice, proxiedNoticeText,
customOnlyCspWarningText) -- en from the source defaults, de translated.

Refs testplan REPORT.md C7, D2, D3.
2026-09-02 09:43:10 +02:00
Paul Nothaft 758dc005df fix(events): rename a shadowing local and bound the photo-cap input
Two small fixes in one file.

The local `mode` at line 323 collided with the info-banner `mode` the i18next
TS resolver reads at line 490, so the extractor emitted four keys the code can
never request (events.infoBanner.mode_managed / mode_reference and the
promoBanner pair) -- the real modes are inherit|custom|off. Renamed to
sourceMode; the four phantom keys are dropped from the locale files.

Also bounds the Photo Limit input, the twin of the one fixed in e5f6085a:
min={0} with no max makes input[type=number] report aria-valuemax="0", and an
out-of-range value only failed at INSERT. Set to the events.photo_cap column's
real signed-32-bit ceiling.

Refs testplan REPORT.md B15 and the aria-valuemax warning.
2026-09-02 09:43:10 +02:00
Paul Nothaft be39929476 fix(accounting): allow creating a customer from the picker
With Accounting on and CRM/customerPortal off, the picker renders but there
was still no way to create the first customer: /admin/clients/accounts and
every CRM editor with inline-create are feature-gated, and the picker's
empty-state hint pointed at that unreachable page.

Reuses the existing InlineCustomerCreate that CustomerPicker already mounts
for the CRM editors. The affordance is gated on customers.create, matching the
backend, where POST /admin/customers is permission-gated and not flag-gated.
mode is 'passive' when customerPortal is off -- a portal invitation would
email a link to a login that does not exist -- and 'both' when it is on. On
success the customer is appended to the selection, which is what the
accounting call sites' next.slice(-1) already expects.

The noResults hint pointing at the hidden page is replaced by two keys: one
naming the button, one for admins without the permission.

Refs testplan REPORT.md B12.
2026-09-02 09:43:10 +02:00
Paul Nothaft 34685505be fix(analytics): serve self-hosted trackers same-origin so CSP stops blocking
A self-hosted Umami/Rybbit domain configured in Settings could never load: the
CSP script-src allowlist is static, and the earlier pass could only add an
admin-visible warning because nginx.conf:58 strips helmet's header and
location / serves the SPA document off disk via try_files -- so helmet can
never govern it in Docker. Verified by reading the config, not inferred; that
kills the "make helmet dynamic" option outright.

Rather than templating the CSP, the tracker is now same-origin. The script and
every endpoint it talks to are served from /api/analytics/tracker/* and
proxied server-side to the configured instance, so script-src 'self' and
connect-src 'self' already cover it. The CSP is unchanged: nothing to
template, no env var, no restart -- it takes effect when Settings is saved.
That also closes A3 structurally rather than by widening a directive.

Endpoint mapping taken from vendor sources, not guessed: Umami's
host || currentScript.src + /api/send, and Rybbit's documented
/track, /site/tracking-config/<id>, /site/<id>/feature-flags/evaluate.
data-host-url is set explicitly so a COLLECT_API_HOST-built Umami cannot
bypass the proxy. Session replay is deliberately NOT proxied: replaying
gallery pages would capture the share token (GHSA-7m6c).

nginx still needed one line, for a non-obvious reason: the static-asset regex
location outranks the plain /api prefix in nginx's matching order, so
/api/analytics/tracker/script.js resolved as a static file. Confirmed
empirically against a real nginx:alpine -- 404 before the ^~ block, 502
(proxied) after, with /assets/app.js and /api/public/settings unchanged.
The native SERVE_FRONTEND install needed no change; helmet already has 'self'
in both directives and the proxy mounts ahead of express.static.

Security boundary, since this makes the server fetch an admin-supplied URL:
closed per-provider path+method allowlist (4 paths), DNS-resolving
isHostAllowed blocking private/internal/metadata addresses in production
(matching the s3Storage prod-only precedent), base rebuilt as
origin + pathname so userinfo/query/fragment cannot smuggle anything,
redirect: 'error', cookie/authorization/referer/host never forwarded, an
HTML upstream response re-served as application/octet-stream + nosniff, and
64KB request / 2MB response / 5s timeout / 120rpm caps. X-Forwarded-For and
User-Agent are forwarded so geo and device attribution survive.
Residual, stated plainly: an unauthenticated rate-limited relay to one
admin-chosen public host on 4 paths, and TOCTOU DNS rebinding is unmitigated
as it is elsewhere in the repo.

The Umami and Rybbit panels now explain they are proxied; the Custom panel
keeps a CSP warning -- it is the one mode with nothing to proxy -- naming both
script-src and connect-src.

Refs testplan REPORT.md A2, A3.
2026-09-02 09:43:10 +02:00
Paul Nothaft fe5ac9162d fix(photos): emit visibility and processing_status from the list mapper
The "hidden photo has no indicator on the admin grid" warning was not a
missing badge. The badge markup has existed since #172; the defect was in
GET /:eventId/photos, which hand-builds its response literal field by field
and never emitted `visibility` -- so the value was always undefined and
neither the grid tile nor the list row badge could render. Same omission class
as the view_count/download_count bug already commented in that file.

(The `visibility` line itself was swept into 4721bd83, whose message does not
mention it -- recording that here.)

Fixes the adjacent instance too: `processing_status` is missing from the same
mapper, so the grid's "Processing…" and "Failed"/Retry placeholders could
never render either.

On the card, reuses the existing EyeOff badge pattern from the list-view rows,
adds a tooltip on both layouts, and drops the category badge to top-9 so the
hidden badge can own the top-left corner.

Also fixes the Photo Limit spinbutton's aria-valuemax, which read 0 even with
a real cap set. Root cause: min={0} with no max -- for input[type=number]
Blink's MaxValueForRange returns DBL_MAX, fails isfinite and supplies no max,
so a11y tooling prints the default 0. Set to 2147483647, the events.photo_cap
column's real signed-32-bit ceiling (migration 074), which also stops an
out-of-range value failing only at INSERT. The sibling expires_in_days input
already had proper bounds.

Known: EventInformationCard carries the identical Photo Limit input with the
same defect; it is held by another concurrent change and follows next.

Refs testplan REPORT.md, hidden-photo and aria-valuemax warnings.
2026-09-02 09:43:10 +02:00
Paul Nothaft 3acb452090 fix(settings): remove the duplicated section heading on 11 tabs
A generic shell heading stacked on top of each tab component's own internal
heading. The report named five tabs "at least"; auditing all 28 found 11:
downloads, sso, apiTokens, webhooks, businessProfile, crm, accounting,
whatsapp, slideshow, moderation, styling. On the first eight the two headings
resolve to the identical string -- sso and businessProfile literally render
the same key twice. The other three were near-identical stacked titles
("Moderation"/"Word Filters", "Custom CSS"/"Custom CSS Templates",
"CRM behaviour"/"CRM settings").

Clean, and left alone: general, events, categories, thumbnails, security, seo,
imageSecurity, status, analytics (its first heading is a genuine sub-section),
plus the eight already in TABS_WITH_OWN_HEADER.

Removed the component side and kept the shell heading: the shell heading is
the consistent one (icon + label + divider on ~20 tabs) and always matches the
nav item the admin clicked, and none of these components are mounted outside
SettingsPage, so nothing loses a title. Subtitles and intro copy preserved
throughout; orphaned icon imports removed.

The guard test was checked against the pre-fix blobs and does fail on them.

Refs testplan REPORT.md, "duplicate H2 section heading" warning.
2026-09-02 09:43:10 +02:00
Paul Nothaft 22cada9082 fix(ui): drop themed text colours from the last three admin surfaces
SystemHealthPage, CrmOverviewSection and HoursSection used text-theme
(color: var(--color-text)) explicitly, so on a dark-toned branding theme they
render near-invisible on the light admin background -- and because the class
is explicit it beats the AdminLayout default that protects everything else.

Converted to the neutral scale using the convention from da9ceb14. Swept the
whole of each file rather than the cited lines: 3 in SystemHealthPage, 5 in
CrmOverviewSection (two of them h3 elements with no colour class at all), 17
in HoursSection. All three are admin-only -- call sites verified as
AdminDashboard, CustomerDetailPage, HoursLoggingPage and /admin/system-health
-- so no customer-portal or public-token surface is affected, where these
utilities are correct by design. Zero themed utilities remain in the three
files.

Refs testplan REPORT.md B14.
2026-09-02 09:43:10 +02:00
Paul Nothaft bd44708a03 fix(contracts): widen the block-library list column
Block names ellipsized to ~4-6 characters ("Vertr...", "Bildr..."). The
tooltip added earlier made them recoverable but the list still was not
scannable.

The file's "intentionally mirrors EmailConfigPage's Templates tab" comment was
the reason the ratio was left alone. Re-evaluated: both pages render in the
same Settings shell so the ratio is shared, but the content is not. A block
tile spends a fixed ~105px of its row on the "System" badge plus the n/6 pill,
and block names are long German noun phrases; EmailConfigPage's tiles carry
one badge and short display names ("Gallery Created"). So the shared ratio is
not simply wrong -- it is wrong here. Diverged only here: lg:grid-cols-3 /
col-span-2 becomes lg:grid-cols-5 with a 2/3 split (40/60). Mobile stack
untouched, and the comment now names the divergence and why.

Refs testplan REPORT.md A5.
2026-09-02 09:43:10 +02:00
Paul Nothaft a28f96b304 fix(gallery): no-store private JSON, and give guest uploads a real status
B6 -- seven gallery routes returned private, per-guest data with no
Cache-Control at all, relying on heuristic freshness. noStoreCache is mounted
per route rather than on the router, because the media routes set their own
private, max-age=1800/3600 and must keep it. Covered: /photos (own
likes/favourites/ratings, hidden photos for a client token), /people, /stats,
/verify-token/:token (an authorization decision -- a cached {valid:true}
outlives a rotated token), /show/:token/session (the response IS a credential;
it mints a gallery JWT), /show/:token/state and /download-jobs/:token (live
polls, where a cached "preparing" strands the caller). Deliberately untouched:
the photo/thumbnail/hero/preview and css-template routes, which set their own
caching, the binary downloads, and /info + /resolve, which are unauthenticated
public metadata rather than per-guest private.

ETag/304 revalidation is intact and pinned by a test: no-store stops the
browser retaining the body, not express agreeing an unchanged payload is
unchanged. That matters because the post-upload poll depends on it.

B7 -- the guest upload flow had no progress signal, so the UI polled the photo
list blind and gave up after 60s with no explanation. Adds
GET /:slug/uploads/status?ids=... rather than pending counts in the photos
payload: counts there are event-wide, so another guest's or the admin's stuck
upload would spin the notice forever and it could never say "your photo
failed".

Authorization: verifyGalleryAccess already resolves req.event from the
caller's token, and the query is scoped `.where('event_id', req.event.id)`, so
an id from another gallery matches no row -- neither a cross-event read nor an
existence oracle, since it returns all-zero counts rather than a 403/404 that
would confirm the id exists elsewhere. Slideshow tokens are denied (a kiosk
never uploads). Ids are pattern-validated, max 50. The response is counts
only: no filenames and specifically no processing_error strings, which can
carry internal paths. Not gated on allow_user_uploads, so an admin flipping
the toggle mid-flight does not strand an in-progress guest.

The frontend now finishes on the real terminal condition, refetches as each
photo lands rather than only at the end, shows a processing pill, and reports
real failures instead of silently timing out.

Refs testplan REPORT.md B6, B7.
2026-09-02 09:43:10 +02:00
Paul Nothaft 7c9baff751 fix(upload): scope category ids, stop temp-file leaks, split the video cap
Four related fixes on the admin upload/photo path.

B5 -- PATCH /photos/:photoId and POST /photos/bulk-update took any
parseInt(...) > 0 straight into the update with no existence or scope check,
so a photo could be moved into another event's category. The upload route
already validated `event_id = X OR is_global` per #500/#525; extracted that
query as findScopedCategory() and used it on all three routes so the 400 body
is byte-identical. 0/negative/'individual'/'collage'/null still clear without
a lookup, so the clear path costs no extra query.

B9 -- three distinct temp-file leaks, not one. The validator's size branch
never unlinked; the cleanup lived in the final handler, unreachable on any
400; and multer's `destination` callback runs per file and overwrote
req.tempUploadPath, so even the success path only ever removed the last
file's directory. Now: discardUploadedFiles() runs on every 4xx and the 500
(ENOENT tolerated, and files are only dropped when the whole request is being
rejected, so the passing path is untouched); cleanup registered before multer
so it also covers multer's own LIMIT_FILE_SIZE return; one directory per
request.

B8 -- the admin uploader filtered on MIME only, so an oversized file was
uploaded in full before the server's 400. Mirrors UserPhotoUpload's existing
per-file toast-and-drop.

C4 -- general_max_file_size_mb was a single cap for photos and videos, so the
50MB default meant admins could not upload ordinary video without also
raising the photo limit. Adds general_max_video_size_mb (default 500MB,
clamped by the same 10GB MAX_ALLOWED_FILE_SIZE_MB ceiling, read per request,
60s cache), editable in Settings -> General.

Photo uploads are protected from regressing by keeping multer's type-blind
limit at max(photoCap, videoCap) and moving the per-kind decision into
validateUploadContent, where file.mimetype exists. It 400s with the existing
message shape, so an oversized photo is still rejected with the identical
body it produced when multer did the rejecting.

Known gap: chunked-upload/init still applies the photo cap to video. Making
it video-aware would change an existing assertion that pins a 200MB video
init being rejected under a 1MB general cap. No component calls that path
today and the direction is strict rather than a bypass, so it is left as-is.
Guest video uploads still share the single cap in gallery.js.

Refs testplan REPORT.md B5, B8, B9, C4.
2026-09-02 09:43:10 +02:00
Paul Nothaft 57dd084763 fix(events): add archive_size to the immutable column deny-set
IMMUTABLE_EVENT_COLUMNS is documented as a COMPLETE deny-set that new
server-managed columns must be added to. archive_size is written by
archiveService from the zip's real byte count and is what the archives list
now sorts and displays, so an events.edit holder could otherwise set a
cosmetic size on a non-archived event.

Follow-up to 59666b59, which added the column.
2026-09-02 09:43:10 +02:00
Paul Nothaft 42ba8351c1 fix(middleware): log ownership lookup failures; drop dead auth surface
ownership.js caught a lookup failure, returned 500 and logged nothing -- the
file had no logger import, so a failing ownership check was invisible in the
logs. Added logging matching photoAuth.js/permissions.js
({ error, stack } plus the relevant id), response behaviour unchanged. Fixed
both swallowed catches: requireEventOwnership, the reported one, and the
byte-identical requireProjectOwnership.

Also removes AdminAuthContext.updatePasswordChanged, now dead -- superseded
by the deliberate full-page reload in onSuccess, with zero callers left.
setMustChangePassword and mustChangePassword stay; nothing else orphaned.

Refs testplan REPORT.md B13, B16.
2026-09-02 09:43:10 +02:00
Paul Nothaft 103863cbab fix(quotes): enforce the status state machine, and correct the table
VALID_QUOTE_TRANSITIONS was a complete-looking quote state machine that
nothing consulted, so status changes were unvalidated.

Mapping every writer of quotes.status (quoteService.js is the only one --
dealsService, projectService, adminDashboard and customer.js all read) showed
the table itself was wrong: six legitimate transitions were missing.
sendQuote allows draft/declined/expired -> sent but the table had draft only;
adminAcceptQuote allows draft/sent/expired but had sent only;
adminDeclineQuote allows draft/sent/expired but had draft/sent; recordResponse
had no same-status entry. Enforcing it as written would have broken
accept-on-behalf from a draft, resend-after-decline, every expired revival and
the 15-minute response-toggle window.

So the table is reconciled to reality first, then assertQuoteTransition()
(409, QUOTE_INVALID_TRANSITION) is called at all seven sites.

Two things worth carrying forward. Nothing in the codebase ever sets
'expired' -- the header comment says "set by the scheduler" and there is no
such scheduler; sent -> expired is retained as documented intent only. And
the backstop's added value is narrow: every reachable invalid transition is
already caught by a call site's own better-worded guard, which fires first.
What it newly catches is a status the machine has never heard of -- a legacy
or corrupt row like 'cancelled' sails through adminAcceptQuote's guard, which
only excludes accepted/declined/converted, and used to be silently
overwritten. That is what the new tests pin.

Refs testplan REPORT.md B4.
2026-09-02 09:43:10 +02:00
Paul Nothaft a7d45ddd0d fix(workflows): restore the once-per-process seed guard
`booted` was assigned but never read, so the guard's early return was missing
and the builtin workflow seeder ran on every call.

Impact was wasteful, not harmful: seedOneBuiltin is idempotent -- it keys on
builtin_key and returns early when adminOwned or storedVersion >= def.version,
writing a graph only on a fresh insert or a version bump. So repeat calls cost
a lookup per builtin plus a graph rebuild, with no duplicate rows.

`booted = true` stays inside the try, so a seed that never got off the ground
(workflows table not migrated, DB down) leaves the flag clear and retries. A
per-builtin failure is still swallowed by the inner catch and does not block
the flag, unchanged.

Restoring the guard broke workflowEngine.test.js, which calls the boot seeder
seven times in one worker and needs the second call to run in two of them.
Followed the existing _backupPathsBoot/_restoreSettingsBoot precedent:
exported _resetBootForTests().

Refs testplan REPORT.md B3.
2026-09-02 09:43:10 +02:00
Paul Nothaft 355fe4ff43 fix(email): give every template a real display name in the config UI
D4 audit found defaultTemplateKeys was worse than stale sample data:

- Only .name was ever read. The subject/body/variables triple on each entry
  was dead data -- and it is where {{password}} and {{expiration_date}}
  originated, neither of which exists in any shipped template (they are
  gallery_password and expiry_date).
- It covered 4 keys out of ~40. A fresh install already carries 17 templates,
  and ~40 with the CRM flags on. Every key not in the list rendered its raw
  snake_case template_key as its display name in both the sidebar and the
  read-only "Template name" field -- customer_gallery_assigned,
  database_backup_completed, invoice_collections_handoff, all five
  event_reminder_*, and so on.

Replaced with TEMPLATE_DISPLAY_NAMES covering every key from
migrations/core/*.js plus the crm/contract/eventReminder template services,
falling back to the raw key. Drops the now-orphaned password sample value.

Adjacent drift found, not fixed (different const, and fixing it would be
scope creep): eventReminderTemplates.js inserts with category 'crm' /
subcategory 'event_reminder', neither of which is in CATEGORY_ORDER or
CORE_SUBCATEGORY_ORDER, so all five reminder templates fall through the
unknown-category fallback into core -> "other". They are visible, just filed
in the wrong bucket.

Refs testplan REPORT.md D4.
2026-09-02 09:43:10 +02:00
Paul Nothaft 41e1de7818 fix(email): repair and seed the gallery lifecycle templates
Correction: the reported premise held for only one of the three templates,
verified by running the core migration set against an empty database.

- expiration_warning is German-is-English on every fresh install, exactly as
  reported. Repaired with migration 194's pattern verbatim.
- gallery_expired and archive_complete are NOT German-is-English -- they do
  not exist at all. Their master rows are inserted only by migrations/legacy/
  010+020, which never run on a fresh install, so 075/099/106/108 seeded zero
  translations for them (they key off a master row that is not there). A
  fresh install's email_templates holds 17 keys and neither is among them.
  The consequence is worse than a translation gap: expirationChecker's
  sendGalleryExpiredEmails and archiveService's completion mail both hit
  "Email template not found", retry three times and die silently in
  email_queue on every expiry and every archive.

So 195 also seeds those two (master row + en/de translations + category),
but only when the master row is absent -- it never overwrites. English
follows legacy 028, which emailProcessor's own comments call the shipped
copy; German follows legacy 026's wording. Both are restructured into the
plain unstyled shape the other core-seeded templates use, so wrapEmailHtml's
configurable palette governs styling rather than hard-coded hex. The
support-contact line is wrapped in {{#if support_email}} because
getSupportEmail() can return ''.

196 adds the {{#if welcome_message}} block that nl/pt/ru/fr/es/sl already
have in gallery_created but en and de lack, so the photographer's personal
note was silently dropped for those two locales even though the value is
passed at send time. safeTemplateReplace does resolve {{#if}} before variable
substitution, so this is a real conditional -- there is a test rendering the
migrated body both ways. HTML body only, matching the other locales:
emailProcessor rewrites welcome_message through formatWelcomeMessage
(escape + nl2br) once for both bodies, so the text part would print literal
<br /> and &amp;.

Both migrations keep 194's conservative condition -- rewrite only while the
German is still byte-identical to English or empty -- so admin-edited and
legacy-translated installs are untouched. Idempotent, guarded, no-op down().

Known gap, documented in 195's header: the two newly seeded templates get
en/de only. nl/pt/ru/fr/es/sl fall back to en via processTemplate's fallback
chain, which is strictly better than today's hard failure but is not real
localisation.

Refs testplan REPORT.md B1, B2.
2026-09-02 09:43:10 +02:00
Paul Nothaft afc5779ce7 fix(events): return 409 instead of 500 when a slug is taken
Correction to the reported cause: both create paths already loop
`while (await db('events').where({ slug }).first())` before inserting, so a
sequential duplicate never 500s -- it gets -1 appended. The 500 is purely the
read-then-insert race: two concurrent creates for the same name+date both
clear the check and the loser's INSERT trips events_slug_unique.

isDuplicateSlugError(), built on the existing utils/dbErrors.isUniqueViolation,
is wired into the catch of POST / and POST /:id/duplicate ->
409 { code: 'EVENT_SLUG_TAKEN' }. The predicate is deliberately narrower than
isUniqueViolation: on PG it matches err.constraint, on SQLite the specific
"UNIQUE constraint failed: ... events.slug" text. A loose message test would
misfire because knex prefixes the whole INSERT -- which always names slug --
to err.message, and events has other unique columns (share_token).

PUT /:id cannot collide: slug is in IMMUTABLE_EVENT_COLUMNS. No other
adminEvents sub-router writes slug. CreateEventPage already toasts data.error,
so no frontend change is needed.

The test makes the race deterministic without timers: it hooks knex's `query`
event and injects the colliding row the instant the route issues its
slug-existence SELECT. The route then spends a full bcrypt hash before its own
INSERT, so the injected row always lands first.

Refs testplan REPORT.md B10.
2026-09-02 09:43:10 +02:00
Paul Nothaft da6e34d6a3 fix(archives): sort and total on real archive sizes, escape LIKE wildcards
Closes the three trade-offs the server-side archives query deliberately
accepted.

C1 -- the sorted number and the displayed number are now the same one.
There was no archive_size column, so the Size column came from a per-row
fs.stat done after pagination while the sort fell back to summed photo bytes:
the list could be ordered by a number the user was not looking at. Adds
events.archive_size (bigInteger -- int4's 2.1GB ceiling is the same limit
that forced the restore path off adm-zip), written at archive time from
archive.pointer(), which is the exact byte count the completion email already
reports. The route now sorts and displays that column and no longer touches
the filesystem. The migration backfills by stat-ing every archive_path where
the column is null, outside the column guard so a half-finished run
self-heals; unstatable rows (missing zip, S3-backed storage) stay null, order
last via COALESCE and display 0 -- exactly what the old fs.stat produced for
a file it could not read. Restore nulls it alongside archive_path.
Accepted: the list no longer notices a zip deleted out of band and shows the
last recorded size. The detail route still stats the real file.

C2 -- escape \ % _ in the bound value plus an explicit ESCAPE '\'. The
ESCAPE clause is load-bearing rather than decorative: SQLite has no default
LIKE escape character, so without it the escaped pattern matches literal
backslashes and the search silently returns nothing on SQLite while working
on Postgres. The value stays bound; no interpolation.

C3 -- the four stat cards aggregated only the current page, so every total
was wrong for any dataset past page one. The list response now carries
totals { archives, photos, archiveSize } computed with the same applyFilters()
closure as pagination.total, so cards and footer cannot drift. Two aggregate
queries: archive_size sums on the unjoined events query (joining photos
multiplies it by photo count) and photos count on the joined one, both read
back through Number() for pg's bigint-as-string. The "Showing X of Y" line
moved out of the totalPages > 1 guard so it survives a single-page result,
now gated on total > 0 so a zero-result search does not render
"Showing 1 to 0 of 0"; only the page controls stay conditional.

Test fixtures deliberately order zip sizes differently from summed photo
bytes, so the sort test can only pass on the right column.

Refs testplan REPORT.md C1, C2, C3.
2026-09-02 09:43:10 +02:00
Paul Nothaft 4c2eeab2f4 refactor(gallery): drop the unreachable Story feedback sheet
StoryFeedbackSheet could never open: handleOpenFeedback was the only caller
of setSelectedPhotoForFeedback and was itself never called. This was the last
remaining build:check error (TS6133).

Removed rather than wired up, on three findings:

- The sheet offered nothing PhotoLightbox does not, and was strictly worse.
  It held comments and ratings in layout-local useState and never called
  feedbackService.getPhotoFeedback, so existing server-side feedback was
  invisible; it rendered stars and a comment form unconditionally, ignoring
  allow_ratings/allow_comments; and it had no reactions, colour labels,
  identity modal or rate-limit handling. This layout already renders
  PhotoLightbox with feedbackEnabled, which does all of that against the
  server.
- It was not a mobile affordance. The CSS styled it as a fixed right-edge
  desktop drawer (right: 0; max-width: 28rem) with no media query.
- Every sibling layout routes feedback through the lightbox. Grid, Masonry,
  Timeline, Mosaic and Carousel expose a per-card onQuickComment that calls
  onOpenPhotoWithFeedback to open the parent's lightbox on the feedback tab;
  none has a standalone feedback surface. The closest sibling,
  GalleryPremiumLayout, renders its own lightbox and deliberately voids
  _onOpenPhotoWithFeedback with no per-card control -- exactly the shape
  Story now has.

Drops the component, its state and handlers, the feedbackOptions destructure
(only the sheet read it) and 251 lines of orphaned CSS. savedIdentity also
fed guest_name/guest_email into the like call; those were always undefined at
runtime since the unreachable sheet was their only writer, so no behaviour
changes.

Also widens the Story nav search input, which clipped its placeholder. At the
input's computed 14px the placeholder measures en 121px, de 145, ru 152,
fr 174 against a 128px box -- so German was 17px over and French 46px over.
8rem -> 13rem collapsed, 12rem -> 17rem focused, keeping expand-on-focus;
verified at 1280px and at the 768px breakpoint where the search appears.

Refs testplan REPORT.md A1 and the gallery-story placeholder warning.
2026-09-02 09:43:10 +02:00
Paul Nothaft 0ae424ff42 test(migrations): pin migration 194's per-field guard
The per-field fix landed without a test for the case it exists for: an
admin-translated subject over a still-English body, and the reverse.
2026-09-02 09:43:02 +02:00
Paul Nothaft 72894e22c2 fix(gallery): stop browser zoom tripping the devtools viewport heuristic
innerHeight is in page CSS pixels and shrinks under browser zoom;
outerHeight does not. At 150-200% zoom a normal window therefore shows an
absolute outer/inner gap of 400-500px, past every threshold, so an
accessibility zoom read as a docked DevTools panel and - at protectionLevel
"maximum" - redirected the guest off the gallery on load. Pre-existing
(the previous threshold was 100px), but the rewrite kept the shape.

The gap is now measured relative to a baseline taken at mount, and the
baseline is re-taken whenever devicePixelRatio changes, which a zoom step
does and a docked panel does not. Only a gap that grows past the threshold
at a constant ratio counts. The mount-time check is dropped: a panel that
is already open at load is indistinguishable from a zoomed window.
2026-09-02 09:33:34 +02:00
Paul Nothaft 77b11ab874 fix(upload): enforce the chunked-upload cap on bytes received, not declared
The init route checked the client-declared fileSize against
general_max_file_size_mb, but nothing checked what then came through the
chunk route: a client could declare `fileSize: 1` and stream any amount,
and completeUpload only logged the size mismatch before handing the merged
file on. The cap the earlier commit added at init was therefore a gate with
no fence.

The service now carries the cap from init and enforces it on the running
byte total per chunk (aborting the upload once crossed, since the chunks on
disk are already over the limit), rejects chunk indices outside the
announced range, and re-checks the merged file as a backstop. Both routes
answer 413/400 for these instead of a blanket 500.
2026-09-02 09:33:34 +02:00
Paul Nothaft 814f205da0 fix(feedback): make the "block" severity tier actually reject
The block level is advertised as "comment is rejected immediately", but every
non-approved comment was saved with is_approved = false instead of the
submission being refused.

moderateText now sets an explicit `blocked: true` on the blocking-violation
branch -- branching on the reason string in the route would have been fragile
-- and the route 400s with code COMMENT_BLOCKED and stores nothing. Everything
else that is not approved (moderate/high, the spam and caps checks, and the
"Moderation system error" fallback) deliberately omits the flag and keeps the
held-for-moderation path, so a moderation failure still fails safe.

Also fixes an adjacent defect that made the tier split unobservable:
feedbackService.submitFeedback ignored feedbackData.is_approved entirely and
hard-derived is_approved from moderate_comments. So a moderate/high
word-filter hit on an event with moderation switched OFF was published
immediately -- the route's `feedbackData.is_approved = false` was dead code.
Now honoured one-directionally: a caller-supplied false is respected, but
nothing a caller passes can RELAX the event's setting. That deliberately
leaves the route's reputation.autoApprove -> is_approved = true branch inert
rather than letting a trusted guest bypass an event's moderation setting.

Refs testplan REPORT.md B11.

(cherry picked from commit b1b57b1615aaf02fe76e789a86b7e11933288d77)
2026-09-02 09:30:49 +02:00
Paul Nothaft da8fcc82ef fix: per-field template guard, LIKE escaping, wait for all uploads
Codex review round 1 on #1266.

Migration 194 gated all three German fields on body_html alone, so an admin
who had translated only the subject would lose it the moment the HTML still
matched English -- and down() is a deliberate no-op, making that loss
unrecoverable. Each field is now judged independently, for both the
translations table and the legacy _de columns.

Archives search escapes LIKE wildcards. % and _ are literal characters to the
client-side includes() this replaced but wildcards to LIKE, so searching
"100%" matched every archive and reported a nonsense total. The ESCAPE clause
is load-bearing: SQLite has no default LIKE escape character, so without it
the escaped pattern matches literal backslashes there while working on PG.

The post-upload poll waits for every queued file. Each is processed
independently, so stopping at the first new photo left the rest of a
multi-file upload hidden until a manual refresh -- the exact symptom the
polling was added to prevent. UserPhotoUpload now reports how many files the
server accepted.

(The latter two are superseded by stronger fixes in #1267 -- the upload-status
endpoint and the shared escape helper -- but each PR has to be correct on its
own.)
2026-09-02 08:41:36 +02:00
Paul Nothaft 6e5755de02 fix(types): resolve the TypeScript build:check backlog
74 errors -> 1. No suppressions: zero `any`, `as unknown as`, `@ts-ignore` or
non-null `!` added, and tsconfig is untouched. Each error was triaged as
"the type is wrong" vs "the code is wrong" and fixed on that side.

Live bugs the checker was pointing at:

- admin.service.ts TS1117 duplicate key: admin_password_reset was defined
  twice and the later one won at runtime. Removed it so the earlier entry
  wins, which matches the actual emitter in userManagementService.js and
  carries the email fallback.
- PhotoGridWithLayouts dropped allowReactions from its prop type, so the
  Premium layout's reactions never activated even though GalleryView passes
  it and GalleryPremiumLayout reads it.
- SlideshowPage's poll never copied `order` into next/prev, so live
  play-order changes never reached a running kiosk.
- CustomerLayout compared branding_force_color_mode against 'auto', which is
  never persisted (only 'dark'|'light'|null), so the customer portal always
  picked the light logo even in OS dark mode.
- EmailConfigPage rendered lang.flag, but SUPPORTED_LANGUAGES exposes Flag, a
  component -- so nothing rendered. And editing a language with no translation
  yet spread undefined, storing a partial object missing required fields.
- publicQuotes.js projected only 6 line-item fields, omitting
  parentLineItemId/parentPosition/detailsText, so the migration-119 sub-item
  hierarchy and details text could never render on the customer-facing quote
  page -- the frontend code for it was unreachable. It reads from the same
  quoteService.getQuoteById the admin route uses, where those fields are
  present; adminQuotes.js projects all three. Fixed the projection rather
  than adding fields to the frontend type, which would have compiled while
  leaving the feature broken.
- DuplicateEventDialog's helper text was silently dropped: LocalizedDateInput
  had no helperText prop. Added, mirroring Input.tsx incl. aria-describedby.
- ThemeEditorModal/EventThemeSection still passed isPreviewMode, a prop
  822be9a9 deliberately removed but missed at these two call sites.
- GalleryPage's hero-photo injection was dead: /gallery/:slug/info does not
  return hero_photo_id (only /photos does) and GalleryView already does it
  correctly. Removed the dead block rather than adding a field the API
  never sends.

Stale types corrected against the backend route that produces each payload:
GalleryInfo (allow_downloads, allow_user_uploads), GalleryData.event
(download_zip_ready), UpdateEventData (client_access_enabled, client_password,
regenerate_client_token), InvoiceSummary (replacesInvoiceId), ExportOptions
(mark_source, plus a snake_case ExportFilter matching the actual wire format),
customer.service contracts, AdminUser timestamps widened to string|null,
formatMoney currency widened to match its own (currency || 'CHF') guard,
faceCropStyle dimensions widened to match its !photoWidth guard, DEFAULT_FLAGS
faces, logo_position 'sidepanel', and the hand-rolled t() props replaced with
i18next's TFunction in four files.

Unused symbols were checked before deletion; UpdateInstructionsDialog's
targetVersion prop was completed rather than deleted (declared and passed but
never rendered -- now the fallback before the query resolves).

Left unfixed, deliberately: GalleryStoryLayout's handleOpenFeedback (TS6133).
It is the only caller of setSelectedPhotoForFeedback and is itself never
called, so StoryFeedbackSheet can never open on the Story theme. Wiring it
needs a new affordance on StoryPhotoCard (no sibling layout exposes one to
copy) and deleting it would orphan the sheet -- a product decision, not a
type fix. Note PhotoLightbox on the same layout already handles feedback,
so the sheet may simply be superseded.

Refs testplan REPORT.md #22 (Part 1.3.04).
2026-09-01 17:09:24 +02:00
Paul Nothaft 5dbb43549c fix(i18n): make i18n:ci pass by fixing the extractor config
exit 1 -> exit 0.

Three findings, none of which matched the reported symptoms.

1. The two "unparseable .d.ts files" are not malformed. RestoreWizard.d.ts and
   BackupHistory.d.ts are valid declaration files sitting next to their .jsx
   implementations; i18next-cli feeds them to SWC as ordinary .ts modules with
   no ambient flag, where an uninitialised `const` is a hard syntax error. They
   should never have been scanned at all. Root cause is the input glob:
   i18next-cli passes `input` straight to `glob`, which does NOT honour
   `!`-prefixed negation inside the pattern list, so
   '!src/**/*.{test,spec,d}.{ts,tsx}' was a silent no-op and all four .d.ts
   files plus 57 test files were being scanned. Moved the exclusions to
   extract.ignore, where they take effect; the extracted key set is unchanged.

2. The "missing French keys" were not English-vs-French drift. The extractor
   wanted to add ~2771 keys to fr.json with value "" -- and src/i18n/config.ts
   does not set returnEmptyString, whose i18next default is true, so those
   empty strings would be returned as valid translations and render as blank
   UI rather than falling back to English. Filling nl/pt/ru/fr with ~11000
   empty strings would have been a worse regression than the failing check.
   The check was demanding parity for locales this project deliberately keeps
   partial, so `locales` is now ['en','de'] -- the two actually kept at parity.
   nl/pt/ru/fr join sl/es as hand-maintained partial locales on
   fallbackLng 'en'. No French was written.

3. de.json is a parity locale, and the extractor legitimately found 307 keys
   missing from both en and de (shipped t() calls never added to the locale
   files). Rather than accept 307 blank German strings these were written by
   hand: 105 are _one/_other variants derived from existing German bases with
   correct singular/plural, the rest translated against each section's register
   (Sie on admin/public-billing surfaces, du in the customer portal to match
   customer.quotes/customer.bills) reusing terms already established in de.json.
   Verified: 0 interpolation-placeholder mismatches between en and de across
   all 308 new keys, 0 empty and 0 key-shaped values remaining, and the diff is
   strictly additive (en +308, de +307, 0 removed, 0 changed).

removeUnusedKeys is now false, replacing the dead preservePatterns: []. It
wanted to delete ~355 live keys per locale across ~90 prefixes -- families
built at runtime (admin.activities.*, admin.notificationMessages.*,
projects.status.*) or held in constant tables the extractor cannot resolve
(AdminSidebar nameKey, CrmDevelopmentPage titleKey/descKey). Covering them
would need ~30 wildcards spanning most of the key space; disabling pruning is
the same behaviour, honestly stated, with the call sites named.

Refs testplan REPORT.md #22 (Part 1.3.03).
2026-09-01 17:07:07 +02:00
Paul Nothaft d16137bb2a fix(contracts): add tooltips to the ellipsized block-library names
The Blocks list column ellipsizes names to ~4-6 characters ("Vertr...",
"Bildr...") with no title attribute, so the list is unscannable without
clicking into each block. Add title on the name and description, plus min-w-0
so the name shrinks instead of pushing the badges out.

Did not widen the column: the file carries an explicit design-intent comment
that its two-column grid "intentionally mirrors EmailConfigPage's Templates
tab", and changing the span would break that deliberate parity. The tooltip
resolves the reported unscannability on its own.

Refs testplan REPORT.md #21 (Part 8, S13).
2026-09-01 16:48:24 +02:00
Paul Nothaft 78b1ddd0db fix(admin): interpolate activity and notification message values
Users were shown raw "{{quoteNumber}}", "{{name}}" and "{{count}}" tokens.
Two distinct render-side causes; nothing is persisted as a rendered string
(messages are stored as type + metadata JSON and formatted client-side), so
no backend change was needed.

{{quoteNumber}} / {{name}} -- AdminDashboard's getActivityMessage built a
hardcoded five-value allowlist (eventName, email, count, template,
categoryName) and passed it to t('admin.activities.<type>'). The backend does
record quoteNumber (quoteService.js) and name (adminWebhooks.js); the values
just never reached i18next, so every activity string interpolating anything
outside that allowlist rendered its literal token. Spread activity.metadata
first, keeping the five derived entries as overrides since they resolve from
columns that are not in metadata. Extracted as buildActivityParams for
testability, mirroring the formatDayHeader extraction.

{{count}} -- different cause, the notification-bell path: archiveBulk.js logs
successfulCount, but the locale string expects count and
bulk_archive_completed had no explicit case, so the default branch spread a
metadata object without one. Added a case next to the existing
bulk_delete_completed, following that idiom.

Also fixes bulk_delete_completed, which has the identical mismatch: it reads
metadata.deleted || metadata.count while archiveBulk.js writes successfulCount,
so that notification always rendered "0 events deleted". It degrades to a wrong
number rather than a visible placeholder, which is why it was not among the
three reported instances -- but it is the same one-token bug.

Refs testplan REPORT.md #15b.
2026-09-01 16:48:24 +02:00
Paul Nothaft d8bd0cd449 i18n: close the admin translation coverage gaps
Recurring pattern of components and strings shipped without translation
coverage, found across unrelated feature areas. +212 keys each to en.json and
de.json, provably additive (flattened-key diff: removed=0, changed=0;
formatting round-trips byte-identically).

Genuinely un-wired components (grep -c useTranslation == 0), now wired:
BulkArchiveModal (8 strings, count-pluralised), WebhookDeliveriesPage (27),
CMSEditor's TipTap toolbar/link dialog/status bar/help modal (64).
Hardcoded strings fixed in code: ImageSecurityTab's 4 spinbutton hints,
ProjectsListPage's unlocalized status enum.
Keys-only (component already calls t() correctly): General "Time format",
Branding Social Media + Promotional Banner, Quotes detail/editor, cms.showInFooter.

Two corrections to the report's attribution:
- BlockLibraryPage was NOT un-wired -- it calls t() on every string with
  English defaults; all 32 contracts.blocks.* keys were simply absent from
  both locale files, so everything fell back to the JSX default. Same for
  ContractsListPage, where the report cited 3 missing keys and there are
  actually 9 (all 5 table column headers plus the pagination line).
- CustomerDetailPage has full t() coverage; its single English "Contracts"
  was a missing customer.nav.contracts key behind a dynamic labelKey.

Locale convention followed: i18next.config.ts manages en/de/nl/pt/ru/fr, but
only en and de are kept at parity (5198/5200 keys); the rest are ~50% partial
and rely on fallbackLng 'en'. Added to en + de only rather than inventing
212x6 unreviewable translations.

Also added the 25 missing businessProfile.* keys (PDF-letterhead section,
bank-accounts QR disclaimer). That component already calls t(), so those
strings localize as soon as the keys exist; no wiring needed.

Refs testplan REPORT.md #15a.
2026-09-01 16:48:24 +02:00
Paul Nothaft 9143997f8e style(backend): clear the eslint backlog to zero
929 problems (928 errors, 1 warning) -> 0, exit 0.

Rule breakdown, which corrects the report's premise -- `indent` dominated, not
`quotes`: indent 719, quotes 68, no-unused-vars 54, no-empty 36,
no-useless-escape 22, no-case-declarations 17, no-inner-declarations 6,
no-control-regex 5, no-useless-catch 1, no-console 1 (warn).

--fix handled only indent + quotes (719+68 = exactly the "fixable" count).
no-useless-escape was NOT auto-fixable in this eslint version, so the one
genuinely risky class never went through the autofixer -- all 22 were done by
hand. Two mechanical proofs on the autofix diff: a token-level AST diff
(espree, before vs after) shows exactly 68 differing tokens, all quotes, with
the 719 indent fixes producing zero token changes; and a cooked-value diff of
every string/template/regex literal shows 0 differences.

Regex escapes: eslint was correctly conservative and did not flag the
load-bearing ones -- \- in [^a-zA-Z0-9_\-\.] (unescaping makes an invalid
reversed _ -> . range) or in [!@#$%^&*()_+\-=...] (would become a + -> = range
silently matching ",-."). Every removal was a \/ \[ or \. inside a character
class; all 11 old/new pairs were brute-forced over 794 inputs with 0
mismatches.

Manual fixes: no-empty were all deliberate best-effort catches around activity
logging, annotated rather than restructured; no-case-declarations braced in
two adminBackup switches; no-inner-declarations converted to const arrows
after checking no call precedes the declaration and no this/arguments use;
no-control-regex and no-console got targeted disables with stated reasons;
one `catch (e) { throw e; }` wrapper removed.

Two unused bindings were near-misses worth noting: secureStatic.js's
`fullPath` is a path-traversal guard (safePathJoin throws on escape) and
restoreService.js's `backupManifest` is the throw-on-corrupt-manifest gate
before a rollback -- deleting either would have silently removed a check. Only
the bindings were dropped; the calls stay.

Two real bugs found and deliberately preserved with a comment plus a narrow
disable rather than deleted, since deleting would erase the evidence:
_workflowSeedBoot.js's `booted` is written but never read, so the intended
once-per-process guard is missing its early return and workflows re-seed on
every call; and quoteService.js's VALID_QUOTE_TRANSITIONS is a full state
machine nothing consults, so quote status changes are unvalidated.

Backend test suite: 253 suites / 2552 tests passing, 0 failures, before and
after.

Refs testplan REPORT.md #22 (Part 1.2.02).
2026-09-01 16:46:34 +02:00
Paul Nothaft da9ceb14ca fix(ui): stop branding-theme text colour rendering headings invisible
Components that render headings with no explicit text-colour class inherit
`body { color: var(--color-text) }`, and the branding theme sets --color-text
on <html> app-wide -- so on a dark-toned theme they render near-invisible
(#f5f5f5 on #fff), including inside the admin panel in light mode.
Compliance-adjacent: /impressum and /datenschutz are two of the surfaces.

Convention copied from AccountingTab, the QA control that is visually
identical but not affected: h2 -> text-neutral-900 dark:text-neutral-100,
labels -> neutral-700/300, checkbox labels -> neutral-800/200, hints ->
neutral-500/400.

Fixed beyond the reported lines, after sweeping each file:
- LegalPage: the CMS prose wrapper and the single-segment 404 heading.
- CMSContentBlock: the multi-segment CMS 404 and the admin unknown-route 404
  turn out to be the same component (App.tsx path="*"; there is no admin-level
  catch-all). Its text already used var(--color-text); the actual defect was
  .card hardcoding bg-white under themed text, so the surface was fixed, not
  the text.
- SettingsBusinessProfilePage (11), CrmSettingsPage (15, incl. both shared
  checkbox-label helpers covering ~20 rendered rows), ReminderTemplatesPage (7,
  incl. text-theme/text-muted-theme on an admin page where they are wrong).
- The <select> elements on those tabs: Tailwind preflight sets color:inherit
  on form controls, so they picked up the near-white body colour on a white
  background. Same root cause, not previously reported.

Plus one line of defence-in-depth on the admin shell (AdminLayout): an
explicit text colour there stops the whole admin panel inheriting the themed
body colour. Components with their own class, including text-theme, still win.

Interpretation -- the robust fix was evaluated and rejected. Scoping the theme
tokens to gallery contexts is not feasible: the leak is deliberate product
behaviour (GlobalThemeProvider applies branding on every non-gallery page),
40 files read var(--color-*) with only 9 under components/gallery, and it
would break the customer portal, the public token pages, AdminLoginPage and
the Branding live preview. It also cannot be done at container level without
moving `body { color: ... }` and the whole .text-theme/.bg-surface/.card-themed
utility family, which are global by construction.

Known remaining instances, not converted: SystemHealthPage, CrmOverviewSection
and HoursSection use text-theme explicitly on admin surfaces, so they keep the
themed colour and stay affected. Outside the reported surfaces.

Refs testplan REPORT.md #14 (Part 8, S3/S4/S13).
2026-09-01 16:41:13 +02:00
Paul Nothaft ac50f0b48b fix(admin): portal the update-available modal to document.body
AdminSidebar's root div carries a Tailwind `transform` utility for the mobile
slide-in, and per the CSS spec a transformed ancestor becomes the containing
block for position:fixed descendants. The modal renders inline inside
VersionInfo/AdminSidebar, so its `fixed inset-0` backdrop was trapped in the
256px sidebar column (measured 256 vs window 1440) -- copy buttons overlapping
text, content truncating.

Reuse the codebase's one existing portal convention, from
gallery/FeedbackLimitReachedModal: assign the JSX to a const and return
createPortal(node, document.body).

Checked for other modals with the same trap; there are none.
UpdateInstructionsDialog is also fixed inset-0 but is mounted from
AdminDashboard inside <main>, and CustomerLayout has an identical transformed
aside with no modal inside it.

Refs testplan REPORT.md #13 (Part 3, B.07).
2026-09-01 16:41:13 +02:00
Paul Nothaft 1be27404fa fix(email): derive preview sample data from each template's variables
The preview modal's hardcoded sampleData had drifted from the templates'
declared variables arrays: it carried `password` and `expiration_date` and no
`host_name` at all, so {{host_name}}, {{gallery_password}} and {{expiry_date}}
rendered as literal placeholders in the gallery_created preview while
event_name/event_date/gallery_link substituted fine.

Derive the key set from the template's own `variables` instead, so nothing can
be missing again. editedTemplate already carries the array at the call site,
so no plumbing was needed. A small module-level lookup keeps sensible shapes
for the ~11 variables where shape matters (dates look like dates, links like
URLs), with a readable [name] fallback for anything uncurated -- curating all
~60 distinct variable names across the ~32 template seeds would just recreate
the drift trap.

Preview-only; real sent mail was never affected.

Refs testplan REPORT.md #17 (Part 3, J.04).
2026-09-01 16:40:02 +02:00
Paul Nothaft 73b08a7b5c fix(email): give gallery_created a real German translation
translations.de for gallery_created was the English copy word for word, while
nl/pt/ru/fr/es/sl are all localized. This is the mail sent on every gallery
creation, so German-default installs have been silently mailing English.

Root cause chain, fresh installs only: 001_init seeds the English template;
059 introduces the multilingual columns and fills subject_de/body_html_de/
body_text_de from their _en counterparts (its own comment: "Copy to German as
default"); 075 then materialises exactly those columns as the `de` row. The
real German only ever existed in migrations/legacy/026, and run-migrations.js
runs core/ only for fresh installs -- so every install created since 059 has
the English-as-German row.

A code-only fix would have changed nothing: knex will not re-run 059/075, so
existing installs would keep the bad row forever. Fixed as a content migration
following the repo's precedent for template repairs (094, 172).

Conservative about what it touches: the German row is rewritten only while it
is still byte-identical to English (or empty) -- precisely the broken state --
so a legacy install whose German came from 026, or any admin-edited template,
is left alone. Also repairs the legacy _de columns, which are still
emailProcessor's fallback path. Idempotent, hasTable-guarded, no-op down()
(reverting would restore English-as-German).

Placeholder parity with the English original is exact and test-asserted:
host_name, event_name, event_date, gallery_link, gallery_password, expiry_date.

Two related gaps found but deliberately not fixed, both outside the reported
bug: expiration_warning, gallery_expired and archive_complete are German-is-
English on fresh installs through the identical 059 mechanism (legacy 026
fixed all four). And nl/pt/ru/fr/es/sl additionally wrap a
{{#if welcome_message}} block that the English original lacks, even though
welcome_message is passed at send time -- so EN and now DE drop the
photographer's personal note. That is an English-side gap needing its own
decision.

Refs testplan REPORT.md #16 (Part 3, J.04).
2026-09-01 16:40:02 +02:00
Paul Nothaft 76a1453fa7 fix(calendar): don't put a fixed reference date in the month header
dayHeaderContent assumed arg.date is always the real column date. It is in
the time-grid views, but FullCalendar v6 fills it from an internal reference
week (1970-01-04..10) for dayGridMonth headers, so the month header read a
fixed "Mo 05.01. ... So 04.01." regardless of the visible month. Body dates
were correct; only the header row was wrong.

Interpretation (flagged as ambiguous): a month-view column header labels seven
generic weekday columns shared by every week in the grid -- it has no single
date, so forcing one in is wrong by construction rather than just mis-computed.
Month view now renders the localized weekday alone, which is also FC's own
default there; timeGridWeek keeps weekday + DD.MM. since each column really is
one date.

Branches on view.type === 'dayGridMonth' exactly, not a dayGrid prefix:
dayGridWeek/dayGridDay do have real per-column dates and a prefix match would
break them if either is ever added.

Extracted to an exported formatDayHeader so it is testable without mounting
the page; the test mounts a real FullCalendar in both views, so an upgrade
that changes the arg.date contract fails rather than silently regresses.
FullCalendar dependency untouched.

Refs testplan REPORT.md #10 (Part 8, S9).
2026-09-01 16:31:09 +02:00
Paul Nothaft fc7cb226f4 fix(archives): run search, filter and sort server-side
ArchivesPage fetched one 20-row page and then filtered and sorted only that
array in memory, while "Showing X of 802" / "Page 1 of 41" kept reporting the
full unfiltered count. Searching for an archive that exists but is not on the
current page returned a false "0 results" with no hint the search was
page-scoped.

The backend did not support the params (it read only page/limit and hardcoded
orderBy archived_at desc), so all three are new. Follows adminEvents/crud.js
for the shape and customerAccountsService for the case-insensitive predicate:
whereRaw with a bound parameter, never interpolated, and sortBy whitelisted to
date/name/size before it reaches orderBy. The same applyFilters() closure runs
against both the count query and the row query, so the total cannot drift from
the rows again.

Frontend mirrors EventsListPage: 300ms debounce, reset to page 1 on any query
change, placeholderData so keystrokes don't flash the spinner.

Two interpretation calls:
- sortBy=size orders by summed photo bytes, not the zip's on-disk size. The
  Size column comes from a per-row fs.stat done after pagination and there is
  no archive_size column, so a global sort on the real zip size would stat all
  802 files per request. Ordering is near-identical except for rows whose zip
  is missing. Adding events.archive_size would be a migration, out of scope.
- No LIKE-metacharacter escaping. escapeLikePattern() does .replace(/'/g,"''"),
  which corrupts a bound value ("Sarah's Birthday"), and its backslash escaping
  is a no-op on SQLite without an ESCAPE clause. Matched customerAccountsService
  instead. A literal % typed by an admin acts as a wildcard in a read-only
  search; no injection risk.

Pre-existing and untouched: the four stat cards still aggregate the current
page only.

Refs testplan REPORT.md #9 (Part 3, I.01).
2026-09-01 16:31:09 +02:00
Paul Nothaft 3f6c81a846 fix(photos): treat category_id 0 as uncategorized instead of storing it
Genuine product bug, found behind the adminPhotos.reference suite (which was
failing for an unrelated reason -- see below).

parseInt('0') is 0 and !isNaN(0) is true, so a '0' category_id was written
literally. photo_categories.id is an increments() column, so 0 can never be a
real category, and every read path already assumes it cannot happen: the list
mapper does `category_id || type` (0 is falsy, renders as uncategorized) and
the list filter explicitly skips '0'. The result was a filter black hole -- the
photo matches no numeric category filter, and misses the "uncategorized"
filter too because that is whereNull(). Displayed as uncategorized, reachable
by nothing.

null rather than a 400: unparseable input ('abc' -> NaN) already falls through
to null, so 400ing on '0' while silently accepting 'abc' would be incoherent,
and '0' is just the HTML <select> shape where the "none" option carries
value="0".

Fixed at all three call sites that share the branch -- PATCH /photos/:photoId,
POST /photos/bulk-update, and the upload route, where the dangling 0 was
written at creation time and the scope-validation guard
(`if (parsedCategoryId && ...)`) skipped on the falsy 0 and let it in
unvalidated. Only the PATCH one was behind the failing test; leaving the other
two would have left the bad state creatable.

The suite's 3 failures were all masked by a fixture gap, not this bug: it
stubs middleware/auth but not middleware/permissions, so requirePermission's
admin_users JOIN roles query hit tables the fixture never creates and every
request 500'd before reaching a handler. Stub it, bring the photos fixture up
to the 7 migrations it had drifted behind, and correct a stale 200 that became
202 when uploads went async in 851744c3.

Known adjacent gap, not fixed (wider than this bug): PATCH and bulk-update
accept any positive category_id with no existence or scope check, unlike the
upload route which validates event_id = X OR is_global per #500/#525 -- so a
photo can be PATCHed into another event's category.

Refs testplan REPORT.md #22 (Part 1.2.01).
2026-09-01 16:30:46 +02:00
Paul Nothaft 18715b5efd fix(gallery): show a guest's own upload without a hard reload
Correction to the QA root cause: the 304 is correct server behaviour, not a
stale cache. The guest upload route answers 202 and queues the file, so the
row lands as processing_status 'pending', and the photos list returns only
completed rows. The immediate post-upload refetch therefore produces a
byte-identical payload, express's body-derived weak ETag matches, and the
browser is answered 304. Cache-busting would not have fixed it -- a busted
request 200ms after the upload returns a 200 whose body still lacks the
photo. The hard reload only worked because it happened seconds later.

Poll instead: refetch immediately and every 2s until the photo count exceeds
the pre-upload baseline, with a 60s deadline and cleanup on unmount. This
also replaces two window.location.reload() callbacks, which could not have
waited for the worker anyway and threw away scroll and folder state.

Not done (out of scope, recommended follow-ups): GET /api/gallery/:slug/photos
sets no cache headers at all for private per-guest data and relies on
heuristic freshness -- noStoreCache.js already exists and would fit. And the
guest upload flow has no progress signal, so the UI polls blind where a
processing-status endpoint (or pending counts in the photos payload) would let
it say "processing...".

Refs testplan REPORT.md #12 (Part 4, P4-E.01).
2026-09-01 16:30:06 +02:00
Paul Nothaft 9d4bd7ab30 fix(gallery): stop devtools protection from breaking the whole page
With enable_devtools_protection on, every click on the gallery failed and
trivial script evaluation hung -- confirmed on two independent events. A
guest with DevTools open for an unrelated reason (network tab, a CDP-attaching
extension) got a silently unresponsive gallery with no error shown.

Mechanisms found, all in the hook (both callsites were innocent):

1. detectByDebugger ran a bare `debugger;` on every tick at medium/high
   sensitivity -- and the per-event flag maps to medium. With any debugger or
   CDP client attached the renderer paused there continuously. This is why
   Runtime.evaluate hung on 1+1 and clicks reported their target gone.
2. Four separate detectors called console.clear() -- the observed clear loop.
3. handleDevToolsDetected was useCallback([options]) over a fresh object
   literal, so runDetection changed identity every render and the effect tore
   down, rebound and re-ran detection on every render -- a 1s interval turned
   into a tight loop.
4. detectByConsole monkey-patched console.log/error/warn/info every tick
   inside a try/catch that swallowed throws, so a throw between patch and
   restore left the guest's console permanently hijacked.
5. contextmenu was preventDefault'd document-wide regardless of target,
   killing the menu on text, links and form fields -- disable_right_click is
   the separate setting meant to cover the whole page.

Kept: the DevTools shortcut keys (only those exact combos; everything else
passes through), the docked-DevTools viewport heuristic as a pure measurement
on resize plus one check at mount, right-click blocked on IMG/CANVAS/VIDEO
targets only. The public API (onDevToolsDetected, redirectOnDetection,
redirectUrl, isDetected, reset) is unchanged, so PhotoLightbox needed no edit.

Removed: debugger traps, console.clear, console monkey-patching, the
timing/element/toString probes, the polling interval, document-wide
contextmenu blocking. Undocked DevTools is now deliberately undetectable --
every technique that catches it costs the page its responsiveness for
everyone. This is a deterrent, not a security boundary.

Also raised the viewport threshold (100 -> 160/200/260 by sensitivity):
browser chrome with a bookmarks bar is ~140px, so the old check false-positived
on ordinary windows, which at protectionLevel 'maximum' redirected legitimate
guests off the gallery.

Refs testplan REPORT.md #3 (Part 4).
2026-09-01 16:30:06 +02:00
Paul Nothaft c6cb01865e fix(settings): derive the sidebar preview from the real sidebar declaration
SidebarPreview kept its own hand-maintained 6-item array with only two gates
wired (analytics, userManagement), so toggling e.g. Workflows changed nothing
in the preview even though it does add a real sidebar entry once saved.

Export AdminSidebar's `navigation` as `adminNavigation` (2 lines) and derive
the preview from it, so every gate -- transfers, messaging, analytics,
userManagement, clients incl. its featureFlagsAny set, accounting, workflows --
is covered and the two can't drift again.

Note the report's item list was partly wrong: Quotes, Contracts, Invoices,
Hours, Projects, Calendar and the CRM dev tools have no top-level sidebar
entries at all -- they are sub-nav inside /admin/clients and surface in the
preview through the CRM entry's featureFlagsAny.

Permission filtering is deliberately not applied (unchanged): the preview
answers "what do these flags do to the sidebar", not "what can this admin see".

Refs testplan REPORT.md #20 (Part 3, J.14).
2026-09-01 16:29:33 +02:00
Paul Nothaft 3e16b81be8 fix(settings): clear the accounting flag when its parent is turned off
Turning Invoices off left the Accounting master flag -- and its sidebar
entry -- silently on and freshly unlocked, because the bills=true =>
accounting=true force-enable had no reverse.

A dependency model already exists and handles every true parent->child pair
(quotes->bills, calendar->calendarBooking, accounting->{taxReport,
incomingInvoices,expenses}), mirrored client-side in applyDependencyRules and
server-side in adminFeatureFlags.js. The gap is only this asymmetric rule.

Interpretation, two decisions:

- Cascade on the client at toggle time, not on the server at persist time.
  applyDependencyRules is a pure invariant over a single state (the GET
  handler runs it too), so it structurally cannot distinguish "accounting is
  on because the admin wants it" from "...because bills forced it". The
  Features tab PUTs the full flag set, so on the wire an explicit true and a
  stale forced true are byte-identical -- a server-side transition rule would
  silently discard an admin who turns Invoices off and deliberately keeps
  Accounting on in the same save. The client is where the gesture is known.
  The persisted result is still server-enforced: the client sends
  accounting:false and the existing server invariant forces the sub-flags off.

- Re-enabling the parent does NOT restore children. Flags are state, not
  history, and silently re-lighting a sub-feature with its routes and sidebar
  entries is the exact failure this bug is about.

Refs testplan REPORT.md #8 (Part 8, S9).
2026-09-01 16:29:33 +02:00
Paul Nothaft 3790156fc9 fix(accounting): let "bill to a customer" work with the portal off
CustomerAccountPicker returns null when customerPortal is off. That is right
for its original use -- the event form assigns portal logins that bypass the
gallery password -- but the Accounting flows reuse it as-is, so their required
"Client" field rendered a bare label with no input and the submit button could
never enable, with no explanation. Accounting-on + CRM-off is a valid,
UI-supported flag combination.

Took option (a): the bill-to-customer path does not depend on the portal.
POST /admin/expenses/:id/invoice is gated by requireExpenses + accounting.manage
only, and /admin/customers{,/search} are permission-gated rather than
flag-gated -- POST /admin/customers exists precisely to create passive,
portal-less customers "to attach a quote / invoice / gallery to". The
un-gated CustomerPicker used by the quote/bill/contract editors is the
precedent. (The comment claiming search 410s with the flag off was stale.)

Add portalAssignment (default true) so the gate and the portal-specific
label/help text apply only in event-assignment mode; the accounting call
sites render their own label. Event-form behaviour is unchanged.

Also fixes AccountingInboxPage's TriageModal, which has the identical
label-only failure on the rebill disposition from the same root cause --
outside the reported surface, but leaving it would half-fix the bug.

Refs testplan REPORT.md #7 (Part 8, S10).
2026-09-01 16:29:33 +02:00
Paul Nothaft 1d84c738d8 test: repair four stale backend suites
All four asserted contracts the product has since moved past. No genuine
product bugs behind any of them; assertions were tightened, not loosened.

adminAuth (3 tests): never mounted errorHandler, so ConflictError/
ValidationError arrived as empty Express defaults. The route also checks
username before email, so the "email conflict" fixture was hitting the
username branch. Mount the handler, fix the fixture, match the real response
shapes.

backupService.enhanced (12 tests): three stacked drifts -- the db mock had no
.returning(), so every runBackup threw at the insert; ensureDatabaseDumpForBackup
now lazily requires ./databaseBackup inside the run, which fails under
mock-fs; and the rsync path moved from exec(shell string) to
spawnAsync('rsync', args) with an isHostAllowed SSRF preflight. Also updates
getBackupStatus to its current shape (frontend aliases, nextScheduledRun null
when no schedule is enabled, #871).

adminSettings.logo: POST /logo gained requirePermission('settings.edit');
the hand-rolled db mock returns a bare Promise from select(), so the
permission lookup threw a TypeError into a 500. Mock the permissions
middleware alongside the already-mocked auth.

crmMintPaths (2 tests): macOS-only. The expected prefix was realpath'd while
the services persist under the raw STORAGE_PATH -- identical on Linux CI
(/var vs /private/var only diverges on macOS), which is why it passed there.
The comment justifying the realpath referenced process.cwd() behaviour the
services no longer have.

Refs testplan REPORT.md #22 (Part 1.2.01).
2026-09-01 16:29:03 +02:00
Paul Nothaft c5c5a6b0c8 fix(webhooks): write delivery timestamps as ISO strings
Applies the repo's documented Jest+SQLite guidance (CLAUDE.md) to the webhook
delivery path, which was the last one still passing raw Date objects into
knex writes. Under jest those store as the literal string "[object Object]",
so next_retry_at came back NaN and the retry/backoff test could not assert on
it. Production (PG, and SQLite outside jest) was unaffected.

Convert the timestamp writes -- and the `next_retry_at <=` due comparison,
which has to stay type-consistent with them -- to .toISOString(), matching
the existing precedent in downloadJobService.js.

Refs testplan REPORT.md #22 (Part 1.2.01).
2026-09-01 16:29:03 +02:00
Paul Nothaft 31ffbc8ae4 fix(users): give the cancel-invitation dialog a distinct confirm label
The cancelInvitation dialog type fell through to the generic
t('userManagement.cancel'), colliding with ConfirmDialog's own dismiss
button -- two buttons both reading "Cancel", where clicking the wrong one
does the opposite of what the user intends.

Reuse the existing userManagement.cancelInvitation key: "Cancel Invitation"
vs "Cancel" (EN), "Einladung abbrechen" vs "Abbrechen" (DE). No new key.

Refs testplan REPORT.md #19 (Part 3, I.04).
2026-09-01 16:28:31 +02:00
Paul Nothaft c19e944b99 fix(events): guard create-event submit against re-entrant submissions
Correction to the QA root cause: the submit Button has carried
`disabled={createMutation.isPending}` since 3424bd22, and it does disable
synchronously after the first click (validateForm's setErrors forces a
re-render that re-reads the mutation snapshot), so an ordinary double-click
could not by itself produce two POSTs.

What was actually missing is a re-entrancy guard in handleSubmit, so any
submission that never touches the button -- implicit form submission, a
programmatic requestSubmit, or two submit events dispatched in one task,
which is the likely shape of the QA repro -- still fired two mutate() calls
racing the same computed slug, one of which 500'd on events_slug_unique.

Add isSubmittingRef (matching the isMountedRef idiom already in this file),
cleared in onSettled. Test proves 2 submit events -> 1 POST.

Not done: turning the backend's raw 500 on events_slug_unique into a
graceful "event already exists" 409. That is an adminEvents.js change and a
separate concern from the client-side race.

Refs testplan REPORT.md #6 (Part 7.03).
2026-09-01 16:28:31 +02:00
Paul Nothaft 673f05556d fix(settings): don't crash on a fresh load before permissions resolve
On a hard navigation or deep link, usePermissions() starts out empty, which
filters every settings nav group down to nothing. allItems is then [], so
`allItems.find(...) ?? allItems[0]` yields undefined and `<activeItem.icon>`
threw -- sometimes into the error boundary, sometimes racing past it.
Reproduced 6+ times across the webhooks/moderation/slideshow/security/events
tabs; in-app SPA navigation never hit it.

Extend the file's existing early-return to `isLoading || permissionsLoading`.
activeTab lives in useState seeded from ?tab= at mount, independent of the
gate, so deep links still land on the right tab once permissions arrive.

Also null-guard activeItem before the section heading: a role holding zero
settings-tab permissions crashes identically even after permissions finish
loading, which the loading gate alone does not cover.

Refs testplan REPORT.md #11 (Part 3, J.08).
2026-09-01 16:28:21 +02:00
Paul Nothaft c2428aa23a fix(events): render a not-found state instead of hanging on a 404
EventDetailsPage gated on `if (eventLoading || !event)`. The backend returns
a clean 404 for a nonexistent id, but once isLoading settled false `event`
stayed undefined forever, so /admin/events/999999 sat on the loading spinner
permanently with no error state.

Destructure isError and split the gate: spinner while loading, then a
not-found Card. Reuses the existing `events.notFound` key (already used by
EventFeedbackPage for the same entity) and the Card padding="lg" not-found
shape from contracts/ContractDetailPage. No new i18n keys.

Refs testplan REPORT.md #5 (Part 7.02).
2026-09-01 16:28:21 +02:00
Paul Nothaft 3489610cb8 fix(analytics): warn about the CSP allowlist on every tracker provider
A self-hosted Umami/Rybbit domain configured in Settings -> Analytics is
always blocked by the static script-src allowlist, silently, with only a
console error. The amber CSP warning that explains this already existed but
was rendered only inside the "custom" provider panel -- not on the two
providers where an admin actually types a self-hosted URL.

Extract it to a local CspWarning and render it in the Umami and Rybbit panels
too. Both translation keys already exist in en.json/de.json.

Interpretation: the dynamic-CSP option was investigated and rejected as not
reachable for the header that actually governs these documents. In the Docker
deployment nginx.conf:58 does `proxy_hide_header Content-Security-Policy`, so
helmet's CSP and the res.setHeader CSP at server.js:445 are stripped before
they leave the stack -- nginx's static server-level CSP is the only one the
browser sees for the SPA documents the tracker is injected into. nginx.conf is
COPYied verbatim by the Dockerfile (only index.html goes through envsubst),
and the tracker URL lives in the DB rather than the environment, so making it
reflect the setting would need start-time templating plus a DB read. The CSP
itself therefore still has to be edited by hand; the warning now says so where
the admin can see it.

Refs testplan REPORT.md #18 (Part 3, B.02).
2026-09-01 16:23:40 +02:00
Paul Nothaft 5fa04e647e fix(categories): validate category name length instead of 500ing
photo_categories.name is varchar(100). Neither the input nor the route
checked length, so a 267-char name hit a raw Postgres "value too long",
came back as a 500, and the form silently stayed open with no toast.

Add isLength({ max: 100 }) to POST / and PUT /:id (the update route had the
identical gap) so it returns the route family's normal 400 { errors: [...] }
shape that the toast helper already renders, and maxLength={100} on the three
category-name inputs (create + inline edit in CategoryManager, create in
EventCategoryManager).

Refs testplan REPORT.md #4 (Part 7.01).
2026-09-01 16:23:28 +02:00
Paul Nothaft 6f7aa59fad fix(feedback): align word-filter severity vocabulary with the admin UI
WordFilterManager.tsx sends low/moderate/high/block; the validator only
accepted mild/moderate/severe, so 3 of the 4 UI levels 400'd with "Invalid
severity level" -- including "block", the strongest advertised tier.

Aligning isIn() alone would have made "block" accepted but semantically
inert: feedbackModeration.js branches on 'severe'/'moderate', so "block"
would fall through to the flag-only branch and behave as the weakest level.
Map the UI vocabulary onto the existing outcomes instead, per the legend the
UI itself renders: block -> reject, moderate/high -> needs approval,
low -> flag only.

'severe' stays an accepted alias in the blocking predicate so any row written
through the old validator (the field is optional, so a direct API caller
could have stored one) keeps blocking. No data migration needed: the column
is a bare varchar(20) default 'moderate' with no CHECK, no enum and no seed
rows, and 'mild' already lands in the flag-only branch that 'low' now means.

Refs testplan REPORT.md #2 (Part 3, J.11).
2026-09-01 16:23:22 +02:00
Paul Nothaft e18ab0d842 fix(upload): enforce the configured per-file size limit on admin uploads
getMaxFileSizeBytes() (general_max_file_size_mb, default 50MB) was only read
by adminSettings.js to display the value. The admin upload routes streamed
against a hardcoded ceiling instead, so the dropzone's "max. 50MB pro Datei"
was never enforced:

- adminPhotos.js POST /:eventId/upload    -> 10GB hardcoded
- adminPhotos.js POST /:eventId/chunked-upload/init -> 10GB hardcoded
- v1/events.js   POST /events/:id/photos  -> 100MB hardcoded

Resolve the cap per request (it is admin-configurable at runtime) and build
the multer instance from it, mirroring what gallery.js and adminTransfers.js
already do. The 400 names the configured limit and reuses gallery.js's exact
error string so the frontend surfaces it identically. getMaxFileSizeBytes()
clamps to MAX_ALLOWED_FILE_SIZE_MB, so the 10GB hard ceiling still bounds
everything.

gallery.js (guest upload) already enforced this correctly and is unchanged --
the report's claim that it did not is stale.

Interpretation: general_max_file_size_mb is a single per-file cap with no
photo/video split, and gallery.js already applies it blanket to guest video
uploads, so admin video uploads now share it too. On a default install that
means a 200MB video needs the setting raised first -- which is what the UI
has been advertising all along.

Refs testplan REPORT.md #1 (Part 7.06).
2026-09-01 16:23:04 +02:00
Paul Nothaft 07f2c90055 fix(security): mask backup credentials on read + unblock MFA login during maintenance
Two pre-existing bugs surfaced while reviewing #806 (kept separate per
scope policy — no OIDC code here):

- backup_s3_secret_key and backup_rsync_ssh_key (an SSH PRIVATE KEY)
  were returned in PLAINTEXT by GET /admin/backup/config and by the
  generic settings reads (GET /admin/settings and /admin/settings/:type
  — which mask the recaptcha/umami/rybbit keys but not these). All
  three now mask with the established bullet sentinel, and
  PUT /admin/backup/config skips the sentinel on write so the edit form
  round-trips without clobbering stored credentials (same pattern as
  the email/WhatsApp config endpoints)
- /api/auth/admin/login/mfa was missing from the maintenance-mode
  allowlist: the first login step passed, the second factor got a 503 —
  any MFA-enrolled admin was locked out exactly while maintenance mode
  was on

Regression tests: masking on all three read paths, sentinel round-trip
preserves stored values, real rotation still writes.
2026-07-16 10:13:34 +02:00
Paul Nothaft e91c7deaa4 fix(oidc): local-credential lockout, session hydration, split-origin gaps (codex round 3)
- OIDC-owned accounts can never authenticate locally: the password
  login rejects auth_provider='oidc' rows outright (generic 401), and
  the super-admin password reset refuses them with a clear message —
  previously a reset would have minted a local password bypassing the
  IdP's MFA/access policies
- /auth/session now returns a full adminUser payload (role join) and
  AdminAuthContext hydrates user state from it: an SSO redirect
  establishes the session without any login JSON, which left the header
  identity blank and current-admin form defaults empty
- the /sso/login error path redirects absolute to the frontend base
  (same split-origin reasoning as the callback)
- docker-compose.yml passes API_URL through to the backend (production
  compose uses env_file and needs nothing; dev compose is gitignored)
- authSession.symmetry test mock taught the joined admin lookup
  (leftJoin, prefixed columns, aliases) — the route change made the old
  mock throw, which read as "table missing, trust token"

Tests: new case pins that a known-good password on an OIDC-owned row
still gets 401. 14/14 OIDC, 13/13 symmetry.
2026-07-16 10:07:59 +02:00
Paul Nothaft 7f7d38a57f fix(oidc): security + robustness hardening from codex review rounds 1-2
Round 1:
- bind SSO identities to (external_issuer, external_subject): OIDC only
  guarantees sub uniqueness within an issuer, so a sub-only lookup let a
  newly configured IdP's user inherit an old IdP's admin account on
  subject collision; migration 162 gains external_issuer + composite
  unique index (unmerged migration, edited in place)
- fetch UserInfo (with sub cross-check) when the ID token carries no
  email — spec-compliant providers may serve email/profile claims only
  there; ID-token claims win on merge
- allowlist /admin/sso/login + /callback in maintenance mode, or
  SSO-only (JIT) admins are locked out exactly when they need in
- strip reserved keys (oidc_client_secret, setup_token) from BOTH
  generic settings reads (GET / and GET /:type)

Round 2:
- redirect_uri prefers API_URL (the API's public origin — where the
  state cookie lives); final redirects absolute to the frontend base;
  login button builds its URL via buildResourceUrl — split-origin
  deployments (absolute VITE_API_URL) work end to end
- PUT /sso validates the MERGED resulting state (partial update cannot
  blank issuer/client while enabled=true survives; enabling requires a
  derivable redirect URI)
- openid scope forced into oidc_scopes on save
- discovery-cache key includes a secret fingerprint (multi-worker
  secret rotation)
- email→admin linking claims the row atomically (conditional update on
  external_subject IS NULL) — concurrent first-time callbacks with the
  same verified email but different subjects can't both authenticate

Tests: mock IdP gains userinfo endpoint + email-via-userinfo-only mode;
new cases pin the userinfo merge and issuer-collision non-inheritance;
redirect assertions updated for absolute URLs. 13/13.
2026-07-16 09:41:48 +02:00
Paul Nothaft ac1838fbd7 fix(oidc): fail clearly when no public base URL is configured
CI exposed that getFrontendBaseUrl() returns '' without FRONTEND_URL or
the general_site_url setting (local runs were masked by backend/.env):
the flow then sent a RELATIVE redirect_uri to the IdP, which surfaced
as an opaque IdP-side error. getRedirectUri now throws OIDC_BAD_CONFIG
with an actionable message (login route maps it to sso_error=config);
the settings GET degrades to an empty redirect_uri instead of 500ing.
The test pins FRONTEND_URL explicitly so it runs identically with and
without a local .env.
2026-07-16 08:59:48 +02:00
Paul Nothaft ed5fc5ad5c feat(auth): OIDC SSO for admin users — phase 1 (#798)
Authorization-code + PKCE against a single configurable IdP via
openid-client v5, with JIT provisioning. Verified end-to-end against a
real Keycloak 26 (realm + confidential client + verified-email user):
settings → discovery test → login button → Keycloak → dashboard.

Backend:
- migration 162: admin_users.auth_provider ('local' default) +
  external_subject, composite unique index
- oidcService: settings-driven config (client secret AES-256-GCM at
  rest, mfaService pattern, OIDC_ENCRYPTION_KEY fallback JWT_SECRET),
  cached discovery, sub-based identity binding — email linking of
  existing admins only with email_verified=true; JIT behind
  oidc_autoprovision with configurable default role and an unusable
  random password hash
- GET /api/auth/admin/sso/login + /callback: state/nonce/PKCE verifier
  cross the redirect in a 10-min signed httpOnly SameSite=Lax cookie;
  the callback reuses the local login's session establishment
  (completeAdminLogin split into establishAdminSession + JSON wrapper)
  so SSO sessions are identical downstream; every failure lands on
  /admin/login?sso_error=<key> as a translated toast
- dedicated /admin/settings/sso GET/PUT/test endpoints (secret
  write-only, redacted to a set-flag; registered ABOVE the generic
  /:type matcher which would shadow them); oidc_client_secret added to
  the reserved keys stripped from generic settings upserts
- public settings expose only oidc_enabled + oidc_button_label for the
  login page

Frontend:
- Settings → Single Sign-On (OIDC) tab: issuer/client/secret, scopes,
  autoprovision + default role, button label, enable toggle, redirect
  URI copy box, server-side discovery test
- login page: SSO button (custom label) when enabled; sso_error query
  param surfaced as translated toasts; EN+DE i18n

Tests: 11 integration cases against an in-process mock IdP (real
discovery/JWKS/PKCE/ID-token validation) — JIT on/off, sub-vs-email
binding, unverified-email rejection, deactivated admin, missing/forged
state cookie, nonce tamper, secret encryption round-trip, disabled 404.

MFA is delegated to the IdP on the SSO path; local login stays
available as break-glass. Role-claim mapping and logout-to-IdP follow
in phase 2/3.
2026-07-16 08:54:26 +02:00
Paul Nothaft b9283386a5 fix(gallery): show feedback filter chips on desktop for galleries without categories (#802)
The desktop feedback-filter chips (All/Likes/Saved/Rated/Commented)
were nested inside the categories row conditional, and the standalone
fallback block is lg:hidden — so a gallery without photo categories
(the default) rendered no feedback filter at all on desktop, despite
the docs and a fully working filter implementation behind it.

Render the row whenever either part has content and gate only the
category scroller on categories existing. The media-count label hides
below lg when no categories exist so the mobile layout stays unchanged
(mobile keeps its own chip block). With-categories galleries render
identically to before.

Regression test pins both chip groups in the DOM with and without
categories (fails on the pre-fix component).
2026-07-15 22:51:22 +02:00
Paul Nothaft 5da1c3a12f fix(event-types): un-hardcode event type dependencies in v1 API and CRM (#800)
Split out of #801 so the public-API behavior change gets its own review:

- v1 POST /events validates event_type against the live event_types
  catalog instead of the hardcoded whitelist — custom types created in
  Settings → Event Types were rejected with 400. BREAKING for the
  never-seeded 'family' slug, which the old whitelist silently accepted
  and wrote as a dangling reference; create a matching event type to
  keep using it
- new GET /api/v1/event-types (read scope) so API-token clients can
  discover valid slugs; OpenAPI enum replaced accordingly
- standalone contract→event conversion no longer hardcodes
  event_type: 'wedding' — it resolves via crm_default_event_type, then
  the catalog catch-all, same chain as quote→event conversion
- resolveDefaultEventType moved from quoteService to eventTypeService
  for shared use (no behavior change)
2026-07-15 22:31:28 +02:00
Paul Nothaft 93301002ba refactor: move v1 API + CRM event-type un-hardcoding to a follow-up PR
Keeps #801 scoped to the setup-wizard event-types feature and its
load-bearing guards. The v1 validator/discovery endpoint and the
contract-conversion default fix ship separately so the public-API
behavior change gets its own review weight.
2026-07-15 22:30:00 +02:00
Paul Nothaft f8ba669716 fix(event-types): harden setup window + catalog validation (codex review)
Three review rounds on PR #801; fixes in response:

- isValidEventType: live catalog is authoritative when it has rows — a
  deleted or deactivated slug no longer validates via the legacy
  fallback (fallback now only serves an empty-catalog install)
- deleteEventType: refuse deleting the last (and last ACTIVE) type;
  updateEventType: refuse deactivating the last active type (unknown
  slugs are rejected since the validator change, so an empty active
  catalog would brick event creation)
- setup window fails closed: only an explicit stored `false` opens it
  (a portable-backup restore can leave the key absent) and a normal
  admin login durably closes it (abandoned-wizard case)
- reserved bootstrap keys (setup_wizard_completed, setup_token) are
  stripped from ALL generic settings upserts (/general, /security,
  /analytics, /seo) so the marker is genuinely one-way
- wizard step: deletes ordered so the catalog can never end up empty,
  and a genuinely failed system-type deletion reloads the list and
  stays on the step instead of advancing past the only window in which
  it can be retried
- CreateEventPage: snap the hardcoded initial 'wedding' selection to
  the first active type when the catalog no longer contains it
- v1 API: new GET /event-types (read scope) so token clients can
  discover valid slugs; OpenAPI enum replaced with the live-catalog
  description
2026-07-15 22:21:20 +02:00
Paul Nothaft 00fff24a1c test(v1): stub eventTypeService in events.create suite + cover unknown-type 400
The catalog-backed event_type validator (#800) makes a db('event_types')
lookup before the handler runs, which consumed the first queued mock
chain and shifted the pinned db() call sequence — 5 tests failed on CI.
Stub isValidEventType to true (validation isn't this suite's subject)
and add an explicit test for the new 400-on-unknown-type path.
2026-07-15 21:36:50 +02:00
Paul Nothaft 7eb6357b4a feat(setup): event-types step in first-run wizard + un-hardcode event type deps (#800)
Fresh installs can now shape the event-type catalog during the setup
wizard — rename, delete or replace the seeded defaults while nothing
references them. On existing installs system types stay protected.

- New wizard step between features and config: edit name/URL prefix,
  remove, or add types; defaults shown as recommendations
- setup_wizard_completed app setting (migration 161): seeded true when
  an admin already exists, false on fresh installs; POST /api/setup/
  complete (adminAuth) flips it when the wizard finishes
- deleteEventType: system types deletable only while the flag is unset;
  in-use check extended to quotes; per-type reminder template
  (event_reminder_<slug>) is deleted with the type
- reminder-template self-heal no longer resurrects templates for slugs
  removed from the catalog
- v1 API event creation validates event_type against the live catalog
  instead of a hardcoded whitelist (custom types were rejected; the
  never-seeded 'family' slug is no longer silently accepted)
- contract→event conversion resolves the event type via
  crm_default_event_type / resolveDefaultEventType instead of
  hardcoding 'wedding' (resolveDefaultEventType moved from quoteService
  to eventTypeService for reuse)
2026-07-15 21:30:23 +02:00
Paul Nothaft 39db7bf6cb fix(ci): publish v-prefixed image tags via type=ref,event=tag (#668)
#783 added `type=semver,pattern=v{{version}}` to the merge-job metadata,
but metadata-action silently dropped it on prereleases — the 3.84.0-beta.0
build published only :3.84.0-beta.0 + :sha, not :v3.84.0-beta.0 (verified
in the merge-backend push log + GHCR: :v3.84.0-beta.0 → 404).

Replace the v{{version}}/v{{major}} semver patterns with type=ref,event=tag,
which emits the git-tag name verbatim (v3.45.0 / v3.84.0-beta.0) for both
stable and beta tags — exactly the string users pin (matches the GitHub
release). Applies to both backend + frontend merge metadata steps.

Takes effect on the next release build. The bare :3.84.0-beta.0 tags stay
(the {{version}} patterns are unchanged), so both forms resolve.
2026-07-10 14:13:17 +02:00
Paul Nothaft b768a53c5b feat(slideshow): per-event play order + category filter (#202)
The Live Slideshow already covers the core of #202 (fullscreen kiosk,
live-appending new uploads, timing/transitions/watermark, per-event
opt-in via the share link). This adds the two customization dimensions
the reporter also asked for:

- **Play order** (show_order): 'chronological' (upload order, default) or
  'random' — the client shuffles the initial set (Fisher-Yates) so
  live-appended uploads keep working.
- **Category filter** (show_category_id): restrict the slideshow to a
  single photo category (NULL = all photos, default). Enforced
  server-side on the slideshow /photos access and mirrored in the
  /session + /state photo_count, so the kiosk viewer can't widen the set.

Per-event enable/disable (default off) is unchanged — it's the existing
'Generate/Disable slideshow link' flow (no token = no slideshow).

- Migration 158: show_order (default 'chronological') + show_category_id.
- Admin: Play-order dropdown + category picker in the Live Slideshow card
  (picker hidden for events without categories); EN + DE i18n.
- Verified: migration (SQLite + PG); live API (category filter → 3/2/5
  photos + matching count; order propagates) and the running kiosk
  requests exactly the filtered set; tsc clean, 136 backend tests pass.
2026-07-10 10:46:54 +02:00
Paul Nothaft 5dea0c9695 docs(releasing): align stable version to main on promote (Option A)
The two release-please tracks count independently — main bumps on every
merge, stable only on promotion — so they drifted far apart (main
v3.83.x-beta while stable sat at v3.45.0 for the same code). Document
the alignment convention: a promotion pins the stable version to main's
base version via a Release-As commit (new step 5 in the cut procedure),
so stable tracks main instead of lagging.

Also records the release-engineering note that release-please.yml must
keep target-branch: stable (the missing pin cut a bogus v2.7.0 once).
2026-07-10 10:03:30 +02:00
Paul Nothaft d3d7df46f2 feat(admin): GitHub repo button in the sidebar footer (#778)
Adds a subtle 'View PicPeak on GitHub' link in the admin sidebar footer
(next to the version/storage widgets), so admins can reach the repo —
star it, browse source, report an issue — from anywhere in the dashboard,
not just the setup screen.

- Centralizes the repo URL as `repoUrl` in utils/githubReleaseUrl.ts
  (githubReleaseUrl now derives from it) so the org URL lives in one place.
- target=_blank + rel=noopener noreferrer; EN + DE i18n
  (`admin.viewOnGithub`); dark-mode aware, matches the muted footer style.
2026-07-10 09:50:05 +02:00
Paul Nothaft 784d059c3d fix(ci): publish v-prefixed image tags so :vX.Y.Z resolves (#668)
docker/metadata-action's type=semver strips the leading 'v', so releases
published only :3.45.0 / :3.83.1-beta.0. But git tags + GitHub releases
are named v3.45.0, so anyone pinning ghcr.io/.../backend:v3.45.0 (the
obvious choice) hit 'manifest unknown' — exactly #664.

Add v-prefixed semver patterns (v{{version}}, v{{major}}.{{minor}},
v{{major}}) alongside the existing bare ones, for both backend and
frontend. Now both :v3.45.0 and :3.45.0 resolve.

Applies to future releases; the already-published v3.45.0 only has the
bare :3.45.0 tag (retagging past releases is out of scope).
2026-07-10 09:46:10 +02:00
Paul Nothaft 65ac6eddac fix(release): target stable in release-please.yml + undo the bogus 2.7.0 bump
The stable release-please workflow (release-please.yml, triggered on
push to stable) had no `target-branch`, so it defaulted to the repo
default branch (main) and computed the next version from main's stale
`.release-please-manifest.json` (2.6.1) — cutting a spurious **v2.7.0**
stable release (a version regression from 3.44.0) when #771 landed on
stable, and bumping main's package.json + manifest to 2.7.0.

- release-please.yml: add `target-branch: stable` so it releases from
  the stable branch (3.44.0 → 3.45.0), like release-please-beta.yml
  already pins `target-branch: main`.
- Restore main's version to 3.83.0-beta.0 (backend + frontend
  package.json), set `.release-please-manifest.json` to 3.44.0, and drop
  the bogus 2.7.0 CHANGELOG section.

The v2.7.0 tag/release is deleted separately; the real v3.45.0 stable is
cut by re-running release-please on the stable branch after this lands.
2026-07-09 11:39:16 +02:00
Paul Nothaft 80503c52b9 ci: run the Tests workflow on stable-targeted PRs
tests.yml (the backend/frontend Jest+Vitest jobs) only triggered on
main/beta, but those two jobs are required status checks on the stable
branch. A beta→stable promote PR therefore hung forever on
'Expected — Waiting for status to be reported' for backend/frontend,
while docker-build / install-smoke / schema-drift (already listing
stable) ran fine. Add stable to the push + pull_request filters so the
Tests suite runs on promote PRs too.
2026-07-08 20:29:19 +02:00
Paul Nothaft 945026d446 fix(admin): stop the event-date field crashing the page on backspace
Repro: create an event, click into the date field, backspace a day digit.
The whole page white-screened and needed a reload.

Root cause: LocalizedDateInput's `toIso` only checked the day/month were
1-2 digits, not that they formed a real date — so a mid-backspace value
like "0/07/2026" was coerced to the string "2026-07-00" and committed to
`event_date`. CreateEventPage then rendered
`format(addDays(new Date('2026-07-00'), days))`, and date-fns `format`
throws RangeError on an Invalid Date — thrown during render, so React
tore the tree down to the error boundary.

Two complementary fixes:
- `toIso` round-trips the parsed y/m/d through `Date` and rejects
  impossible dates (day 00, month 13, 31 Feb…), so the field never
  commits a value that isn't a real calendar date.
- `useLocalizedDate.format`/`formatDistanceToNow` guard with `isValid`
  and return '' instead of throwing — defence in depth for the ~57 call
  sites that could otherwise white-screen on a bad date.

Verified live: backspacing to a partial/invalid date no longer crashes
(the form stays rendered), a valid date still commits + the expiry
preview renders. Adds a LocalizedDateInput regression test; tsc + build
green.
2026-07-07 08:56:04 +02:00
Paul Nothaft 60b03b1728 fix(branding): unify hero logo SIZE the same way as visibility (#756)
Follow-on to the visibility fix in this PR — hero_logo_size had the same
split-brain: GalleryLayout read the global branding_logo_size live while
the hero-header path used the per-event snapshot, so a hero logo could
render at different sizes on different layouts and the global size didn't
reach hero-header galleries.

Now mirrored on the visibility model: NULL per-event hero_logo_size =
inherit branding_logo_size; explicit = override.

- Migration 153: hero_logo_size nullable + backfill NULL so existing
  galleries inherit the global size (restores GalleryLayout's prior
  live-global behaviour and fixes the hero-header staleness).
- Creation stores NULL unless explicit; gallery.js resolves
  per-event ?? global and sends the effective size.
- GalleryLayout now consumes that resolved size for the hero logo (new
  heroLogoSize prop) instead of the global — both render paths match.
- Admin size control gains a 'Use branding default' (inherit) option.

Verified: migration on SQLite + PG; live resolution (inherit follows
global both ways, override wins); creation stores NULL on PG; tsc clean,
106 adminEvents+gallery tests pass, build green.
2026-07-06 11:44:33 +02:00
Paul Nothaft 96fe478bf8 fix(branding): make 'Show logo in hero' a true global toggle with per-event override (#756)
Before: the global branding_logo_display_hero toggle was only a
creation-time default — snapshotted into each event's hero_logo_visible
column at creation and never consulted again. Disabling it did nothing
to existing galleries (the reporter's bug), and the two gallery render
paths disagreed (GalleryLayout read the global, HeroHeader read the
per-event snapshot).

Now: NULL per-event hero_logo_visible = 'inherit the global toggle';
an explicit true/false is a per-gallery override.

- Migration 152: make events.hero_logo_visible nullable and NULL out the
  defaulted  rows so existing galleries follow the global going
  forward. Deliberate per-gallery hides () are preserved.
- Creation stores NULL unless the admin explicitly sets it; the update
  path preserves NULL.
- gallery.js resolves per-event ?? global (branding_logo_display_hero,
  default true) and sends the EFFECTIVE value on both gallery responses.
- Both frontend render paths now consume that resolved value
  (GalleryLayout gets it via a new heroLogoVisible prop).
- Admin per-event control is now tri-state: Use branding default /
  Always show / Always hide (en + de).

Verified: SQLite migration + live resolution (inherit follows global
both ways; override wins both ways); PG migration SQL dry-run; the admin
tri-state renders 'Use branding default' for an inherited event; tsc
clean, 106 adminEvents+gallery tests pass, build green.
2026-07-06 11:20:36 +02:00
Paul Nothaft a0a28a4777 fix(og): broaden social-crawler coverage (Bluesky Cardyb, WeChat-scraper, fediverse, etc.)
From alexvaltchev's field UA list on #699. Adds CRAWLER-EXCLUSIVE tokens
to both the nginx UA regex and SOCIAL_CRAWLER_PATTERNS (kept in sync):
Cardyb (Bluesky's actual link-card fetcher), facebookcatalog, Signal,
Misskey, Pleroma, Synapse, Nextcloud, Rocket.Chat, kakaotalk-scrap,
Google-PageRenderer, OdklBot, ZoomBot.

Deliberately NOT added: UAs shared with real human in-app browsers
(WeChat MicroMessenger, LINE 'Line/', Zalo) and broad strings
('InAppBrowser', 'preview', 'unfurl', 'XING' → matches 'boxing'). Our OG
response is meta-only with no redirect, so matching those would serve a
human the bare stub. New negative test locks that exclusion in.

Verified: nginx -t passes; live harness confirms the new tokens rewrite
to /og while the in-app-browser UAs still get the SPA. Backend suite 15/15.
2026-07-05 23:37:16 +02:00
Paul Nothaft 0dffe0ce92 fix(og): route branded short URLs + slideshow links to OG, add Viber (#699)
Follow-up to #699/#700/#702 — the OG SSR handler existed but three link
shapes never reached it behind the frontend nginx:

- Branded short URLs (/s/<slug>, #702) had NO nginx location, so they fell
  through to the SPA — which has no /s/ route. Dead for humans (no 302
  redirect) and crawlers (no OG). Add an ^~ /s/ proxy to the backend, whose
  /s/:shortSlug route already handles both.
- Slideshow links (/gallery/<slug>/show/<token>) have TWO extra path
  segments; the crawler-detect location regex allowed only one, so they
  never rewrote to /og and got generic site-wide OG. Widen to {0,2} extra
  segments (quoted regex — the braces would otherwise be parsed as nginx
  config delimiters). client-access still matches (its token is in ?query,
  one path segment).
- Viber's preview fetcher wasn't in either UA list, so Viber shares showed
  no preview. Add it to nginx + SOCIAL_CRAWLER_PATTERNS (kept in sync).

Verified end-to-end: nginx -t passes; a live nginx+mock-backend harness
confirms /s/ proxies to the backend, slideshow + Viber + share-token +
client-access crawler UAs all rewrite to /og/gallery/<slug>, and browsers
still get the SPA. Backend isSocialCrawler test extended for Viber.
2026-07-05 21:19:53 +02:00
Paul Nothaft dadaaeea77 feat(setup): final community/thank-you step (#732); fix create-admin button overflow (#730)
#730 — the account step's Create-admin button shared a flex row with Back;
its label + loading spinner exceeded the card width, so the button
overflowed the card outline while submitting (and was fragile for longer
i18n labels). Stack both buttons full-width — the primary always has room
for the spinner now, matching every other wizard step.

#732 — add a final 'community' step, shown once on first-run after
config / no-config, before entering the app. Mission line + four link
cards (report a bug, request a feature, star/share, Buy Me a Coffee),
all target=_blank rel=noopener, and a Finish → Dashboard button. Fully
i18n (en + de). Restore keeps its reload flow (a restored instance is no
longer first-run, so it never reaches this step). Adds .github/FUNDING.yml
so GitHub renders a Sponsor button too.

Verified live: drove the real first-run wizard end to end — stacked
account buttons render inside the card, community step shows the mission
+ all four links, Finish lands on the dashboard.
2026-07-03 12:29:58 +02:00
Paul Nothaft f564b38c5a Merge remote-tracking branch 'origin/main' into refactor/codebase-cleanup
# Conflicts:
#	backend/src/routes/adminEvents.js
#	backend/src/routes/protectedImages.js
#	frontend/src/pages/admin/EventDetailsPage.tsx
2026-07-03 11:54:01 +02:00
Paul Nothaft 96e3c68b9d feat(admin-ui): TOTP MFA enrollment + two-step login; remove stub 2FA toggle
Frontend for #738.
- mfa.service.ts + MfaSettingsCard (Settings → General → Admin Account):
  per-user setup (QR + manual secret + verify), recovery codes shown once
  (copy/download/confirm), status, regenerate, disable. Renders for
  super_admin (closes #735).
- Two-step login in AdminLoginPage: on {mfaRequired,mfaToken} swap to a
  code step (TOTP or recovery), call /auth/admin/login/mfa; handle
  MFA_INVALID / MFA_SESSION_EXPIRED / 423 lockout.
- Removed the non-functional global enable_2fa checkbox from SecurityTab
  (and its persistence) — replaced with a note pointing to per-user setup.
- en + de i18n.

Verified live in-browser: enroll (QR→code→recovery codes), logout, and
the two-step challenge into the dashboard as super_admin.
2026-07-03 11:44:07 +02:00