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.
Two issues in the fonts service test suite added by #390 — the behaviour
assertions all passed, but 5 of 24 tests had assertions that silently
no-op'd, so any regression in those code paths would not have been
caught.
## Issue 1: jest.resetModules() bypassed the logger mock
`beforeEach` called `jest.resetModules()` then re-required `fontsService`.
After resetModules, the `jest.mock('../../src/utils/logger', ...)` factory
at the top of the file no longer applied to subsequent requires — so the
freshly-required `fontsService` captured the REAL logger while the test
file's `logger` variable still pointed at the mocked one. The 4
"warning logged" / "info logged" assertions resolved as 0 calls and
silently passed-as-noop.
The resetModules call wasn't necessary in the first place — module-level
state in fontsService is just the cache, which clearFontsCache() already
resets. And both getBundledFontsRoot() and getUserFontsRoot() read
process.env at call-time, not at module load, so the env vars set in
beforeEach are picked up without needing a fresh require.
Fix: require fontsService once at module top (inside the jest.mock
hoisting scope) and drop resetModules + the per-test re-require.
## Issue 2: case-insensitive filesystem (macOS / Windows)
The "case-insensitive duplicate within the same root" test created
`Inter/` and `INTER/` to trigger the dedup warning. On a case-sensitive
FS (Linux ext4) both directory entries exist and the dedup branch fires;
on macOS APFS or Windows NTFS the second mkdir resolves to the same
folder as the first, so only one ever exists and the dedup is
unreachable from this test setup. Test failed on macOS dev, passed on
Linux CI.
Fix: probe at load time by creating a lowercase file and checking if
its uppercase variant resolves to the same inode, then conditionally
test.skip the affected test on case-insensitive hosts. Comment in the
test body explains why.
## Result
23 of 24 tests now pass on macOS; the case-sensitive-only test runs on
Linux CI. All previously-no-op'd assertions now exercise their code
paths.
The Acknowledgments block had a generic "thanks to all contributors"
line but no actual recognition by name. Two people in particular have
moved the project meaningfully forward and should be called out:
- @Luca-Timo — code contributor across multi-arch Docker, the external-
URL CMS toggle, folder tree picker, admin email picker, self-hosted
webfonts, the gallery header/banner decoupling, and typed-API
refactors. Consistent quality.
- @Rekoo-PS — bug reporter and feedback loop. Filed the issues that
drove the login-loop fix, gallery loading skeleton, redirection
cleanup, mobile lightbox overhaul, admin events search-counter fix,
photo-count column, and bulk-delete workflow. Also a BuyMeACoffee
supporter.
Closes the implicit recognition gap and sets up the section so future
contributors can be added with a one-line PR.
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.
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.