Files
familienarchiv/frontend/src/lib/ocr/OcrTrainingCard.svelte
Marcel 19e2f65a21
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / OCR Service Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / fail2ban Regex (push) Has been cancelled
CI / Semgrep Security Scan (push) Has been cancelled
CI / Compose Bucket Idempotency (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Successful in 3m34s
CI / OCR Service Tests (pull_request) Successful in 20s
CI / fail2ban Regex (pull_request) Has been cancelled
CI / Semgrep Security Scan (pull_request) Has been cancelled
CI / Compose Bucket Idempotency (pull_request) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
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 <noreply@anthropic.com>
2026-05-20 20:35:51 +02:00

105 lines
3.2 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
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;
totalOcrBlocks?: number;
availableDocuments?: number;
ocrServiceAvailable?: boolean;
lastRun?: TrainingRun | null;
runs?: TrainingRun[];
personNames?: Record<string, string>;
}
interface Props {
trainingInfo: TrainingInfo | null;
}
let { trainingInfo }: Props = $props();
let training = $state(false);
let successMessage = $state<string | null>(null);
let errorMessage = $state<string | null>(null);
const available = $derived(trainingInfo?.availableBlocks ?? 0);
const tooFewBlocks = $derived(available < 5);
const serviceDown = $derived(trainingInfo?.ocrServiceAvailable === false);
const disabled = $derived(training || tooFewBlocks || serviceDown);
async function startTraining() {
training = true;
successMessage = null;
errorMessage = null;
try {
const res = await fetch('/api/ocr/train', withCsrf({ method: 'POST' }));
if (res.ok) {
successMessage = m.training_success();
setTimeout(() => {
successMessage = null;
}, 5000);
} else {
errorMessage = m.training_start_failed();
}
} catch {
errorMessage = m.training_start_failed();
} finally {
training = false;
}
}
</script>
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
<h2 class="mb-1 font-sans text-sm font-bold text-ink">{m.training_ocr_heading()}</h2>
<p class="mb-4 text-sm text-ink-2">{m.training_ocr_description()}</p>
<p class="mb-3 text-sm text-ink">
{m.training_ocr_blocks_ready({ blocks: available, docs: trainingInfo?.availableDocuments ?? 0 })}
<span class="text-ink-2"
>{m.training_ocr_blocks_total({ total: trainingInfo?.totalOcrBlocks ?? 0 })}</span
>
</p>
<button
onclick={startTraining}
disabled={disabled}
class="rounded-sm bg-primary px-5 py-2 font-sans text-xs font-bold tracking-widest text-primary-fg uppercase transition-opacity hover:opacity-80 focus-visible:ring-2 focus-visible:ring-brand-navy disabled:cursor-not-allowed disabled:opacity-50"
>
{training ? '…' : m.training_start_btn()}
</button>
{#if tooFewBlocks}
<p class="mt-2 text-xs text-ink-3">
{m.training_too_few_blocks({ available })}
</p>
{:else if serviceDown}
<p class="mt-2 text-xs text-orange-600">{m.training_service_down()}</p>
{/if}
{#if successMessage}
<p class="mt-2 flex items-center gap-2 text-xs text-green-700" aria-live="polite">
{successMessage}
<button
type="button"
class="-my-2 ml-1 inline-flex h-11 w-11 items-center justify-center rounded-sm underline hover:no-underline focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
onclick={() => (successMessage = null)}
aria-label={m.comp_dismiss()}>×</button
>
</p>
{/if}
{#if errorMessage}
<p class="mt-2 text-xs text-red-600" aria-live="assertive">{errorMessage}</p>
{/if}
<h3 class="mt-6 mb-3 text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.training_history_heading()}
</h3>
<TrainingHistory
runs={(trainingInfo?.runs ?? []).filter((r) => r.modelName !== 'blla')}
personNames={trainingInfo?.personNames ?? {}}
/>
</div>