Commit Graph

452 Commits

Author SHA1 Message Date
Luca 565ae45ca7 fix(admin): tab underlines use accent (not accent-dark) for proper highlight color 2026-05-06 01:29:54 +02:00
Luca 578a1745b8 fix(branding): comprehensive sweep — replace remaining primary-* legacy colors with accent tokens 2026-05-06 01:16:52 +02:00
Luca fc2bce3a01 fix(branding): admin sidebar uses accent-dark, primary buttons follow CI token 2026-05-06 00:57:54 +02:00
Luca b19bb0c620 fix(branding): working tooltips, high-contrast selected states, gallery chrome follows accent 2026-05-05 17:29:15 +02:00
Luca 5b410ed9f8 fix(branding): selected-state accent colors, force-mode actually flips galleries, compact color picker layout 2026-05-05 16:48:47 +02:00
Luca 67d7d8d3fa feat(branding): inline force color mode with auto-save + clearer palette help text 2026-05-05 16:29:00 +02:00
Luca d2a10f6523 fix(cms): apply dark mode to CMS editor, public CMS, and admin modals 2026-05-05 16:07:21 +02:00
Luca 5a162fc8be feat(branding): force color mode (dark or light) site-wide 2026-05-05 16:06:38 +02:00
Luca 114aab5777 feat(theme): expand color settings to 8-token CI palette + alt button 2026-05-05 16:04:43 +02:00
github-actions[bot] 01f5e44353 chore(beta): release 3.40.1-beta.0 2026-05-04 22:19:57 +00:00
Paul Nothaft b106da1ede fix(auth): /auth/session must enforce session timeout symmetrically (#350 recurrence)
Third loop fix in the same /admin/login → /admin/dashboard → /admin/login
pattern as #355 and #363. Reported on v3.39.1-beta.0 — the loop returns
after a server restart or after an idle gap longer than the configured
session timeout.

## Root cause (server)

`sessionTimeoutMiddleware` is mounted on `/api/admin` (server.js:411). It
rejects with `401 SESSION_TIMEOUT` when either:
  - the in-memory `lastActivity` for the token is older than the timeout, or
  - this is the first request with this token AND the token's `iat` is
    older than the timeout (post-restart guard).

`/auth/session` lives under `/api/auth/session`, NOT under `/api/admin`,
so the middleware never runs for it. Result: an idle/old-iat admin token
returns `valid: true` from `/auth/session` while every protected
endpoint immediately rejects it with `401 SESSION_TIMEOUT`. Frontend's
401 interceptor hard-redirects to `/admin/login`, `/auth/session` says
valid again, loop closes — exact same shape as the previous two
asymmetries the symmetry pass missed.

Fix: add a non-mutating `isSessionExpired(token, decoded)` helper to
`middleware/sessionTimeout.js` that reads the same in-memory map and
applies the same lastActivity / iat-vs-timeout logic as the middleware,
without updating the map (the middleware is the only place that records
activity; `/auth/session` is read-only by design). `/auth/session`
calls the helper for `decoded.type === 'admin'` after the existing
admin-existence and password-change checks. Same try/catch fall-through
pattern as the prior fixes so a missing/broken helper doesn't fail-closed
during early bootstrap or in test stubs.

## Root cause (client race amplifying the loop)

Even with the server fix, the previous `useSessionTimeout` hook called
`AdminAuthContext.logout()` which dispatches `POST /auth/logout`
fire-and-forget AND has its own `finally { window.location.href }`,
then immediately set `window.location.href = '/admin/login?session=expired'`
on top. Two consequences:
  - The cookie wasn't reliably cleared before the new page loaded —
    if any /auth/session asymmetry slipped through, the loop replayed
    inside the same tab. New-tab and "refresh several times" "fixes"
    were just the logout request eventually completing.
  - Two redirects raced; sometimes the `?session=expired` query was
    dropped, breaking the login-page toast.

Fix: rewrite the hook to (a) await `POST /auth/logout` so the cookie
is guaranteed cleared, (b) clear `sessionStorage.admin_user` directly
instead of going through AdminAuthContext.logout (which has the
side-effect redirect we don't want), and (c) navigate exactly once
with the `?session=expired` query.

## Tests

- `__tests__/routes/authSession.symmetry.test.js` — 4 new cases under
  a `session-timeout symmetry` describe block: helper says expired →
  valid:false; helper says active → valid:true; helper not called for
  gallery tokens; helper throws → fall through to valid:true (defensive).
  Existing 9 tests still pass (mock now includes
  `isSessionExpired: jest.fn(() => Promise.resolve(false))` as the
  default).
- `__tests__/middleware/sessionTimeout.isSessionExpired.test.js` — 7
  new unit tests for the helper itself: fresh token / old-iat /
  recently-active / null-input / no-mutation / 60-min default
  boundary cases.

20 cases total, all green. Lint clean on every touched file.
2026-05-05 00:12:01 +02:00
github-actions[bot] 3d8fe2ec6e chore(beta): release 3.40.0-beta.0 2026-05-04 21:23:32 +00:00
Paul Nothaft d04bf28808 Merge pull request #390 from Luca-Timo/feat/self-hosted-fonts
feat(branding): self-hosted webfonts with filesystem scanner
2026-05-04 23:23:04 +02:00
Luca dcff451572 feat(branding): per-family generic fallback via meta.json 2026-05-04 22:27:52 +02:00
github-actions[bot] ac05230867 chore(beta): release 3.39.1-beta.0 2026-05-04 19:37:59 +00:00
github-actions[bot] 6fe1d8de9e chore(beta): release 3.39.0-beta.0 2026-05-04 19:16:41 +00:00
Paul Nothaft 1f1a856083 Merge pull request #385 from Luca-Timo/beta
feat(gallery): decouple header style from layout, add banner option
2026-05-04 21:16:13 +02:00
github-actions[bot] 0a48646529 chore(beta): release 3.38.0-beta.0 2026-05-04 19:00:37 +00:00
Paul Nothaft 647aea21ae Merge pull request #389 from the-luap/feat/events-bulk-delete
feat(events): bulk delete with password confirmation (#384)
2026-05-04 21:00:06 +02:00
Paul Nothaft 48d538f94f feat(events): bulk delete with password confirmation (#384)
Adds the bulk-delete half of #384 — admins can select multiple
events from the list and delete them in one batch, gated by
re-entering their password.

## Why password confirmation

Bulk delete is destructive and irreversible (cascades across 5 DB
tables and 3 filesystem paths per event). Re-entering the password
matches the pattern already used by /auth/admin/change-password and
makes accidental clicks much harder than a plain "type DELETE to
confirm" — the muscle-memory required to type your real password is
a stronger gate than typing a literal word.

## Changes

### Backend (adminEvents.js)

- Extracted the per-event cascade-delete logic into a module-private
  `deleteEventCascade(eventId, adminContext)` helper. The DELETE /:id
  route now calls it instead of inlining 60 lines of cascade — same
  behaviour, no drift between the per-event and bulk paths.
- New `POST /admin/events/bulk-delete`. Body: `{ eventIds, password }`.
  Permission: `events.delete`.
  - Validates `eventIds` array length (1–100) and that each id is an
    integer. The 100-cap keeps request time bounded; the per-event
    cascade touches DB + filesystem so 1000 events at once would risk
    timing out the request.
  - Verifies `password` against the calling admin's bcrypt hash via
    `bcrypt.compare()` (same as /auth/admin/change-password). Wrong
    password → 401 `{ error, code: 'INVALID_PASSWORD' }` and no
    events are touched.
  - Loops via `deleteEventCascade`, returns
    `{ results: { successful, failed } }` with the same shape as
    /bulk-archive so the frontend can show partial-failure feedback.
  - Logs `bulk_delete_completed` activity with totals.

### Frontend

- `events.service.ts`: `bulkDeleteEvents(eventIds, password)`.
- New `BulkDeleteModal.tsx`. Red/destructive variant of the
  bulk-archive modal:
  - Lists the events to be deleted (so the admin can verify).
  - Password input with show/hide toggle, autofocus, Enter-to-submit.
  - Inline `passwordError` prop surfaces the 401 INVALID_PASSWORD
    response without losing the modal state — admin can retry
    without re-typing the event list.
  - "Processing" state replaces the form with a spinner + "Deleting
    N events. This may take a few minutes — please don't close this
    window." (i18n) so admins know not to abandon the page during
    a slow operation.
- `EventsListPage.tsx`: "Delete Selected" button next to "Archive
  Selected" in the bulk-actions bar (red-styled to signal danger),
  bulkDeleteMutation that maps the 401 to the modal's inline error
  and any other failure to a generic toast.

### i18n

12 new keys under `events.bulkDelete.*` in all 5 locales
(en/de/nl/pt/ru): title, warning, password label/placeholder/help,
submit, processing, incorrectPassword, successAll, successPartial,
errorGeneric, plus `events.deleteSelected` for the button. Hand-
written for de; nl/pt/ru should get a native-speaker pass at some
point but read naturally.

### Verified

- `npx tsc --noEmit` clean
- `npx eslint` clean on every touched file (4 pre-existing errors in
  adminEvents.js for unused vars unrelated to this PR)
- All 5 locale JSON files parse cleanly
- `node -e "require('./src/routes/adminEvents')"` loads the module

Closes the bulk-delete half of #384. The Photos-column half lands
separately in PR #387.
2026-05-04 20:56:00 +02:00
github-actions[bot] 67999999d8 chore(beta): release 3.37.0-beta.0 2026-05-04 18:52:09 +00:00
Paul Nothaft ffb4318a1f feat(events): add Photos column to admin events list (#384)
The admin events table didn't surface how many photos each event
contained — admins had to click into the event to find out. The
backend already computes `photo_count` for every row in the
GET /admin/events list response (adminEvents.js:794-796), so this
is a frontend-only display change.

- Insert a "Photos" column between Date and Status — groups with
  the "what's in this event" info.
- Right-aligned, tabular-nums for clean numeric alignment in the
  column.
- Reuses the existing `events.photos` i18n key already shipped in
  all 5 locales for the EventDetailsPage tab list ("Fotos" / etc.) —
  no new translations needed.
- Updates the empty-state colSpan from 7 to 8.

Closes the column-add half of #384. The bulk-delete request from
the same issue lands separately.
2026-05-04 20:47:13 +02:00
Luca f410207b2d revert(branding): per-option font preview (defer to follow-up) 2026-05-04 20:39:00 +02:00
Luca e6c03e4b6e fix(nginx): proxy /fonts requests to backend 2026-05-04 20:07:13 +02:00
Luca b4f9b65f1d feat(branding): preview each font in its own face in the picker dropdown 2026-05-04 19:44:20 +02:00
Luca bac51fe69a feat(branding): self-hosted webfonts with filesystem scanner 2026-05-04 19:15:47 +02:00
Luca 894e151255 i18n(gallery): nl/ru wording polish for standard header style 2026-05-04 16:58:26 +02:00
Luca 91ad6bba99 style(theme-customizer): match header-style picker grid to layout picker (3 cols) 2026-05-04 15:48:12 +02:00
Luca 045e9ea486 fix(gallery): default controls to inline for every layout 2026-05-04 15:46:41 +02:00
Luca d823dcda26 Merge branch 'beta' into refactor/external-media-types 2026-05-04 14:40:01 +02:00
Luca 0bd61be5a6 i18n(gallery): translate banner header strings (nl/pt/ru) 2026-05-04 14:34:55 +02:00
Luca aff29c91bb feat(gallery): decouple header style from layout, add banner option 2026-05-04 14:34:42 +02:00
github-actions[bot] 66281f9790 chore(beta): release 3.36.0-beta.0 2026-05-04 11:33:17 +00:00
Paul Nothaft 3fe8e61bd1 Merge pull request #379 from Luca-Timo/feat/admin-email-picker
feat(events): prefill admin email + admin picker on event creation
2026-05-04 13:32:53 +02:00
Luca ffbb0e659f refactor(events): type external-media list response, drop any casts 2026-05-04 12:28:46 +02:00
Paul Nothaft 98c6f6cf06 chore(events): type FolderTreeNode entries with ExternalEntry
Follow-up to PR #378 — drops the (e: any) / (d: any) casts in the
external-folder-tree picker. ExternalEntry is already exported from
externalMedia.service.ts; the call site just wasn't using it.

- Import the type alongside the service.
- Annotate the dirs filter callback so `e.type` is the union 'dir' |
  'file' instead of any.
- Drop the (d: any) annotation from the map — TypeScript infers
  ExternalEntry from the typed `dirs` array.

No behaviour change, no test impact. `npx tsc --noEmit` clean,
`npx eslint` clean.
2026-05-04 12:24:21 +02:00
github-actions[bot] 7e4f1acb88 chore(beta): release 3.35.0-beta.0 2026-05-04 10:21:13 +00:00
Luca 40d23e24cb i18n(events): translate admin email picker strings (nl/pt/ru) 2026-05-04 12:10:23 +02:00
Luca ee56b6762f feat(events): prefill admin email + admin picker on event creation 2026-05-04 12:10:23 +02:00
Luca bd42ee1ce0 fix(events): match scrollbar to theme in external folder tree picker 2026-05-04 11:30:46 +02:00
Luca 954ab00495 i18n(events): translate external folder picker strings (nl/pt/ru) 2026-05-04 11:02:00 +02:00
Luca f927b09c70 feat(events): tree view for external media folder picker 2026-05-04 11:01:49 +02:00
github-actions[bot] 13e3a05386 chore(beta): release 3.34.2-beta.0 2026-05-04 07:14:40 +00:00
github-actions[bot] eb6791792e chore(beta): release 3.34.1-beta.0 2026-05-03 22:18:02 +00:00
Paul Nothaft bce5c1f725 fix(cms): nl/pt/ru i18n + gate external_url in public response
Two follow-ups to PR #372 (external-URL toggle for imprint /
privacy CMS pages):

1. **i18n.** PR #372 added 6 new `cms.*` keys to the en + de
   locales but the project ships 5 locales total. Adds the missing
   nl / pt / ru translations so the admin CMS page renders in the
   active language for those users instead of falling back to
   English literals next to the German/Dutch/Portuguese/Russian
   surrounding strings.

2. **API shape.** `publicCMS.js` returned `external_url`
   unconditionally — even when `use_external_url` is false the URL
   value was still emitted in the public response. The frontend
   correctly gated on both flags so it worked, but the API surface
   was leaking a value the admin had explicitly disabled. The
   value still lives in the DB (so the toggle can be flipped back
   on without losing it), but the public endpoint now returns
   `null` whenever the toggle is off.

   Note: kept the existing `logo_url` shape unchanged. Its semantics
   are different — null means "fall back to global branding" and
   consumers rely on always having the field, so emitting it
   unconditionally is intentional there.

No frontend change needed: both `GalleryLayout` and `LegalPage`
already gate on `use_external_url && external_url`, so the
short-circuit handles `external_url: null` correctly.
2026-05-04 00:14:07 +02:00
github-actions[bot] cf04c0b2dc chore(beta): release 3.34.0-beta.0 2026-05-03 22:11:01 +00:00
Paul Nothaft b2c8161a43 Merge pull request #372 from Luca-Timo/beta
feat(cms): add external URL toggle for imprint and privacy pages
2026-05-04 00:10:44 +02:00
Paul Nothaft c270bcfc9f i18n(events): translate PasswordResetModal across 5 locales
The rebuilt modal in this PR shipped with hard-coded English strings.
That made the reset flow untranslated for German/Dutch/Portuguese/
Russian customers — toasts, confirm dialog, success screen all
fell back to English regardless of the active locale.

- New `events.passwordReset.*` namespace in en/de/nl/pt/ru with 22
  keys covering both modal screens, the warning banner, validation
  errors, and the toast messages.
- Modal uses `useTranslation()` for every previously hard-coded
  string. Reuses `common.cancel`, `events.copy`, `events.copied`
  where they already exist across all locales.
- The {{eventName}} interpolation uses i18next's standard variable
  syntax so the description line reads naturally in each language.

No behaviour change. TypeScript clean (`npx tsc --noEmit`), ESLint
clean. JSON validity checked for all 5 locale files.
2026-05-03 23:16:21 +02:00
Luca c5bba505ac feat(cms): redirect legal links to external URL when configured 2026-05-03 23:06:16 +02:00
Luca a4e3d10fb0 feat(cms): admin UI for external imprint/privacy URL 2026-05-03 23:05:51 +02:00