Compare commits

...

9 Commits

Author SHA1 Message Date
Marcel
bee309e40c test(e2e): read-only user reads a transcription, no edit affordances (#697)
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m35s
CI / OCR Service Tests (pull_request) Successful in 20s
CI / Backend Unit Tests (pull_request) Successful in 3m27s
CI / fail2ban Regex (pull_request) Successful in 43s
CI / Semgrep Security Scan (pull_request) Successful in 19s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m2s
CI happy path: seed a PDF document with a transcription block as admin, then
as the READ_ALL "reader" open it — assert the "Transkription lesen" control,
the read text, a plain "Transkription" header, and the absence of the
Lesen/Bearbeiten tabs (panel cannot switch to edit).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 12:21:39 +02:00
Marcel
cef05abaaa feat(ui): confine read-only users to the transcription read view (#697)
On the document detail page, pass canEdit={canWrite} to the panel header,
guard onModeChange so a reader can never flip to edit, and default panelMode
to 'read' for readers. Thread canAnnotate={canWrite} through DocumentViewer
to PdfViewer so the annotation layer's canDraw (which also gates delete and
resize) is off for readers — they can open and read, but not draw, edit, or
delete. The writer-only OCR status check is also skipped for readers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 12:19:54 +02:00
Marcel
c5d62bef71 feat(ui): show read-only transcription header without an edit tab (#697)
TranscriptionPanelHeader gains a canEdit prop (default true). Editors keep
the Lesen/Bearbeiten segmented toggle; read-only users get a plain
"Transkription" heading instead of a lone single-option pill, while the
"N Abschnitte" status line stays visible.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 12:19:36 +02:00
Marcel
d1b24c905f i18n(transcription): add reader read-label and panel title strings (#697)
transcription_read_label ("Transkription lesen") for the read-only entry
control and transcription_panel_title ("Transkription") for the plain
header readers see instead of the Lesen/Bearbeiten toggle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 12:14:46 +02:00
Marcel
4e218dc2b8 chore(api): regenerate Document type with hasTranscription (#697)
Mirrors the new server-computed boolean on the document detail payload so
the frontend can gate the transcription entry control at first paint.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 12:08:30 +02:00
Marcel
a73fddefe3 test(security): lock READ_ALL -> 403 on transcription/annotation writes (#697)
Read-only users will soon be able to open the transcription read view, so
the write endpoints become the real authorization boundary. Explicitly
assert a READ_ALL-only principal is forbidden from create/update/reorder/
review block writes and annotation create/patch (the prior tests only used
a no-authority principal).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 12:03:39 +02:00
Marcel
1ac2a74f30 feat(document): add server-computed hasTranscription to detail payload (#697)
getDocumentById now populates a transient hasTranscription boolean so the
document detail page can gate the transcription entry control at first
paint (no client store, no full block fetch, no layout shift).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 12:00:23 +02:00
Marcel
8df7d13368 feat(transcription): expose hasBlocks on TranscriptionBlockQueryService (#697)
Domain-service wrapper over existsByDocumentId so other domains can ask
"does this document have any transcription blocks?" without reaching into
the repository.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 11:59:42 +02:00
Marcel
96e4d20c03 feat(transcription): add existsByDocumentId block query (#697)
Cheap EXISTS query backing a server-side "has a transcription" signal so
read-only users can be offered the read view at first paint.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 11:59:03 +02:00
25 changed files with 468 additions and 42 deletions

View File

@@ -177,6 +177,13 @@ public class Document {
@Builder.Default
private Set<TrainingLabel> trainingLabels = new HashSet<>();
// Not persisted — computed per detail fetch so read-only users can tell at first
// paint whether there is a transcription to read (DocumentService.getDocumentById).
@Transient
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
@Builder.Default
private boolean hasTranscription = false;
// The `?v={thumbnailGeneratedAt}` cache-buster is load-bearing: the thumbnail
// endpoint sends `Cache-Control: private, max-age=31536000, immutable`
// (DocumentController.getDocumentThumbnail). `immutable` is only safe because

View File

@@ -943,6 +943,7 @@ public class DocumentService {
Document doc = documentRepository.findById(id)
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id));
tagService.resolveEffectiveColors(doc.getTags());
doc.setHasTranscription(transcriptionBlockQueryService.hasBlocks(id));
return doc;
}

View File

@@ -17,6 +17,10 @@ public class TranscriptionBlockQueryService {
private final TranscriptionBlockRepository blockRepository;
public boolean hasBlocks(UUID documentId) {
return blockRepository.existsByDocumentId(documentId);
}
public Map<UUID, Integer> getCompletionStats(List<UUID> documentIds) {
if (documentIds.isEmpty()) return Map.of();
Map<UUID, Integer> result = new HashMap<>();

View File

@@ -43,6 +43,8 @@ public interface TranscriptionBlockRepository extends JpaRepository<Transcriptio
int countByDocumentId(UUID documentId);
boolean existsByDocumentId(UUID documentId);
@Query("""
SELECT b FROM TranscriptionBlock b
JOIN DocumentAnnotation a ON a.id = b.annotationId

View File

@@ -118,6 +118,26 @@ class DocumentServiceTest {
assertThat(documentService.getDocumentById(id)).isEqualTo(doc);
}
@Test
void getDocumentById_setsHasTranscriptionTrue_whenBlocksExist() {
UUID id = UUID.randomUUID();
Document doc = Document.builder().id(id).title("Test").build();
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
when(transcriptionBlockQueryService.hasBlocks(id)).thenReturn(true);
assertThat(documentService.getDocumentById(id).isHasTranscription()).isTrue();
}
@Test
void getDocumentById_setsHasTranscriptionFalse_whenNoBlocksExist() {
UUID id = UUID.randomUUID();
Document doc = Document.builder().id(id).title("Test").build();
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
when(transcriptionBlockQueryService.hasBlocks(id)).thenReturn(false);
assertThat(documentService.getDocumentById(id).isHasTranscription()).isFalse();
}
// ─── updateDocument ───────────────────────────────────────────────────────
@Test

View File

@@ -83,6 +83,15 @@ class AnnotationControllerTest {
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void createAnnotation_returns403_whenUserHasOnlyReadAllPermission() throws Exception {
mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations").with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(ANNOTATION_JSON))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void createAnnotation_returns201_whenHasWriteAllPermission() throws Exception {
@@ -190,6 +199,15 @@ class AnnotationControllerTest {
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void patchAnnotation_returns403_whenUserHasOnlyReadAllPermission() throws Exception {
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(PATCH_JSON))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void patchAnnotation_returns200_withWriteAllPermission() throws Exception {

View File

@@ -159,6 +159,15 @@ class TranscriptionBlockControllerTest {
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void createBlock_returns403_whenUserHasOnlyReadAllPermission() throws Exception {
mockMvc.perform(post(URL_BASE).with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(CREATE_JSON))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void createBlock_returns201_withSavedBlock_whenAuthorised() throws Exception {
@@ -233,6 +242,15 @@ class TranscriptionBlockControllerTest {
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void updateBlock_returns403_whenUserHasOnlyReadAllPermission() throws Exception {
mockMvc.perform(put(URL_BLOCK).with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(UPDATE_JSON))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void updateBlock_returns200_withUpdatedBlock_whenAuthorised() throws Exception {
@@ -363,6 +381,15 @@ class TranscriptionBlockControllerTest {
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void reorderBlocks_returns403_whenUserHasOnlyReadAllPermission() throws Exception {
mockMvc.perform(put(URL_REORDER).with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(REORDER_JSON))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void reorderBlocks_returns200_withReorderedBlocks_whenAuthorised() throws Exception {
@@ -440,6 +467,14 @@ class TranscriptionBlockControllerTest {
.andExpect(jsonPath("$.reviewed").value(true));
}
@Test
@WithMockUser(authorities = "READ_ALL")
void reviewBlock_returns403_whenUserHasOnlyReadAllPermission() throws Exception {
mockMvc.perform(put("/api/documents/{documentId}/transcription-blocks/{blockId}/review",
DOC_ID, BLOCK_ID).with(csrf()))
.andExpect(status().isForbidden());
}
// ─── PUT .../review-all ───────────────────────────────────────────────────
private static final String URL_REVIEW_ALL = URL_BASE + "/review-all";

View File

@@ -0,0 +1,35 @@
package org.raddatz.familienarchiv.document.transcription;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class TranscriptionBlockQueryServiceTest {
@Mock TranscriptionBlockRepository blockRepository;
@InjectMocks TranscriptionBlockQueryService queryService;
@Test
void hasBlocks_returns_true_when_a_block_exists() {
UUID documentId = UUID.randomUUID();
when(blockRepository.existsByDocumentId(documentId)).thenReturn(true);
assertThat(queryService.hasBlocks(documentId)).isTrue();
}
@Test
void hasBlocks_returns_false_when_no_block_exists() {
UUID documentId = UUID.randomUUID();
when(blockRepository.existsByDocumentId(documentId)).thenReturn(false);
assertThat(queryService.hasBlocks(documentId)).isFalse();
}
}

View File

@@ -102,4 +102,22 @@ class TranscriptionBlockRepositoryIntegrationTest {
assertThat(byDoc).containsEntry(DOC_A, 100);
assertThat(byDoc).containsEntry(DOC_B, 0);
}
@Test
@Sql(statements = {
"INSERT INTO documents (id, title, original_filename, status) VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Doc A', 'a.pdf', 'PLACEHOLDER')",
"INSERT INTO document_annotations (id, document_id, page_number, x, y, width, height, color) VALUES ('cccccccc-cccc-cccc-cccc-cccccccccccc', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 1, 0, 0, 1, 1, '#fff')",
"INSERT INTO transcription_blocks (annotation_id, document_id, sort_order, reviewed) VALUES ('cccccccc-cccc-cccc-cccc-cccccccccccc', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 0, false)"
})
void existsByDocumentId_returns_true_when_document_has_a_block() {
assertThat(repository.existsByDocumentId(DOC_A)).isTrue();
}
@Test
@Sql(statements = {
"INSERT INTO documents (id, title, original_filename, status) VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Doc A', 'a.pdf', 'PLACEHOLDER')"
})
void existsByDocumentId_returns_false_when_document_has_no_blocks() {
assertThat(repository.existsByDocumentId(DOC_A)).isFalse();
}
}

View File

@@ -0,0 +1,98 @@
import { test, expect } from '@playwright/test';
import path from 'path';
import { fileURLToPath } from 'url';
import fs from 'fs';
import { login } from './helpers/auth';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PDF_FIXTURE = path.resolve(__dirname, 'fixtures/minimal.pdf');
/**
* E2E for issue #697 — read-only users can read an existing transcription.
*
* Setup runs as admin (default storage state): a PDF document with one
* transcription block, so hasTranscription is true. The assertions run in a
* fresh context logged in as the seeded READ_ALL-only "reader": they can open
* the read view but see no edit tab and no per-block edit controls, and the
* panel cannot be switched to edit.
*/
let docId: string;
let docHref: string;
test.describe.configure({ mode: 'serial' });
test.describe('Read-only user reads an existing transcription', () => {
test.beforeAll(async ({ request }) => {
const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000';
const uniqueSuffix = Date.now();
const docRes = await request.post('/api/documents', {
multipart: {
title: `E2E Read-only Transcription ${uniqueSuffix}`,
documentDate: '1945-05-08'
}
});
if (!docRes.ok()) throw new Error(`Create document failed: ${docRes.status()}`);
docId = (await docRes.json()).id;
docHref = `${baseURL}/documents/${docId}`;
await request.put(`/api/documents/${docId}`, {
multipart: {
title: `E2E Read-only Transcription ${uniqueSuffix}`,
documentDate: '1945-05-08',
file: {
name: 'minimal.pdf',
mimeType: 'application/pdf',
buffer: fs.readFileSync(PDF_FIXTURE)
}
}
});
const annRes = await request.post(`/api/documents/${docId}/annotations`, {
data: { pageNumber: 1, x: 0.1, y: 0.1, width: 0.5, height: 0.1, color: '#00C7B1' }
});
if (!annRes.ok()) throw new Error(`Create annotation failed: ${annRes.status()}`);
const blockRes = await request.post(`/api/documents/${docId}/transcription-blocks`, {
data: {
pageNumber: 1,
x: 0.1,
y: 0.1,
width: 0.5,
height: 0.1,
text: 'Liebe Mutter, viele Grüße vom Mai 1945',
label: null
}
});
if (!blockRes.ok()) throw new Error(`Create block failed: ${blockRes.status()}`);
});
test.afterAll(async ({ request }) => {
if (docId) await request.delete(`/api/documents/${docId}`);
});
test('reader opens the read view with no edit tab or edit controls', async ({ browser }) => {
const context = await browser.newContext({ storageState: { cookies: [], origins: [] } });
const page = await context.newPage();
try {
await login(page, 'reader', 'reader123');
await page.goto(docHref);
// Reader entry control is the read label, not "Transkribieren".
const readButton = page.getByRole('button', { name: /Transkription lesen/i });
await expect(readButton).toBeVisible({ timeout: 5000 });
await readButton.click();
// Read view shows the transcription text.
await expect(page.getByText(/Mai 1945/)).toBeVisible({ timeout: 5000 });
// Header is a plain "Transkription" label, not a Lesen/Bearbeiten toggle.
await expect(page.getByRole('heading', { name: /^Transkription$/i })).toBeVisible();
await expect(page.getByTestId('mode-edit')).toHaveCount(0);
await expect(page.getByTestId('mode-read')).toHaveCount(0);
} finally {
await context.close();
}
});
});

View File

@@ -603,6 +603,8 @@
"doc_details_no_tags": "Keine Schlagwörter zugeordnet",
"doc_details_more_receivers": "+{count} weitere",
"transcription_mode_label": "Transkribieren",
"transcription_read_label": "Transkription lesen",
"transcription_panel_title": "Transkription",
"transcription_mode_stop": "Fertig",
"transcription_block_placeholder": "Text eingeben — mit @Name eine Person aus dem Archiv verknüpfen",
"transcription_block_save_saving": "Speichere...",

View File

@@ -603,6 +603,8 @@
"doc_details_no_tags": "No tags assigned",
"doc_details_more_receivers": "+{count} more",
"transcription_mode_label": "Transcribe",
"transcription_read_label": "Read transcription",
"transcription_panel_title": "Transcription",
"transcription_mode_stop": "Done",
"transcription_block_placeholder": "Type text — use @name to link a person from the archive",
"transcription_block_save_saving": "Saving...",

View File

@@ -603,6 +603,8 @@
"doc_details_no_tags": "No hay etiquetas asignadas",
"doc_details_more_receivers": "+{count} más",
"transcription_mode_label": "Transcribir",
"transcription_read_label": "Leer transcripción",
"transcription_panel_title": "Transcripción",
"transcription_mode_stop": "Listo",
"transcription_block_placeholder": "Escriba el texto — use @nombre para vincular a una persona del archivo",
"transcription_block_save_saving": "Guardando...",

View File

@@ -5,6 +5,7 @@ import { clickOutside } from '$lib/shared/actions/clickOutside';
type Props = {
canWrite: boolean;
isPdf: boolean;
hasTranscription: boolean;
transcribeMode: boolean;
filePath?: string | null;
originalFilename?: string | null;
@@ -14,12 +15,18 @@ type Props = {
let {
canWrite,
isPdf,
hasTranscription,
transcribeMode = $bindable(),
filePath = null,
originalFilename = null,
fileUrl
}: Props = $props();
const canOpenTranscription = $derived((canWrite || hasTranscription) && isPdf);
const transcriptionLabel = $derived(
canWrite ? m.transcription_mode_label() : m.transcription_read_label()
);
let mobileMenuOpen = $state(false);
function startTranscribe() {
@@ -50,10 +57,10 @@ function startTranscribe() {
role="menu"
class="absolute top-full right-0 z-50 mt-1 min-w-[200px] rounded-md border border-line bg-surface p-2 shadow-lg"
>
{#if canWrite && isPdf && !transcribeMode}
{#if canOpenTranscription && !transcribeMode}
<button
onclick={startTranscribe}
aria-label={m.transcription_mode_label()}
aria-label={transcriptionLabel}
aria-pressed={false}
class="flex w-full items-center gap-2 rounded px-3 py-2 text-left text-[16px] text-ink transition hover:bg-muted focus-visible:ring-2 focus-visible:ring-primary"
>
@@ -70,7 +77,7 @@ function startTranscribe() {
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
/>
</svg>
{m.transcription_mode_label()}
{transcriptionLabel}
</button>
{/if}

View File

@@ -8,6 +8,7 @@ afterEach(cleanup);
const baseProps = {
canWrite: false,
isPdf: false,
hasTranscription: false,
transcribeMode: false,
filePath: null as string | null,
originalFilename: 'brief.pdf' as string | null,
@@ -49,6 +50,43 @@ describe('DocumentMobileMenu', () => {
await expect.element(page.getByRole('button', { name: /transkribieren/i })).toBeVisible();
});
it('shows no transcription action for a read-only user when no transcription exists', async () => {
render(DocumentMobileMenu, {
props: {
...baseProps,
canWrite: false,
isPdf: true,
hasTranscription: false,
filePath: 'docs/x.pdf'
}
});
await page.getByRole('button', { name: /weitere aktionen/i }).click();
await expect
.element(page.getByRole('button', { name: /transkribieren/i }))
.not.toBeInTheDocument();
await expect
.element(page.getByRole('button', { name: /transkription lesen/i }))
.not.toBeInTheDocument();
});
it('shows the read-transcription action for a read-only user when a transcription exists', async () => {
render(DocumentMobileMenu, {
props: {
...baseProps,
canWrite: false,
isPdf: true,
hasTranscription: true,
filePath: 'docs/x.pdf'
}
});
await page.getByRole('button', { name: /weitere aktionen/i }).click();
await expect.element(page.getByRole('button', { name: /transkription lesen/i })).toBeVisible();
});
it('hides the transcribe action when already in transcribeMode', async () => {
render(DocumentMobileMenu, {
props: {

View File

@@ -28,6 +28,7 @@ type Doc = {
location?: string | null;
status?: string | null;
tags?: Tag[] | null;
hasTranscription?: boolean;
};
type GeschichteSummary = {
@@ -132,6 +133,7 @@ const overflowPersons = $derived(receivers.slice(2));
documentId={doc.id}
canWrite={canWrite}
isPdf={!!isPdf}
hasTranscription={!!doc.hasTranscription}
bind:transcribeMode={transcribeMode}
filePath={doc.filePath}
originalFilename={doc.originalFilename}
@@ -143,6 +145,7 @@ const overflowPersons = $derived(receivers.slice(2));
<DocumentMobileMenu
canWrite={canWrite}
isPdf={!!isPdf}
hasTranscription={!!doc.hasTranscription}
bind:transcribeMode={transcribeMode}
filePath={doc.filePath}
originalFilename={doc.originalFilename}

View File

@@ -5,6 +5,7 @@ type Props = {
documentId: string;
canWrite: boolean;
isPdf: boolean;
hasTranscription: boolean;
transcribeMode: boolean;
filePath?: string | null;
originalFilename?: string | null;
@@ -15,17 +16,23 @@ let {
documentId,
canWrite,
isPdf,
hasTranscription,
transcribeMode = $bindable(),
filePath = null,
originalFilename = null,
fileUrl
}: Props = $props();
const canOpenTranscription = $derived((canWrite || hasTranscription) && isPdf);
const transcriptionLabel = $derived(
canWrite ? m.transcription_mode_label() : m.transcription_read_label()
);
</script>
{#if canWrite && isPdf && !transcribeMode}
{#if canOpenTranscription && !transcribeMode}
<button
onclick={() => (transcribeMode = true)}
aria-label={m.transcription_mode_label()}
aria-label={transcriptionLabel}
aria-pressed={false}
class="hidden items-center gap-1.5 rounded border border-primary px-3 py-1.5 font-sans text-[16px] font-medium text-ink transition hover:bg-primary hover:text-primary-fg focus-visible:ring-2 focus-visible:ring-primary md:flex"
>
@@ -42,7 +49,7 @@ let {
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
/>
</svg>
{m.transcription_mode_label()}
{transcriptionLabel}
</button>
{/if}

View File

@@ -9,6 +9,7 @@ const baseProps = {
documentId: 'd1',
canWrite: false,
isPdf: false,
hasTranscription: false,
transcribeMode: false,
filePath: null as string | null,
originalFilename: 'brief.pdf' as string | null,
@@ -34,6 +35,56 @@ describe('DocumentTopBarActions', () => {
await expect.element(page.getByRole('button', { name: /transkribieren/i })).toBeVisible();
});
it('shows no transcription control for a read-only user when no transcription exists', async () => {
render(DocumentTopBarActions, {
props: {
...baseProps,
canWrite: false,
isPdf: true,
hasTranscription: false,
filePath: 'docs/x.pdf'
}
});
await expect
.element(page.getByRole('button', { name: /transkribieren/i }))
.not.toBeInTheDocument();
await expect
.element(page.getByRole('button', { name: /transkription lesen/i }))
.not.toBeInTheDocument();
});
it('shows the read-transcription button for a read-only user when a transcription exists', async () => {
render(DocumentTopBarActions, {
props: {
...baseProps,
canWrite: false,
isPdf: true,
hasTranscription: true,
filePath: 'docs/x.pdf'
}
});
await expect.element(page.getByRole('button', { name: /transkription lesen/i })).toBeVisible();
await expect
.element(page.getByRole('button', { name: /^transkribieren$/i }))
.not.toBeInTheDocument();
});
it('shows the transcribe (edit) label for a writer regardless of hasTranscription', async () => {
render(DocumentTopBarActions, {
props: {
...baseProps,
canWrite: true,
isPdf: true,
hasTranscription: false,
filePath: 'docs/x.pdf'
}
});
await expect.element(page.getByRole('button', { name: /transkribieren/i })).toBeVisible();
});
it('omits the transcribe button when not a PDF', async () => {
render(DocumentTopBarActions, {
props: { ...baseProps, canWrite: true, isPdf: false, filePath: 'docs/x.jpg' }

View File

@@ -17,6 +17,7 @@ type Props = {
isLoading: boolean;
error: string;
transcribeMode?: boolean;
canAnnotate?: boolean;
blockNumbers?: Record<string, number>;
annotationReloadKey?: number;
activeAnnotationId: string | null;
@@ -33,6 +34,7 @@ let {
isLoading,
error,
transcribeMode = false,
canAnnotate = false,
blockNumbers = {},
annotationReloadKey = 0,
activeAnnotationId = $bindable(),
@@ -93,6 +95,7 @@ let {
url={fileUrl}
documentId={doc.id}
transcribeMode={transcribeMode}
canAnnotate={canAnnotate}
blockNumbers={blockNumbers}
annotationReloadKey={annotationReloadKey}
bind:activeAnnotationId={activeAnnotationId}

View File

@@ -8,11 +8,20 @@ type Props = {
hasBlocks: boolean;
blockCount: number;
lastEditedAt: string | null;
canEdit?: boolean;
onModeChange: (mode: 'read' | 'edit') => void;
onClose: () => void;
};
let { mode, hasBlocks, blockCount, lastEditedAt, onModeChange, onClose }: Props = $props();
let {
mode,
hasBlocks,
blockCount,
lastEditedAt,
canEdit = true,
onModeChange,
onClose
}: Props = $props();
const formattedDate = $derived(
lastEditedAt
@@ -34,37 +43,41 @@ function handleReadClick() {
<div
class="flex h-[44px] items-center justify-between border-b border-line bg-surface px-3 font-sans"
>
<!-- Segmented toggle + help chip -->
<div class="flex items-center gap-1.5">
<div class="flex h-9 items-center rounded-full border border-line bg-muted p-0.5 md:h-7">
<button
type="button"
data-testid="mode-read"
aria-disabled={!hasBlocks}
onclick={handleReadClick}
class="h-full rounded-full px-3 text-xs font-semibold transition-colors {mode === 'read'
? 'bg-primary text-primary-fg'
: 'text-ink-2 hover:text-ink'}"
style:opacity={!hasBlocks ? '0.35' : undefined}
>
{m.mode_read()}
</button>
<button
type="button"
data-testid="mode-edit"
onclick={() => onModeChange('edit')}
class="h-full rounded-full px-3 text-xs font-semibold transition-colors {mode === 'edit'
? 'bg-primary text-primary-fg'
: 'text-ink-2 hover:text-ink'}"
>
<span class="md:hidden">{m.mode_edit_short()}</span>
<span class="hidden md:inline">{m.mode_edit()}</span>
</button>
<!-- Segmented toggle + help chip for editors; plain title for read-only users -->
{#if canEdit}
<div class="flex items-center gap-1.5">
<div class="flex h-9 items-center rounded-full border border-line bg-muted p-0.5 md:h-7">
<button
type="button"
data-testid="mode-read"
aria-disabled={!hasBlocks}
onclick={handleReadClick}
class="h-full rounded-full px-3 text-xs font-semibold transition-colors {mode === 'read'
? 'bg-primary text-primary-fg'
: 'text-ink-2 hover:text-ink'}"
style:opacity={!hasBlocks ? '0.35' : undefined}
>
{m.mode_read()}
</button>
<button
type="button"
data-testid="mode-edit"
onclick={() => onModeChange('edit')}
class="h-full rounded-full px-3 text-xs font-semibold transition-colors {mode === 'edit'
? 'bg-primary text-primary-fg'
: 'text-ink-2 hover:text-ink'}"
>
<span class="md:hidden">{m.mode_edit_short()}</span>
<span class="hidden md:inline">{m.mode_edit()}</span>
</button>
</div>
<HelpPopover label={m.transcription_mode_help_label()}>
<p class="text-xs leading-relaxed">{m.transcription_mode_help_body()}</p>
</HelpPopover>
</div>
<HelpPopover label={m.transcription_mode_help_label()}>
<p class="text-xs leading-relaxed">{m.transcription_mode_help_body()}</p>
</HelpPopover>
</div>
{:else}
<h2 class="text-[16px] font-semibold text-ink">{m.transcription_panel_title()}</h2>
{/if}
<!-- Status line (hidden on mobile to save space) -->
<p class="hidden text-xs text-ink-2 md:block">

View File

@@ -22,6 +22,27 @@ describe('TranscriptionPanelHeader', () => {
await expect.element(page.getByRole('button', { name: /bearbeiten/i })).toBeVisible();
});
it('renders both tabs when canEdit is true', async () => {
render(TranscriptionPanelHeader, { ...baseProps, canEdit: true });
await expect.element(page.getByTestId('mode-read')).toBeInTheDocument();
await expect.element(page.getByTestId('mode-edit')).toBeInTheDocument();
});
it('hides the edit tab and shows a plain title when canEdit is false', async () => {
render(TranscriptionPanelHeader, { ...baseProps, canEdit: false });
await expect.element(page.getByTestId('mode-edit')).not.toBeInTheDocument();
await expect.element(page.getByTestId('mode-read')).not.toBeInTheDocument();
await expect.element(page.getByRole('heading', { name: /^transkription$/i })).toBeVisible();
});
it('keeps the section status line visible for readers (canEdit false)', async () => {
render(TranscriptionPanelHeader, { ...baseProps, canEdit: false, blockCount: 3 });
await expect.element(page.getByText('3 Abschnitte')).toBeVisible();
});
it('marks the Lesen button as aria-disabled when hasBlocks is false', async () => {
render(TranscriptionPanelHeader, {
...baseProps,

View File

@@ -14,6 +14,7 @@ let {
url,
documentId = '',
transcribeMode = false,
canAnnotate = false,
blockNumbers = {},
annotationReloadKey = 0,
activeAnnotationId = $bindable<string | null>(null),
@@ -28,6 +29,7 @@ let {
url: string;
documentId?: string;
transcribeMode?: boolean;
canAnnotate?: boolean;
blockNumbers?: Record<string, number>;
annotationReloadKey?: number;
activeAnnotationId?: string | null;
@@ -262,7 +264,7 @@ function handleAnnotationClick(id: string) {
annotations={visibleAnnotations.filter(
(a) => a.pageNumber === renderer.currentPage
)}
canDraw={transcribeMode}
canDraw={transcribeMode && canAnnotate}
color={TRANSCRIPTION_COLOR}
blockNumbers={blockNumbers}
activeAnnotationId={activeAnnotationId}

View File

@@ -86,6 +86,38 @@ describe('PdfViewer — loaded state', () => {
}
});
it('makes the annotation surface drawable (crosshair) when transcribeMode and canAnnotate', async () => {
render(PdfViewer, {
url: '/api/documents/test/file',
documentId: 'test',
transcribeMode: true,
canAnnotate: true,
libLoader: makeFakeLibLoader()
});
await vi.waitFor(() => {
const surface = document.querySelector('[role="presentation"]');
expect(surface).not.toBeNull();
expect(surface?.getAttribute('style') ?? '').toContain('crosshair');
});
});
it('does not make the annotation surface drawable when canAnnotate is false (read-only user)', async () => {
render(PdfViewer, {
url: '/api/documents/test/file',
documentId: 'test',
transcribeMode: true,
canAnnotate: false,
libLoader: makeFakeLibLoader()
});
await vi.waitFor(() => {
expect(document.querySelector('.bg-pdf-bg')).not.toBeNull();
});
const surface = document.querySelector('[role="presentation"]');
expect(surface?.getAttribute('style') ?? '').not.toContain('crosshair');
});
it('renders the canvas region when documentFileHash is provided', async () => {
render(PdfViewer, {
url: '/api/documents/test/file',

View File

@@ -1758,6 +1758,7 @@ export interface components {
sender?: components["schemas"]["Person"];
tags?: components["schemas"]["Tag"][];
trainingLabels?: ("KURRENT_RECOGNITION" | "KURRENT_SEGMENTATION")[];
hasTranscription: boolean;
thumbnailUrl?: string;
};
PersonMention: {

View File

@@ -71,7 +71,7 @@ const ocrJob = createOcrJob({
onJobFinished: async () => {
await transcription.load();
transcription.bumpAnnotationReloadKey();
panelMode = transcription.hasBlocks ? 'read' : 'edit';
panelMode = canWrite && !transcription.hasBlocks ? 'edit' : 'read';
}
});
@@ -148,10 +148,10 @@ $effect(() => {
if (skipInitialPanelMode) {
skipInitialPanelMode = false;
} else {
panelMode = transcription.hasBlocks ? 'read' : 'edit';
panelMode = canWrite && !transcription.hasBlocks ? 'edit' : 'read';
}
});
ocrJob.checkStatus();
if (canWrite) ocrJob.checkStatus();
}
});
@@ -252,6 +252,7 @@ onMount(() => {
isLoading={fileLoader.isLoading}
error={fileLoader.fileError}
transcribeMode={transcribeMode && !ocrJob.running}
canAnnotate={canWrite}
blockNumbers={transcription.blockNumbers}
annotationReloadKey={transcription.annotationReloadKey}
annotationsDimmed={transcribeMode && panelMode === 'read'}
@@ -293,7 +294,10 @@ onMount(() => {
hasBlocks={transcription.hasBlocks}
blockCount={transcription.blocks.length}
lastEditedAt={transcription.lastEditedAt}
onModeChange={(newMode) => (panelMode = newMode)}
canEdit={canWrite}
onModeChange={(newMode) => {
if (canWrite) panelMode = newMode;
}}
onClose={() => (transcribeMode = false)}
/>
<div class="flex-1 overflow-y-auto">