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") 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) { diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 37e25574..2835fccc 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -522,6 +522,7 @@ "notification_filter_unread": "Ungelesen", "notification_filter_mention": "Erwähnung", "notification_filter_reply": "Antwort", + "notification_error_generic": "Aktion fehlgeschlagen. Bitte versuche es erneut.", "notification_mark_all_read_aria": "Alle Benachrichtigungen als gelesen markieren", "notification_load_more": "Ältere laden", "notification_empty_history": "Keine Benachrichtigungen", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index b4b675c5..e679d061 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -522,6 +522,7 @@ "notification_filter_unread": "Unread", "notification_filter_mention": "Mention", "notification_filter_reply": "Reply", + "notification_error_generic": "Action failed. Please try again.", "notification_mark_all_read_aria": "Mark all notifications as read", "notification_load_more": "Load older", "notification_empty_history": "No notifications", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 898b3e85..dcc948d3 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -522,6 +522,7 @@ "notification_filter_unread": "No leídas", "notification_filter_mention": "Mención", "notification_filter_reply": "Respuesta", + "notification_error_generic": "La acción ha fallado. Por favor, inténtalo de nuevo.", "notification_mark_all_read_aria": "Marcar todas las notificaciones como leídas", "notification_load_more": "Cargar anteriores", "notification_empty_history": "Sin notificaciones", diff --git a/frontend/src/lib/activity/ChronikFuerDichBox.svelte b/frontend/src/lib/activity/ChronikFuerDichBox.svelte index 9cb94c24..ac893171 100644 --- a/frontend/src/lib/activity/ChronikFuerDichBox.svelte +++ b/frontend/src/lib/activity/ChronikFuerDichBox.svelte @@ -1,4 +1,5 @@
+ {#if errorMessage} + + {/if} {#if unread.length === 0}
- + +
    @@ -89,7 +109,7 @@ function href(n: NotificationItem): string { aria-hidden="true" class="mt-0.5 inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-accent-bg font-sans text-xs font-bold text-accent" > - {n.type === 'MENTION' ? '@' : '\u21A9'} + {n.type === 'MENTION' ? '@' : '↩'}

    @@ -100,25 +120,40 @@ function href(n: NotificationItem): string {

    - + + + {/each}
diff --git a/frontend/src/lib/activity/ChronikFuerDichBox.svelte.spec.ts b/frontend/src/lib/activity/ChronikFuerDichBox.svelte.spec.ts index f491dd06..8ffd6713 100644 --- a/frontend/src/lib/activity/ChronikFuerDichBox.svelte.spec.ts +++ b/frontend/src/lib/activity/ChronikFuerDichBox.svelte.spec.ts @@ -5,7 +5,36 @@ import { page, userEvent } from 'vitest/browser'; import ChronikFuerDichBox from './ChronikFuerDichBox.svelte'; import type { NotificationItem } from '$lib/notification/notifications.svelte'; -afterEach(cleanup); +const mockFormResult = vi.hoisted(() => ({ type: 'success' as string })); + +vi.mock('$app/forms', () => ({ + enhance( + node: HTMLFormElement, + submit?: (opts: { + formData: FormData; + }) => (opts: { + result: { type: string; data?: Record }; + update: () => Promise; + }) => Promise + ) { + const handler = async (e: Event) => { + e.preventDefault(); + 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(); + mockFormResult.type = 'success'; +}); function notif(partial: Partial): NotificationItem { return { @@ -26,8 +55,8 @@ describe('ChronikFuerDichBox', () => { it('renders inbox-zero state when there are no unread items', async () => { render(ChronikFuerDichBox, { unread: [], - onMarkRead: vi.fn(), - onMarkAllRead: vi.fn() + optimisticMarkRead: vi.fn(), + optimisticMarkAllRead: vi.fn() }); const zero = document.querySelector('[data-testid="chronik-inbox-zero"]'); expect(zero).not.toBeNull(); @@ -37,8 +66,8 @@ describe('ChronikFuerDichBox', () => { it('links to the archived mentions in the inbox-zero state', async () => { render(ChronikFuerDichBox, { unread: [], - onMarkRead: vi.fn(), - onMarkAllRead: vi.fn() + optimisticMarkRead: vi.fn(), + optimisticMarkAllRead: vi.fn() }); const link = document.querySelector('a[href="/aktivitaeten?filter=fuer-dich"]'); expect(link).not.toBeNull(); @@ -47,8 +76,8 @@ describe('ChronikFuerDichBox', () => { it('renders the count badge with correct total when unread exists', async () => { render(ChronikFuerDichBox, { unread: [notif({ id: 'a' }), notif({ id: 'b' })], - onMarkRead: vi.fn(), - onMarkAllRead: vi.fn() + optimisticMarkRead: vi.fn(), + optimisticMarkAllRead: vi.fn() }); await expect.element(page.getByText('2 neu')).toBeInTheDocument(); }); @@ -56,8 +85,8 @@ describe('ChronikFuerDichBox', () => { it('count badge has aria-live=polite when unread exists', async () => { render(ChronikFuerDichBox, { unread: [notif({ id: 'a' })], - onMarkRead: vi.fn(), - onMarkAllRead: vi.fn() + optimisticMarkRead: vi.fn(), + optimisticMarkAllRead: vi.fn() }); // Wait for render await expect.element(page.getByText('1 neu')).toBeInTheDocument(); @@ -69,8 +98,8 @@ describe('ChronikFuerDichBox', () => { it('does not render the "Alle gelesen" button when there are no unread items', async () => { render(ChronikFuerDichBox, { unread: [], - onMarkRead: vi.fn(), - onMarkAllRead: vi.fn() + optimisticMarkRead: vi.fn(), + optimisticMarkAllRead: vi.fn() }); await expect.element(page.getByText('Keine neuen Erwähnungen')).toBeInTheDocument(); const all = document.querySelector('[data-testid="chronik-mark-all-read"]'); @@ -80,38 +109,38 @@ describe('ChronikFuerDichBox', () => { it('renders the "Alle gelesen" button when unread exists', async () => { render(ChronikFuerDichBox, { unread: [notif({ id: 'a' })], - onMarkRead: vi.fn(), - onMarkAllRead: vi.fn() + optimisticMarkRead: vi.fn(), + optimisticMarkAllRead: vi.fn() }); await expect.element(page.getByText('Alle gelesen')).toBeInTheDocument(); }); - it('calls onMarkAllRead when the "Alle gelesen" button is clicked', async () => { - const onMarkAllRead = vi.fn(); + it('calls optimisticMarkAllRead when the "Alle gelesen" button is submitted', async () => { + const optimisticMarkAllRead = vi.fn(); render(ChronikFuerDichBox, { unread: [notif({ id: 'a' })], - onMarkRead: vi.fn(), - onMarkAllRead + optimisticMarkRead: vi.fn(), + optimisticMarkAllRead }); await userEvent.click(page.getByText('Alle gelesen')); - expect(onMarkAllRead).toHaveBeenCalledTimes(1); + expect(optimisticMarkAllRead).toHaveBeenCalledTimes(1); }); - it('calls onMarkRead (and not navigation) when a per-item Dismiss button is clicked', async () => { - const onMarkRead = vi.fn(); + it('calls optimisticMarkRead with the notification id when its dismiss button is submitted', async () => { + const optimisticMarkRead = vi.fn(); const n = notif({ id: 'xyz' }); render(ChronikFuerDichBox, { unread: [n], - onMarkRead, - onMarkAllRead: vi.fn() + optimisticMarkRead, + optimisticMarkAllRead: vi.fn() }); const dismiss = document.querySelector( '[data-testid="chronik-fuerdich-dismiss"]' ) as HTMLButtonElement | null; expect(dismiss).not.toBeNull(); dismiss?.click(); - expect(onMarkRead).toHaveBeenCalledTimes(1); - expect(onMarkRead.mock.calls[0][0]).toEqual(n); + expect(optimisticMarkRead).toHaveBeenCalledTimes(1); + expect(optimisticMarkRead.mock.calls[0][0]).toBe('xyz'); }); it('mention row href includes both commentId and annotationId when annotationId is present', async () => { @@ -124,8 +153,8 @@ describe('ChronikFuerDichBox', () => { annotationId: 'annot-9' }) ], - onMarkRead: vi.fn(), - onMarkAllRead: vi.fn() + optimisticMarkRead: vi.fn(), + optimisticMarkAllRead: vi.fn() }); const link = document.querySelector( 'a[href="/documents/doc-42?commentId=comment-7&annotationId=annot-9"]' @@ -136,8 +165,8 @@ describe('ChronikFuerDichBox', () => { it('Dismiss button is a sibling of the document link, never nested inside ', async () => { render(ChronikFuerDichBox, { unread: [notif({ id: 'x' })], - onMarkRead: vi.fn(), - onMarkAllRead: vi.fn() + optimisticMarkRead: vi.fn(), + optimisticMarkAllRead: vi.fn() }); const dismiss = document.querySelector('[data-testid="chronik-fuerdich-dismiss"]'); expect(dismiss).not.toBeNull(); @@ -145,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 6b887a86..2ee844e3 100644 --- a/frontend/src/lib/activity/ChronikFuerDichBox.svelte.test.ts +++ b/frontend/src/lib/activity/ChronikFuerDichBox.svelte.test.ts @@ -4,7 +4,36 @@ import { page } from 'vitest/browser'; import ChronikFuerDichBox from './ChronikFuerDichBox.svelte'; import type { NotificationItem } from '$lib/notification/notifications'; -afterEach(cleanup); +const mockFormResult = vi.hoisted(() => ({ type: 'success' as string })); + +vi.mock('$app/forms', () => ({ + enhance( + node: HTMLFormElement, + submit?: (opts: { + formData: FormData; + }) => (opts: { + result: { type: string; data?: Record }; + update: () => Promise; + }) => Promise + ) { + const handler = async (e: Event) => { + e.preventDefault(); + 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(); + mockFormResult.type = 'success'; +}); const mention = (overrides: Partial = {}): NotificationItem => ({ id: 'n-1', @@ -22,7 +51,7 @@ const mention = (overrides: Partial = {}): NotificationItem => describe('ChronikFuerDichBox', () => { it('renders the inbox-zero state when there are no unread', async () => { render(ChronikFuerDichBox, { - props: { unread: [], onMarkRead: () => {}, onMarkAllRead: () => {} } + props: { unread: [], optimisticMarkRead: () => {}, optimisticMarkAllRead: () => {} } }); await expect.element(page.getByText(/keine neuen erwähnungen/i)).toBeVisible(); @@ -34,8 +63,8 @@ describe('ChronikFuerDichBox', () => { render(ChronikFuerDichBox, { props: { unread: [mention(), mention({ id: 'n-2' }), mention({ id: 'n-3' })], - onMarkRead: () => {}, - onMarkAllRead: () => {} + optimisticMarkRead: () => {}, + optimisticMarkAllRead: () => {} } }); @@ -47,8 +76,8 @@ describe('ChronikFuerDichBox', () => { render(ChronikFuerDichBox, { props: { unread: [mention({ id: 'n-m', type: 'MENTION' }), mention({ id: 'n-r', type: 'REPLY' })], - onMarkRead: () => {}, - onMarkAllRead: () => {} + optimisticMarkRead: () => {}, + optimisticMarkAllRead: () => {} } }); @@ -62,8 +91,8 @@ describe('ChronikFuerDichBox', () => { render(ChronikFuerDichBox, { props: { unread: [mention({ actorName: 'Bertha' })], - onMarkRead: () => {}, - onMarkAllRead: () => {} + optimisticMarkRead: () => {}, + optimisticMarkAllRead: () => {} } }); @@ -76,8 +105,8 @@ describe('ChronikFuerDichBox', () => { render(ChronikFuerDichBox, { props: { unread: [mention({ type: 'REPLY', actorName: 'Carl' })], - onMarkRead: () => {}, - onMarkAllRead: () => {} + optimisticMarkRead: () => {}, + optimisticMarkAllRead: () => {} } }); @@ -86,11 +115,11 @@ describe('ChronikFuerDichBox', () => { .toBeVisible(); }); - it('calls onMarkRead with the notification when its dismiss button is clicked', async () => { - const onMarkRead = vi.fn(); + it('calls optimisticMarkRead with the notification id when its dismiss button is clicked', async () => { + const optimisticMarkRead = vi.fn(); const item = mention({ id: 'n-7' }); render(ChronikFuerDichBox, { - props: { unread: [item], onMarkRead, onMarkAllRead: () => {} } + props: { unread: [item], optimisticMarkRead, optimisticMarkAllRead: () => {} } }); const dismiss = document.querySelector( @@ -98,35 +127,55 @@ describe('ChronikFuerDichBox', () => { ) as HTMLElement; dismiss.click(); - expect(onMarkRead).toHaveBeenCalledWith(item); + expect(optimisticMarkRead).toHaveBeenCalledWith('n-7'); }); - it('calls onMarkAllRead when the mark-all-read button is clicked', async () => { - const onMarkAllRead = vi.fn(); + it('calls optimisticMarkAllRead when the mark-all-read button is clicked', async () => { + const optimisticMarkAllRead = vi.fn(); render(ChronikFuerDichBox, { props: { unread: [mention()], - onMarkRead: () => {}, - onMarkAllRead + optimisticMarkRead: () => {}, + optimisticMarkAllRead } }); const btn = document.querySelector('[data-testid="chronik-mark-all-read"]') as HTMLElement; btn.click(); - expect(onMarkAllRead).toHaveBeenCalledOnce(); + expect(optimisticMarkAllRead).toHaveBeenCalledOnce(); }); it('builds a deep-link href to the comment for each notification', async () => { render(ChronikFuerDichBox, { props: { unread: [mention({ documentId: 'doc-x', referenceId: 'ref-y', annotationId: null })], - onMarkRead: () => {}, - onMarkAllRead: () => {} + optimisticMarkRead: () => {}, + optimisticMarkAllRead: () => {} } }); 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(); + }); }); 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/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 @@