Commit Graph

757 Commits

Author SHA1 Message Date
Paul Nothaft d0274886e6 fix(gallery): decide the overlay by pointer capability, not viewport width
Codex review round 1 on #1272. Both findings are consequences of extending the
Grid/Justified tap-to-reveal model to every layout: what those two layouts got
away with, because they were the only ones using it, becomes wrong once
Masonry, Mosaic and Timeline inherit it.

The hover variants no longer hide behind `md:`. On a fine pointer under 768px
`isTouchDevice` is false, so nothing reveals the overlay, and the `md:` prefix
disabled the only hover variants there were -- the controls stayed
`opacity-0 pointer-events-none` with no way to reach them. Grid and Justified
already behaved that way, but Masonry, Mosaic and Timeline had unprefixed
`group-hover:` and revealed at any width, so this was a regression for them.
Width was never the real question: what the breakpoint was standing in for is
that :hover latches on a touchscreen once a tile is tapped. So the variants are
emitted for pointer devices only and withheld on touch, which says that
directly.

detectCoarsePointer no longer ORs the touch fallbacks over matchMedia.
matchMedia describes the PRIMARY pointer; `ontouchstart` and `maxTouchPoints`
only say a touchscreen exists somewhere, which is equally true of a touchscreen
laptop or a docked tablet being driven by its mouse. OR-ing them classified
those as touch-only, so an ordinary click merely revealed the overlay and
opening a photo took two clicks. The fallbacks now stand in only where
matchMedia is absent, which is what the comment already claimed.

3 more tests, all 3 failing before this commit.
2026-09-02 14:15:05 +02:00
Paul Nothaft c0d34796cd fix(gallery): stop invisible overlay controls swallowing mobile taps
Closes #1263.

A tap on a photo tile did one of three things depending on where the finger
landed: opened the photo, downloaded it, or liked it. The cause is that
`opacity-0` hides pixels but not hit-testing. The overlay's View/Download/Like
buttons and the selection checkbox were rendered at opacity 0 and left fully
tappable; each one calls stopPropagation, so hitting an unseen button both
fired its action and suppressed the tile's own open.

On a pointer device hover reveals the controls before anyone can click them, so
the gap never showed. On a touchscreen there is no hover, so in Masonry, Mosaic
and Timeline the controls were invisible for good and tappable for good.

Visibility and hit-testing now move together. PhotoCard computes both from one
place, so every layout that uses it gets the same rule instead of passing its
own opacity classes:

- `touchAware` is gone. It gated the tap-to-reveal state machine, and only Grid
  and Justified opted in -- which is why those two behaved and the other three
  did not. Every PhotoCard layout is touch-aware now: first tap reveals the
  controls, second tap on a control acts, second tap elsewhere opens the photo.
  Pointer devices keep hover semantics unchanged.
- The pointer reading moved from an effect into the initial state. As an effect
  it landed a mount-time render between the tile measurement in useLayoutEffect
  and the image mount that measurement gates, remounting every card once --
  caught by the #1095 regression test, which is the reason that test exists.
  It also now degrades to ontouchstart/maxTouchPoints where matchMedia is
  absent, since every layout runs this path now.

Two more instances of the same class, outside PhotoCard:

- GalleryPremiumLayout's checkbox and like button are CSS-hidden the same way.
  They get pointer-events alongside opacity, and because that layout has no
  reveal gesture, a `(hover: none)` block shows both outright at a finger-sized
  target rather than leaving them unreachable.
- PhotoGrid's download button called `onClick={onDownload}` with no
  stopPropagation, so downloading also opened the lightbox.

Verified on a mobile viewport with real touch emulation: at rest the tile
centre now hits the image rather than an unseen Download button, and one tap
reveals the controls instead of downloading the file.

5 tests, all 5 failing before the change.
2026-09-02 13:49:10 +02:00
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 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 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 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 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 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 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 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 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 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 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 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