From 85c13b3d4610d221ed6c56fa92cb56f34627374f Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 19 May 2026 22:51:08 +0200 Subject: [PATCH 01/14] feat(notification): add dismiss-notification and mark-all-read form actions to aktivitaeten Adds two SvelteKit form actions to /aktivitaeten/+page.server.ts so the notification bell can POST there instead of calling the backend directly from the browser. Co-Authored-By: Claude Sonnet 4.6 --- .../src/routes/aktivitaeten/+page.server.ts | 28 +++++++ .../routes/aktivitaeten/page.server.spec.ts | 80 ++++++++++++++++++- 2 files changed, 106 insertions(+), 2 deletions(-) diff --git a/frontend/src/routes/aktivitaeten/+page.server.ts b/frontend/src/routes/aktivitaeten/+page.server.ts index 21df3e85..940cedb6 100644 --- a/frontend/src/routes/aktivitaeten/+page.server.ts +++ b/frontend/src/routes/aktivitaeten/+page.server.ts @@ -1,4 +1,6 @@ +import { fail } from '@sveltejs/kit'; import { createApiClient } from '$lib/shared/api.server'; +import { getErrorMessage } from '$lib/shared/errors'; import type { components, operations } from '$lib/generated/api'; type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO']; @@ -65,3 +67,29 @@ export async function load({ fetch, url }) { loadError }; } + +export const actions = { + 'dismiss-notification': async ({ request, fetch }) => { + const data = await request.formData(); + const notificationId = data.get('notificationId') as string; + const api = createApiClient(fetch); + const result = await api.PATCH('/api/notifications/{id}/read', { + params: { path: { id: notificationId } } + }); + if (!result.response.ok) { + const code = (result.error as unknown as { code?: string })?.code; + return fail(result.response.status, { error: getErrorMessage(code) }); + } + return { success: true }; + }, + + 'mark-all-read': async ({ fetch }) => { + const api = createApiClient(fetch); + const result = await api.POST('/api/notifications/read-all'); + if (!result.response.ok) { + const code = (result.error as unknown as { code?: string })?.code; + return fail(result.response.status, { error: getErrorMessage(code) }); + } + return { success: true }; + } +}; diff --git a/frontend/src/routes/aktivitaeten/page.server.spec.ts b/frontend/src/routes/aktivitaeten/page.server.spec.ts index 31a1619f..fbff2670 100644 --- a/frontend/src/routes/aktivitaeten/page.server.spec.ts +++ b/frontend/src/routes/aktivitaeten/page.server.spec.ts @@ -1,8 +1,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { load } from './+page.server'; +import { load, actions } from './+page.server'; const mockApi = { - GET: vi.fn() + GET: vi.fn(), + PATCH: vi.fn(), + POST: vi.fn() }; vi.mock('$lib/shared/api.server', () => ({ @@ -173,3 +175,77 @@ describe('aktivitaeten/load — kinds param per filter', () => { expect(call[1].params.query.kinds).toHaveLength(2); }); }); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function makeActionEvent(formData: FormData): any { + return { + request: new Request('http://localhost/aktivitaeten', { method: 'POST', body: formData }), + fetch + }; +} + +describe('aktivitaeten/actions — dismiss-notification', () => { + it('calls PATCH /api/notifications/{id}/read with the form-supplied notificationId', async () => { + mockApi.PATCH.mockResolvedValue({ response: { ok: true }, data: {} }); + const fd = new FormData(); + fd.set('notificationId', 'n-abc'); + + await actions['dismiss-notification'](makeActionEvent(fd)); + + expect(mockApi.PATCH).toHaveBeenCalledWith('/api/notifications/{id}/read', { + params: { path: { id: 'n-abc' } } + }); + }); + + it('returns { success: true } when the API responds ok', async () => { + mockApi.PATCH.mockResolvedValue({ response: { ok: true }, data: {} }); + const fd = new FormData(); + fd.set('notificationId', 'n-abc'); + + const result = await actions['dismiss-notification'](makeActionEvent(fd)); + + expect(result).toEqual({ success: true }); + }); + + it('returns fail(status, { error }) when the API responds non-ok', async () => { + mockApi.PATCH.mockResolvedValue({ + response: { ok: false, status: 403 }, + error: { code: 'NOTIFICATION_NOT_FOUND' } + }); + const fd = new FormData(); + fd.set('notificationId', 'n-abc'); + + const result = await actions['dismiss-notification'](makeActionEvent(fd)); + + expect(result).toMatchObject({ status: 403 }); + }); +}); + +describe('aktivitaeten/actions — mark-all-read', () => { + it('calls POST /api/notifications/read-all', async () => { + mockApi.POST.mockResolvedValue({ response: { ok: true }, data: null }); + + await actions['mark-all-read'](makeActionEvent(new FormData())); + + expect(mockApi.POST).toHaveBeenCalledWith('/api/notifications/read-all'); + }); + + it('returns { success: true } when the API responds ok', async () => { + mockApi.POST.mockResolvedValue({ response: { ok: true }, data: null }); + + const result = await actions['mark-all-read'](makeActionEvent(new FormData())); + + expect(result).toEqual({ success: true }); + }); + + it('returns fail(status, { error }) when the API responds non-ok', async () => { + mockApi.POST.mockResolvedValue({ + response: { ok: false, status: 500 }, + error: { code: 'INTERNAL_ERROR' } + }); + + const result = await actions['mark-all-read'](makeActionEvent(new FormData())); + + expect(result).toMatchObject({ status: 500 }); + }); +}); -- 2.49.1 From cdd5bfa318f9b433a53715fedc43d4aaceb59519 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 19 May 2026 22:59:01 +0200 Subject: [PATCH 02/14] refactor(notification): rename markRead/markAllRead to optimistic helpers without fetch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes raw fetch() calls from the store. optimisticMarkRead(id) and optimisticMarkAllRead() now only mutate local $state — the actual API calls move to SvelteKit form actions on /aktivitaeten. Co-Authored-By: Claude Sonnet 4.6 --- .../notification/notifications.svelte.spec.ts | 42 +++++++++++++++++-- .../lib/notification/notifications.svelte.ts | 31 +++++--------- 2 files changed, 49 insertions(+), 24 deletions(-) diff --git a/frontend/src/lib/notification/notifications.svelte.spec.ts b/frontend/src/lib/notification/notifications.svelte.spec.ts index f4bb1ca0..15345286 100644 --- a/frontend/src/lib/notification/notifications.svelte.spec.ts +++ b/frontend/src/lib/notification/notifications.svelte.spec.ts @@ -108,12 +108,46 @@ describe('notificationStore (singleton)', () => { expect(notificationStore.unreadCount).toBe(1); }); - it('markAllRead resets unreadCount', async () => { - mockFetch.mockResolvedValue(new Response(null, { status: 200 })); - await notificationStore.markAllRead(); + it('optimisticMarkRead marks the notification read and decrements unreadCount without fetching', () => { + notificationStore.init(); + const notification = makeNotification({ id: 'sse-1', read: false }); + lastEventSource!.simulate('notification', JSON.stringify(notification)); + mockFetch.mockReset(); // clear the fetchUnreadCount call from init - expect(mockFetch).toHaveBeenCalledWith('/api/notifications/read-all', { method: 'POST' }); + notificationStore.optimisticMarkRead('sse-1'); + + expect(notificationStore.notifications[0].read).toBe(true); expect(notificationStore.unreadCount).toBe(0); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('optimisticMarkRead on an already-read notification does not decrement unreadCount below 0', () => { + notificationStore.init(); + const notification = makeNotification({ id: 'sse-1', read: true }); + lastEventSource!.simulate('notification', JSON.stringify(notification)); + + notificationStore.optimisticMarkRead('sse-1'); + + expect(notificationStore.unreadCount).toBe(0); + }); + + it('optimisticMarkAllRead resets unreadCount and marks all notifications read without fetching', () => { + notificationStore.init(); + lastEventSource!.simulate( + 'notification', + JSON.stringify(makeNotification({ id: 'n1', read: false })) + ); + lastEventSource!.simulate( + 'notification', + JSON.stringify(makeNotification({ id: 'n2', read: false })) + ); + mockFetch.mockReset(); + + notificationStore.optimisticMarkAllRead(); + + expect(notificationStore.unreadCount).toBe(0); + expect(notificationStore.notifications.every((n) => n.read)).toBe(true); + expect(mockFetch).not.toHaveBeenCalled(); }); }); diff --git a/frontend/src/lib/notification/notifications.svelte.ts b/frontend/src/lib/notification/notifications.svelte.ts index 2ac49e4e..1e52cfe3 100644 --- a/frontend/src/lib/notification/notifications.svelte.ts +++ b/frontend/src/lib/notification/notifications.svelte.ts @@ -35,28 +35,19 @@ async function fetchUnreadCount(): Promise { } } -async function markRead(notification: NotificationItem): Promise { - if (!notification.read) { - try { - await fetch(`/api/notifications/${notification.id}/read`, { method: 'PATCH' }); - notification.read = true; - unreadCount = Math.max(0, unreadCount - 1); - } catch (e) { - console.error('Failed to mark notification as read', e); - } +function optimisticMarkRead(id: string): void { + const notification = notifications.find((n) => n.id === id); + if (notification && !notification.read) { + notification.read = true; + unreadCount = Math.max(0, unreadCount - 1); } } -async function markAllRead(): Promise { - try { - await fetch('/api/notifications/read-all', { method: 'POST' }); - for (const n of notifications) { - n.read = true; - } - unreadCount = 0; - } catch (e) { - console.error('Failed to mark all notifications as read', e); +function optimisticMarkAllRead(): void { + for (const n of notifications) { + n.read = true; } + unreadCount = 0; } function init(): void { @@ -123,8 +114,8 @@ export const notificationStore = { }, fetchNotifications, fetchUnreadCount, - markRead, - markAllRead, + optimisticMarkRead, + optimisticMarkAllRead, init, destroy }; -- 2.49.1 From 3d3c111c2b1c8137a54fe661121d662625c5134b Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 19 May 2026 23:15:56 +0200 Subject: [PATCH 03/14] refactor(notification): replace callback props with form actions in Dropdown and Bell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NotificationDropdown now wraps each row in a
and the mark-all control in , wired via use:enhance for optimistic UI. Props renamed onMarkRead/onMarkAllRead → optimisticMarkRead/optimisticMarkAllRead to match the simplified store API. NotificationBell passes the store helpers directly; handleMarkRead is removed. Test mocks updated: $app/forms enhance mock fires SubmitFunction synchronously on form submit so callback assertions work without a real HTTP round-trip. Co-Authored-By: Claude Sonnet 4.6 --- .../lib/notification/NotificationBell.svelte | 17 +- .../NotificationBell.svelte.spec.ts | 59 ++---- .../notification/NotificationDropdown.svelte | 168 +++++++++++------- .../NotificationDropdown.svelte.test.ts | 141 ++++++++++----- 4 files changed, 218 insertions(+), 167 deletions(-) diff --git a/frontend/src/lib/notification/NotificationBell.svelte b/frontend/src/lib/notification/NotificationBell.svelte index 3648f95f..3a04bb35 100644 --- a/frontend/src/lib/notification/NotificationBell.svelte +++ b/frontend/src/lib/notification/NotificationBell.svelte @@ -1,10 +1,8 @@
+ {#if errorMessage} + + {/if} {#if unread.length === 0}
{ + errorMessage = null; optimisticMarkAllRead(); - return async ({ update }) => { + return async ({ result, update }) => { + if (result.type === 'failure' || result.type === 'error') { + errorMessage = m.notification_error_generic(); + } await update({ reset: false, invalidateAll: false }); }; }} @@ -115,8 +124,12 @@ function href(n: NotificationItem): string { action="/aktivitaeten?/dismiss-notification" method="POST" use:enhance={() => { + errorMessage = null; optimisticMarkRead(n.id); - return async ({ update }) => { + return async ({ result, update }) => { + if (result.type === 'failure' || result.type === 'error') { + errorMessage = m.notification_error_generic(); + } await update({ reset: false, invalidateAll: false }); }; }} diff --git a/frontend/src/lib/activity/ChronikFuerDichBox.svelte.spec.ts b/frontend/src/lib/activity/ChronikFuerDichBox.svelte.spec.ts index 44099b40..8ffd6713 100644 --- a/frontend/src/lib/activity/ChronikFuerDichBox.svelte.spec.ts +++ b/frontend/src/lib/activity/ChronikFuerDichBox.svelte.spec.ts @@ -5,18 +5,36 @@ import { page, userEvent } from 'vitest/browser'; import ChronikFuerDichBox from './ChronikFuerDichBox.svelte'; import type { NotificationItem } from '$lib/notification/notifications.svelte'; +const mockFormResult = vi.hoisted(() => ({ type: 'success' as string })); + vi.mock('$app/forms', () => ({ - enhance(node: HTMLFormElement, submit?: (opts: { formData: FormData }) => unknown) { - const handler = (e: Event) => { + enhance( + node: HTMLFormElement, + submit?: (opts: { + formData: FormData; + }) => (opts: { + result: { type: string; data?: Record }; + update: () => Promise; + }) => Promise + ) { + const handler = async (e: Event) => { e.preventDefault(); - submit?.({ formData: new FormData(node) } as never); + const cb = submit?.({ formData: new FormData(node) } as never); + if (typeof cb === 'function') { + await ( + cb as (o: { result: typeof mockFormResult; update: () => Promise }) => Promise + )({ result: mockFormResult, update: async () => {} }); + } }; node.addEventListener('submit', handler); return { destroy: () => node.removeEventListener('submit', handler) }; } })); -afterEach(cleanup); +afterEach(() => { + cleanup(); + mockFormResult.type = 'success'; +}); function notif(partial: Partial): NotificationItem { return { @@ -156,4 +174,22 @@ describe('ChronikFuerDichBox', () => { // Prevents the senior-audience tap-drag bug flagged by Leonie. expect(dismiss?.closest('a')).toBeNull(); }); + + it('shows an accessible error banner when the dismiss action returns a failure', async () => { + mockFormResult.type = 'failure'; + render(ChronikFuerDichBox, { + unread: [notif({ id: 'err-1' })], + optimisticMarkRead: vi.fn(), + optimisticMarkAllRead: vi.fn() + }); + const dismiss = document.querySelector( + '[data-testid="chronik-fuerdich-dismiss"]' + ) as HTMLButtonElement | null; + expect(dismiss).not.toBeNull(); + dismiss?.click(); + // Allow microtask queue to flush + await new Promise((r) => setTimeout(r, 0)); + const alert = document.querySelector('[role="alert"]'); + expect(alert).not.toBeNull(); + }); }); diff --git a/frontend/src/lib/activity/ChronikFuerDichBox.svelte.test.ts b/frontend/src/lib/activity/ChronikFuerDichBox.svelte.test.ts index dfc271f6..2ee844e3 100644 --- a/frontend/src/lib/activity/ChronikFuerDichBox.svelte.test.ts +++ b/frontend/src/lib/activity/ChronikFuerDichBox.svelte.test.ts @@ -4,18 +4,36 @@ import { page } from 'vitest/browser'; import ChronikFuerDichBox from './ChronikFuerDichBox.svelte'; import type { NotificationItem } from '$lib/notification/notifications'; +const mockFormResult = vi.hoisted(() => ({ type: 'success' as string })); + vi.mock('$app/forms', () => ({ - enhance(node: HTMLFormElement, submit?: (opts: { formData: FormData }) => unknown) { - const handler = (e: Event) => { + enhance( + node: HTMLFormElement, + submit?: (opts: { + formData: FormData; + }) => (opts: { + result: { type: string; data?: Record }; + update: () => Promise; + }) => Promise + ) { + const handler = async (e: Event) => { e.preventDefault(); - submit?.({ formData: new FormData(node) } as never); + const cb = submit?.({ formData: new FormData(node) } as never); + if (typeof cb === 'function') { + await ( + cb as (o: { result: typeof mockFormResult; update: () => Promise }) => Promise + )({ result: mockFormResult, update: async () => {} }); + } }; node.addEventListener('submit', handler); return { destroy: () => node.removeEventListener('submit', handler) }; } })); -afterEach(cleanup); +afterEach(() => { + cleanup(); + mockFormResult.type = 'success'; +}); const mention = (overrides: Partial = {}): NotificationItem => ({ id: 'n-1', @@ -140,4 +158,24 @@ describe('ChronikFuerDichBox', () => { const link = document.querySelector('ul[role="list"] li a') as HTMLAnchorElement; expect(link.getAttribute('href')).toContain('doc-x'); }); + + it('shows an accessible error banner when the dismiss action returns a failure', async () => { + mockFormResult.type = 'failure'; + render(ChronikFuerDichBox, { + props: { + unread: [mention({ id: 'err-1' })], + optimisticMarkRead: () => {}, + optimisticMarkAllRead: () => {} + } + }); + + const dismiss = document.querySelector( + '[data-testid="chronik-fuerdich-dismiss"]' + ) as HTMLElement; + dismiss.click(); + // Allow microtask queue to flush + await new Promise((r) => setTimeout(r, 0)); + const alert = document.querySelector('[role="alert"]'); + expect(alert).not.toBeNull(); + }); }); -- 2.49.1 From 392097287c55f535adfc26c9858ab137befab1e7 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 20 May 2026 07:31:26 +0200 Subject: [PATCH 11/14] fix(notification): address review suggestions - ChronikFuerDichBox: move update() inside the failure branch so success path skips it, matching NotificationDropdown's pattern - NotificationDropdown test: add role=alert assertion for mark-all-read failure to match existing dismiss-failure coverage in ChronikFuerDichBox - +page.server.ts: use getErrorMessage(undefined) instead of null so the missing-notificationId 400 goes through the same i18n pipeline as other errors Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/activity/ChronikFuerDichBox.svelte | 4 ++-- .../NotificationDropdown.svelte.test.ts | 17 +++++++++++++++++ .../src/routes/aktivitaeten/+page.server.ts | 2 +- 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/frontend/src/lib/activity/ChronikFuerDichBox.svelte b/frontend/src/lib/activity/ChronikFuerDichBox.svelte index 18c2edff..ac893171 100644 --- a/frontend/src/lib/activity/ChronikFuerDichBox.svelte +++ b/frontend/src/lib/activity/ChronikFuerDichBox.svelte @@ -81,8 +81,8 @@ function href(n: NotificationItem): string { return async ({ result, update }) => { if (result.type === 'failure' || result.type === 'error') { errorMessage = m.notification_error_generic(); + await update({ reset: false, invalidateAll: false }); } - await update({ reset: false, invalidateAll: false }); }; }} > @@ -129,8 +129,8 @@ function href(n: NotificationItem): string { return async ({ result, update }) => { if (result.type === 'failure' || result.type === 'error') { errorMessage = m.notification_error_generic(); + await update({ reset: false, invalidateAll: false }); } - await update({ reset: false, invalidateAll: false }); }; }} > diff --git a/frontend/src/lib/notification/NotificationDropdown.svelte.test.ts b/frontend/src/lib/notification/NotificationDropdown.svelte.test.ts index 459bd72b..bdbda7eb 100644 --- a/frontend/src/lib/notification/NotificationDropdown.svelte.test.ts +++ b/frontend/src/lib/notification/NotificationDropdown.svelte.test.ts @@ -234,6 +234,23 @@ describe('NotificationDropdown', () => { expect(optimisticMarkAllRead).toHaveBeenCalledOnce(); }); + it('shows a role=alert error banner when mark-all-read returns a failure', async () => { + mockFormResult.type = 'failure'; + render(NotificationDropdown, { + props: { + notifications: [makeNotification()], + optimisticMarkRead: () => {}, + optimisticMarkAllRead: () => {}, + onClose: () => {} + } + }); + + await page.getByRole('button', { name: /alle gelesen/i }).click(); + + const alert = document.querySelector('[role="alert"]'); + expect(alert).not.toBeNull(); + }); + it('calls onClose when the view-all button is clicked', async () => { const onClose = vi.fn(); render(NotificationDropdown, { diff --git a/frontend/src/routes/aktivitaeten/+page.server.ts b/frontend/src/routes/aktivitaeten/+page.server.ts index 8d5e3fb1..368b7bc3 100644 --- a/frontend/src/routes/aktivitaeten/+page.server.ts +++ b/frontend/src/routes/aktivitaeten/+page.server.ts @@ -73,7 +73,7 @@ export const actions = { const data = await request.formData(); const raw = data.get('notificationId'); const notificationId = typeof raw === 'string' ? raw : null; - if (!notificationId) return fail(400, { error: null }); + if (!notificationId) return fail(400, { error: getErrorMessage(undefined) }); const api = createApiClient(fetch); const result = await api.PATCH('/api/notifications/{id}/read', { params: { path: { id: notificationId } } -- 2.49.1 From 7b282f699d20bd898361b634f621e7013ef9d152 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 20 May 2026 20:09:27 +0200 Subject: [PATCH 12/14] fix(document): add receivers+trainingLabels to Document.list entity graph Document.list was missing receivers (caused LazyInitializationException when sorting by receiver) and trainingLabels (latent crash for any document with OCR training labels assigned). Document.full was missing trainingLabels for the same reason. OSIV is disabled so every lazy association used after the transaction closes must be in the graph. Co-Authored-By: Claude Sonnet 4.6 --- .../java/org/raddatz/familienarchiv/document/Document.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/document/Document.java b/backend/src/main/java/org/raddatz/familienarchiv/document/Document.java index 21052240..387e3547 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/document/Document.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/document/Document.java @@ -30,7 +30,9 @@ import java.util.UUID; }) @NamedEntityGraph(name = "Document.list", attributeNodes = { @NamedAttributeNode("sender"), - @NamedAttributeNode("tags") + @NamedAttributeNode("receivers"), + @NamedAttributeNode("tags"), + @NamedAttributeNode("trainingLabels") }) @Entity @Table(name = "documents") -- 2.49.1 From 909f960b2e471aab2c615f728ba14cf0ddcc9d62 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 20 May 2026 20:09:48 +0200 Subject: [PATCH 13/14] fix(transcription): allow ANNOTATE_ALL on block write endpoints TranscriptionBlockController required WRITE_ALL exclusively, blocking users with only ANNOTATE_ALL from saving, reviewing, or deleting blocks. All write endpoints now accept {ANNOTATE_ALL, WRITE_ALL}, matching the pattern already established in AnnotationController and CommentController. Co-Authored-By: Claude Sonnet 4.6 --- .../transcription/TranscriptionBlockController.java | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/document/transcription/TranscriptionBlockController.java b/backend/src/main/java/org/raddatz/familienarchiv/document/transcription/TranscriptionBlockController.java index 0a2240a4..feb5e378 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/document/transcription/TranscriptionBlockController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/document/transcription/TranscriptionBlockController.java @@ -43,7 +43,7 @@ public class TranscriptionBlockController { @PostMapping @ResponseStatus(HttpStatus.CREATED) - @RequirePermission(Permission.WRITE_ALL) + @RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL}) public TranscriptionBlock createBlock( @PathVariable UUID documentId, @Valid @RequestBody CreateTranscriptionBlockDTO dto, @@ -53,7 +53,7 @@ public class TranscriptionBlockController { } @PutMapping("/{blockId}") - @RequirePermission(Permission.WRITE_ALL) + @RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL}) public TranscriptionBlock updateBlock( @PathVariable UUID documentId, @PathVariable UUID blockId, @@ -65,7 +65,7 @@ public class TranscriptionBlockController { @DeleteMapping("/{blockId}") @ResponseStatus(HttpStatus.NO_CONTENT) - @RequirePermission(Permission.WRITE_ALL) + @RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL}) public void deleteBlock( @PathVariable UUID documentId, @PathVariable UUID blockId) { @@ -73,7 +73,7 @@ public class TranscriptionBlockController { } @PutMapping("/reorder") - @RequirePermission(Permission.WRITE_ALL) + @RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL}) public List reorderBlocks( @PathVariable UUID documentId, @RequestBody ReorderTranscriptionBlocksDTO dto) { @@ -82,7 +82,7 @@ public class TranscriptionBlockController { } @PutMapping("/{blockId}/review") - @RequirePermission(Permission.WRITE_ALL) + @RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL}) public TranscriptionBlock reviewBlock( @PathVariable UUID documentId, @PathVariable UUID blockId, @@ -92,7 +92,7 @@ public class TranscriptionBlockController { } @PutMapping("/review-all") - @RequirePermission(Permission.WRITE_ALL) + @RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL}) public List markAllBlocksReviewed( @PathVariable UUID documentId, Authentication authentication) { -- 2.49.1 From 19e2f65a2100ea96c0336b5fd638504951164368 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 20 May 2026 20:10:12 +0200 Subject: [PATCH 14/14] fix(csrf): send X-XSRF-TOKEN on all client-side mutating fetch calls hooks.server.ts already forwards the CSRF token for server-side fetch (form actions, load). Client-side XHR calls bypassed it, causing Spring Security to return 403 before PermissionAspect even ran. Adds getCsrfToken/withCsrf/makeCsrfFetch to cookies.ts. useTranscriptionBlocks wraps its injectable fetchImpl with makeCsrfFetch (covers all block mutations and saveBlockWithConflictRetry). useBlockAutoSave, TranscriptionEditView, BulkDocumentEditLayout, OcrTrainingCard, and SegmentationTrainingCard apply withCsrf inline. Co-Authored-By: Claude Sonnet 4.6 --- .../document/BulkDocumentEditLayout.svelte | 6 ++- .../TranscriptionEditView.svelte | 14 +++--- .../transcription/useBlockAutoSave.svelte.ts | 16 ++++--- .../useTranscriptionBlocks.svelte.ts | 3 +- frontend/src/lib/ocr/OcrTrainingCard.svelte | 3 +- .../lib/ocr/SegmentationTrainingCard.svelte | 3 +- frontend/src/lib/shared/cookies.ts | 43 +++++++++++++++++++ 7 files changed, 73 insertions(+), 15 deletions(-) diff --git a/frontend/src/lib/document/BulkDocumentEditLayout.svelte b/frontend/src/lib/document/BulkDocumentEditLayout.svelte index b2623140..c4eaf2df 100644 --- a/frontend/src/lib/document/BulkDocumentEditLayout.svelte +++ b/frontend/src/lib/document/BulkDocumentEditLayout.svelte @@ -17,6 +17,7 @@ import PdfViewer from '$lib/document/viewer/PdfViewer.svelte'; import { bulkTitleFromFilename } from '$lib/document/filename'; import type { Tag } from '$lib/tag/TagInput.svelte'; import type { components } from '$lib/generated/api'; +import { withCsrf } from '$lib/shared/cookies'; type Person = components['schemas']['Person']; @@ -183,7 +184,10 @@ async function saveUpload() { // FormData with per-chunk progress. Session cookie is sent automatically // by the browser for same-origin requests. try { - const res = await fetch('/api/documents/quick-upload', { method: 'POST', body: formData }); + const res = await fetch( + '/api/documents/quick-upload', + withCsrf({ method: 'POST', body: formData }) + ); const body = await res.json().catch(() => ({ errors: [] })); const errorFilenames = new Set( (body.errors ?? []).map((err: { filename: string }) => err.filename) diff --git a/frontend/src/lib/document/transcription/TranscriptionEditView.svelte b/frontend/src/lib/document/transcription/TranscriptionEditView.svelte index 87e9749a..225e1e4e 100644 --- a/frontend/src/lib/document/transcription/TranscriptionEditView.svelte +++ b/frontend/src/lib/document/transcription/TranscriptionEditView.svelte @@ -6,6 +6,7 @@ import TranscribeCoachEmptyState from '$lib/shared/help/TranscribeCoachEmptyStat import type { PersonMention, TranscriptionBlockData } from '$lib/shared/types'; import { createBlockAutoSave } from '$lib/document/transcription/useBlockAutoSave.svelte'; import { createBlockDragDrop } from '$lib/document/transcription/useBlockDragDrop.svelte'; +import { withCsrf } from '$lib/shared/cookies'; type Props = { documentId: string; @@ -113,11 +114,14 @@ function handleDelete(blockId: string) { async function reorder(newOrder: string[]) { try { - const res = await fetch(`/api/documents/${documentId}/transcription-blocks/reorder`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ blockIds: newOrder }) - }); + const res = await fetch( + `/api/documents/${documentId}/transcription-blocks/reorder`, + withCsrf({ + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ blockIds: newOrder }) + }) + ); if (!res.ok) return; const updated = await res.json(); for (const b of updated) { diff --git a/frontend/src/lib/document/transcription/useBlockAutoSave.svelte.ts b/frontend/src/lib/document/transcription/useBlockAutoSave.svelte.ts index 4e538fbc..d2b0bb47 100644 --- a/frontend/src/lib/document/transcription/useBlockAutoSave.svelte.ts +++ b/frontend/src/lib/document/transcription/useBlockAutoSave.svelte.ts @@ -1,5 +1,6 @@ import { SvelteMap } from 'svelte/reactivity'; import type { PersonMention } from '$lib/shared/types'; +import { withCsrf } from '$lib/shared/cookies'; export type SaveState = 'idle' | 'saving' | 'saved' | 'fading' | 'error'; @@ -116,12 +117,15 @@ export function createBlockAutoSave({ saveFn, documentId }: Options) { for (const [blockId, text] of pendingTexts) { const mentions = pendingMentions.get(blockId) ?? []; clearDebounce(blockId); - void fetch(`/api/documents/${documentId}/transcription-blocks/${blockId}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ text, mentionedPersons: mentions }), - keepalive: true - }); + void fetch( + `/api/documents/${documentId}/transcription-blocks/${blockId}`, + withCsrf({ + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text, mentionedPersons: mentions }), + keepalive: true + }) + ); pendingTexts.delete(blockId); pendingMentions.delete(blockId); } diff --git a/frontend/src/lib/document/transcription/useTranscriptionBlocks.svelte.ts b/frontend/src/lib/document/transcription/useTranscriptionBlocks.svelte.ts index da864ad8..4adcc310 100644 --- a/frontend/src/lib/document/transcription/useTranscriptionBlocks.svelte.ts +++ b/frontend/src/lib/document/transcription/useTranscriptionBlocks.svelte.ts @@ -2,6 +2,7 @@ lastEditedAt's $derived are scope-local to one computation; they're never stored on $state. */ import type { TranscriptionBlockData, PersonMention } from '$lib/shared/types'; +import { makeCsrfFetch } from '$lib/shared/cookies'; import { saveBlockWithConflictRetry } from './saveBlockWithConflictRetry'; import { BlockConflictResolvedError } from './blockConflictMerge'; @@ -41,7 +42,7 @@ export function createTranscriptionBlocks( options: TranscriptionBlocksOptions ): TranscriptionBlocksController { const { documentId } = options; - const fetchImpl = options.fetchImpl ?? fetch; + const fetchImpl = makeCsrfFetch(options.fetchImpl ?? fetch); let blocks = $state([]); let annotationReloadKey = $state(0); diff --git a/frontend/src/lib/ocr/OcrTrainingCard.svelte b/frontend/src/lib/ocr/OcrTrainingCard.svelte index 3bb8eb6c..88ae1c1f 100644 --- a/frontend/src/lib/ocr/OcrTrainingCard.svelte +++ b/frontend/src/lib/ocr/OcrTrainingCard.svelte @@ -2,6 +2,7 @@ import TrainingHistory from './TrainingHistory.svelte'; import { m } from '$lib/paraglide/messages.js'; import type { TrainingRun } from '$lib/ocr/training.js'; +import { withCsrf } from '$lib/shared/cookies'; interface TrainingInfo { availableBlocks?: number; @@ -33,7 +34,7 @@ async function startTraining() { successMessage = null; errorMessage = null; try { - const res = await fetch('/api/ocr/train', { method: 'POST' }); + const res = await fetch('/api/ocr/train', withCsrf({ method: 'POST' })); if (res.ok) { successMessage = m.training_success(); setTimeout(() => { diff --git a/frontend/src/lib/ocr/SegmentationTrainingCard.svelte b/frontend/src/lib/ocr/SegmentationTrainingCard.svelte index cfd9c478..c1e2f50b 100644 --- a/frontend/src/lib/ocr/SegmentationTrainingCard.svelte +++ b/frontend/src/lib/ocr/SegmentationTrainingCard.svelte @@ -2,6 +2,7 @@ import TrainingHistory from './TrainingHistory.svelte'; import { m } from '$lib/paraglide/messages.js'; import type { TrainingRun } from '$lib/ocr/training.js'; +import { withCsrf } from '$lib/shared/cookies'; interface TrainingInfo { availableSegBlocks?: number; @@ -27,7 +28,7 @@ async function startTraining() { training = true; successMessage = null; try { - const res = await fetch('/api/ocr/segtrain', { method: 'POST' }); + const res = await fetch('/api/ocr/segtrain', withCsrf({ method: 'POST' })); if (res.ok) { successMessage = m.training_success(); setTimeout(() => { diff --git a/frontend/src/lib/shared/cookies.ts b/frontend/src/lib/shared/cookies.ts index ca71e08d..3b2a273a 100644 --- a/frontend/src/lib/shared/cookies.ts +++ b/frontend/src/lib/shared/cookies.ts @@ -1,3 +1,46 @@ +/** + * Reads the XSRF-TOKEN cookie set by Spring Security's CookieCsrfTokenRepository. + * Returns null outside the browser or when the cookie is absent. + */ +export function getCsrfToken(): string | null { + if (typeof document === 'undefined') return null; + const match = document.cookie.match(/(?:^|;\s*)XSRF-TOKEN=([^;]+)/); + return match ? decodeURIComponent(match[1]) : null; +} + +/** + * Merges the X-XSRF-TOKEN header into a RequestInit so Spring Security's + * CSRF filter accepts the request. Safe to call server-side (no-op when the + * cookie is absent). + */ +export function withCsrf(init?: RequestInit): RequestInit { + const token = getCsrfToken(); + if (!token) return init ?? {}; + const headers = new Headers(init?.headers); + headers.set('X-XSRF-TOKEN', token); + return { ...init, headers }; +} + +/** + * Wraps a fetch implementation so that every state-mutating call (POST, PUT, + * PATCH, DELETE) automatically includes the X-XSRF-TOKEN header. GET/HEAD + * requests pass through unchanged. + * + * Used to CSRF-protect client-side hooks that accept an injectable fetchImpl. + * In unit tests the injected mock is wrapped but getCsrfToken() returns null + * (no browser cookie), so no header is added and existing test expectations + * are unaffected. + */ +export function makeCsrfFetch(inner: typeof fetch): typeof fetch { + return (input: RequestInfo | URL, init?: RequestInit): Promise => { + const method = (init?.method ?? 'GET').toUpperCase(); + if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) { + return inner(input, withCsrf(init)); + } + return inner(input, init); + }; +} + /** * Extracts the fa_session cookie value from a list of Set-Cookie response headers. * -- 2.49.1