text-xs (12px) is at the lower bound for the 60+ transcriber cohort.
text-sm (14px) matches the visual weight of the progress counter label
above and is more comfortable to read under stress (failed operation).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds an await for the button to become non-disabled between the two
dispatchEvent calls in 'clears error on next successful call'. This
ensures the first async rejection has fully settled and Svelte has
flushed markingAllReviewed before the second click fires.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds a test for when the server returns a non-JSON body (e.g. an nginx
502 HTML page). Confirms the res.json().catch(() => ({})) fallback
produces 'INTERNAL_ERROR' as the thrown message and leaves blocks intact.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds toHaveTextContent(m.transcription_mark_all_reviewed_error()) to the
error-present test. The previous check only asserted presence via
role="alert", which would not have caught the dead key bug — the banner
was showing the generic fallback rather than the operation-specific copy.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Removes the getErrorMessage() indirection and calls
m.transcription_mark_all_reviewed_error() directly in the catch block.
The previous implementation routed through getErrorMessage(code) which
mapped any error code to the generic m.error_internal_error() fallback,
leaving the domain-specific key unreachable.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The function now throws instead of silently returning on failure.
Update the test name and assertion to match the new behaviour, and
verify blocks remain unchanged after the error.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace hardcoded German strings with m.transcription_mark_all_reviewed()
and m.transcription_mark_all_reviewed_disabled().
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds markAllError state and catch block to handleMarkAllReviewed.
Error banner renders below the review progress bar with role="alert"
and aria-live="polite" for screen reader announcement. Dismiss button
clears the error; next successful call also clears it automatically.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Previously the function silently returned on failure, leaving no way
for callers to detect or surface the error to the user.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
RED phase: 4 new Vitest browser tests that fail because the error
banner and catch block don't exist yet.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces hardcoded visible text 'Für Training vormerken' with
m.transcribe_mark_for_training() so the label translates in EN and ES.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces hardcoded 'Menü öffnen'/'Menü schließen' ternary with
m.layout_menu_open()/m.layout_menu_close() so the mobile nav toggle
announces correctly in EN and ES locales.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces hardcoded Zurück/Weiter/Verkleinern/Vergrößern aria-label strings
with m.viewer_previous_page(), m.viewer_next_page(), m.viewer_zoom_out(),
and m.viewer_zoom_in() so viewer controls translate in EN and ES locales.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds 7 Paraglide keys (viewer_previous_page, viewer_next_page,
viewer_zoom_out, viewer_zoom_in, transcribe_mark_for_training,
layout_menu_open, layout_menu_close) to de/en/es.json.
Adds messages.spec.ts to enforce key parity across all three locales.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
After adding @Schema(requiredMode=REQUIRED) to InviteListItemDTO.shareableUrl,
npm run generate:api now emits shareableUrl as required. Replace the hand-rolled
InviteListItem interface with a type alias to the generated InviteListItemDTO
and remove the two 'as unknown as InviteListItem' casts + TODO comments.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Fix VALID_STATUSES fallback to use uppercase enum value
- Add TODO comment on InviteListItem cast pending type regeneration
- Guard revoke action against null id (returns fail 400)
- Add request: to delete action mock events for Sentry consistency
- Add expiresAt forwarding test for create action
- Add null-id guard test for revoke action
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add load() unit tests for admin/users/[id] (permission gate, 404, success)
- Rename .test.ts → .spec.ts for consistency with rest of suite
- Add @Schema(requiredMode=REQUIRED) to InviteListItem.shareableUrl
- Add client-side allowlist for invite status query param
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
`@sentry/sveltekit` wraps load functions and reads `event.request.method` and
`event.url.pathname`. Mock events that omitted `request` or `url` threw
`TypeError: Cannot read properties of undefined` on every invocation, silently
masking 86 test failures on main.
Two root causes fixed:
- Added `request: new Request(...)` (and `url: new URL(...)` where absent) to
all mock event objects in 14 `*.server.spec.ts` files
- Changed `;` to `&&` in the `test:coverage` npm script so a failing server
run propagates its exit code instead of being swallowed by the client run
All 576 server-project tests now pass.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace fetch('/api/users/${id}', { method: 'PUT', ... }) + inline JSON
error parsing with createApiClient(fetch).PUT('/api/users/{id}', ...) and
the standard result.error cast pattern.
Also fix pre-existing Sentry mock event failures in layout.server.spec.ts
by adding request and url to the test event object.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace manual fetch(${apiUrl}/api/...) calls in load, create, and revoke
with createApiClient(fetch) so auth injection is handled by handleFetch
and the typed API contract is enforced at compile time.
Also fix pre-existing load test failures caused by Sentry's load wrapper
reading event.request.method (add request to the mock event object).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add min-h-[44px] py-2 to <summary> in ImportStatusCard for 44 px touch target
- Add SkippedFile and skipped count entries to docs/GLOSSARY.md
- Add MassImportServiceTest case: ALREADY_EXISTS fires before file I/O when doc is UPLOADED and file is present on disk
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Change importSingleDocument return type from boolean to Optional<String>
so callers in processRows receive the skip reason on every non-success path.
S3 upload failures now surface as "S3_UPLOAD_FAILED" and already-imported
documents as "ALREADY_EXISTS" in the skippedFiles list shown in the admin UI.
- Add two new tests: runImportAsync_addsS3UploadFailed_toSkippedFiles and
runImportAsync_addsAlreadyExists_toSkippedFiles; update
importSingleDocument_skips_whenDocumentAlreadyUploadedNotPlaceholder and
the S3-failure test to assert on the Optional return value.
- Add i18n keys for S3_UPLOAD_FAILED and ALREADY_EXISTS in de/en/es messages.
- Svelte ImportStatusCard: add aria-hidden="true" to SVG chevron, wrap
conditional warning section in aria-live="polite" div, add max-h-64
overflow-y-auto to skipped-files <ul> to cap height on large batches.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add comment to openFileStream() explaining package-private visibility
is intentional (Mockito spy seam for IOException test)
- Key {#each} skippedFiles by filename instead of array index
- Add test: skipped section hidden when state is FAILED
- Add test: reasonLabel returns raw code for unknown reason strings
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Use loop index as each key (handles duplicate filenames)
- Increase skipped filename font from text-xs to text-sm
- Add motion-safe guard to details chevron transition
- Replace text-warning with text-amber-900 to meet WCAG AA contrast
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- remove duplicate List import in AdminControllerTest
- derive skipped() from skippedFiles.size() — drop redundant int field
- use machine codes for SkippedFile.reason (INVALID_PDF_SIGNATURE, FILE_READ_ERROR)
- map reason codes to i18n strings in ImportStatusCard (de/en/es)
- replace raw amber Tailwind classes with warning semantic token
- fix <summary> accessibility: replace list-none with rotating chevron SVG
- replace <p> with <span> inside <summary> (phrasing content rule)
- extract setupOneValidOneFakeImport() helper — remove 3x copy-paste
- add lenient mock to short-file test for defensive coverage
- add IOException path test for isPdfMagicBytes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds SkippedFile to the local ImportStatus type and updates
ImportStatusCard to show an amber skipped-count section with a
collapsible filename list in the DONE state. Only rendered when
skipped > 0. i18n keys added for de/en/es.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- frontend/hooks.server.ts: replace request.url.includes('/api/') with
new URL(request.url).pathname.startsWith('/api/') so a page named
/my-api/something cannot accidentally match the API gate
- DomainException: add optional retryAfterSeconds field and a new
tooManyRequests() factory overload that carries the value
- LoginRateLimiter: pass windowMinutes * 60 as retryAfterSeconds when
throwing TOO_MANY_LOGIN_ATTEMPTS (RFC 6585 §4 SHOULD)
- GlobalExceptionHandler: emit Retry-After header when retryAfterSeconds
is set on a DomainException
- RateLimitInterceptor: emit Retry-After: 60 on 429 responses (1-min
window matches the existing MAX_REQUESTS_PER_MINUTE logic)
- LoginRateLimiterTest: assert retryAfterSeconds equals window duration
- RateLimitInterceptorTest: assert Retry-After header is set on 429
- JdbcSessionRevocationAdapterIntegrationTest: new @SpringBootTest +
Testcontainers test verifying revokeAll deletes all spring_session rows
and revokeOther leaves the current session intact
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Remove unreachable `&& !xsrfToken` condition from `handleFetch` guard;
simplify the redundant `cookieParts.length > 0` check that follows it
- Add `TOO_MANY_LOGIN_ATTEMPTS` to both Error Handling sections in CLAUDE.md
(backend and frontend) so LLMs are aware of the code without looking it up
- Add reverse-proxy IP trust and IPv6 address-cycling caveats to ADR-022
Consequences section
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Renders LoginPage with form.rateLimited=true and asserts that the
role="alert" div (clock icon + error message) is visible in the browser.
Previously only the form action's rateLimited=true return value was tested;
now the rendered UI is also verified.
Addresses Sara Concern 4 / Elicit open question from PR #617 review.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Regular error div was missing role="alert" — screen readers did not
announce it on dynamic display. Rate-limited clock icon used text-ink-3
(muted grey) instead of text-red-600, visually inconsistent with the
surrounding error text. Also removes the erroneous aria-invalid="true"
from the rate-limit alert div (not a permitted attribute on role=alert).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- handleFetch injects X-XSRF-TOKEN + XSRF-TOKEN cookie on all mutating
backend API requests (double-submit cookie pattern); generates a fresh
UUID when no XSRF-TOKEN cookie exists yet
- ErrorCode union gains CSRF_TOKEN_MISSING and TOO_MANY_LOGIN_ATTEMPTS;
getErrorMessage maps both to i18n keys
- de/en/es messages add error_csrf_token_missing and
error_too_many_login_attempts translations
- Login action maps HTTP 429 to fail(429, { ..., rateLimited: true });
page shows a muted clock icon with aria-invalid on rate-limit errors
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
text-xs (12px) is below Leonie's body-copy floor for the senior reader cohort
who hit /login?reason=expired on a phone in sunlight after being logged out.
text-sm (14px) restores legibility without breaking the visual hierarchy with
the heading. Addresses PR #612 / Leonie L3.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Color-blind reader cohort (8% of men) on a phone in sunlight cannot rely on
amber alone to parse the banner as a warning. Add a Heroicons-style
exclamation-triangle SVG, aria-hidden because the heading text already
conveys the meaning to assistive tech. Addresses PR #612 / Leonie L2.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replace text-amber-900/text-amber-800 with the existing --color-warning
utility from layout.css. The amber soft fill stays (matching the precedent
of the green "registered" banner; a full surface-token pair is out of scope
for this PR). Addresses PR #612 / Leonie L1.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Four new tests against the composed handle (with sequence stubbed to return
the head function): backend 401 on a private path redirects to
/login?reason=expired; backend 401 on /login does NOT redirect (no loop);
missing fa_session passes through without a backend call; 200 attaches the
user to event.locals. Closes the hook-coverage gap flagged by Sara S1.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Three tests: happy path POSTs to backend with the session cookie and clears
both fa_session and legacy auth_token; cookies are cleared even when the
backend call rejects (best-effort logout); skips the backend call when no
session cookie is present. Addresses PR #612 / Sara S1.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Six tests covering: load() exposes ?registered and ?reason; action returns 400
on missing email; 401 with INVALID_CREDENTIALS on backend reject; success
re-emits fa_session and deletes legacy auth_token; 500 when backend omits
fa_session in Set-Cookie. Closes the frontend coverage gap on the credential-
handling logic that moved out of the Java side. Addresses PR #612 / Sara S1.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replace the duck-typed `status in error && location in error` check with the
official SvelteKit guard. Fragile against minor-version error-shape changes
becomes a one-liner against a typed helper. Addresses PR #612 / Felix F1.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Drop the inline parser; reuse the now-shared helper. Pure rewire, no behaviour
change. Addresses PR #612 / Felix F2.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Move the Set-Cookie parser out of login/+page.server.ts into a shared module
with its own Vitest coverage (single-header, multi-header getSetCookie path,
missing-header, attribute-stripping, prefix-match-rejection). An Undici or
Node upgrade that changes header shape now trips its own test instead of
silently breaking login. Addresses PR #612 / Felix F2.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Amber aria-live banner when ?reason=expired (set by hooks.server.ts
after the backend rejects an expired fa_session) with a one-line
explainer about the 8h idle window.
- autofocus on email so users returning after a session-expired kick
can immediately retype credentials.
- min-h-[44px] on the submit button hits the iOS HIG / WCAG 2.1 AAA
touch target minimum — relevant for the reader cohort on phones.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
userGroup: GET /api/users/me with Cookie: fa_session=<id>. On 401, drop
the stale cookie and redirect to /login?reason=expired (unless already
on a public path) so the user sees an explainer instead of a silent kick.
handleFetch: forward fa_session as a Cookie header on every API call
except the public auth endpoints. Drops the old auth_token injection.
Also adds a one-off cleanup of any lingering auth_token cookie from
pre-migration sessions.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The backend POST invalidates the spring_session row and writes the
LOGOUT audit entry; the client cookie is deleted unconditionally so a
network blip during logout still logs the user out locally.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces the Basic-credentials-in-cookie flow with the Spring Session model:
1. POST {email, password} as JSON to /api/auth/login
2. Map 401 → INVALID_CREDENTIALS (or SESSION_EXPIRED if the backend returns it)
3. Parse Set-Cookie for fa_session=<opaque> and re-emit to the browser
4. Drop the legacy auth_token cookie
load() now also exposes ?reason= so the page can show the
session-expired banner (Task 21 wires it into the .svelte file).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Mirrors the backend ErrorCode additions from commit 393a3c25.
Adds error_session_expired_explainer for the login-page banner that
will surface when ?reason=expired.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Lower tracesSampleRate from 1.0 to 0.1 in both hooks (errors still captured
at 100%; trace volume reduced for self-hosted GlitchTip on shared VPS)
- Add comment explaining VITE_SENTRY_DSN is a write-only ingest key, safe in
client bundle — prevents accidental rotation as if it were a password
- Restore HTTP status code prominence: text-4xl font-bold (was text-xs text-ink-3)
- Add min-w-[44px] to copy button for WCAG 2.2 minimum touch target
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Two tests matching the existing hooks.server.test.ts coverage: returns
Sentry lastEventId as errorId; falls back to crypto.randomUUID when
lastEventId returns undefined.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>