From c3652f5b5723ce40494574a10940e5bfaa897580 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 31 May 2026 11:07:35 +0200 Subject: [PATCH 1/4] fix(ui): hide header upload button from non-writers (#696) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The header "Hochladen" link was gated only on {#if data?.user}, so a reader without WRITE_ALL saw it, clicked it, and got bounced by the server-side redirect in documents/new — confusing friction on the main read journey. Gate it on data.canWrite (already on the layout data). Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/routes/+layout.svelte | 2 +- frontend/src/routes/layout.svelte.spec.ts | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 2703ff9c..9dd0d37c 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -75,7 +75,7 @@ const userInitials = $derived.by(() => {
- {#if data?.user} + {#if data?.user && data.canWrite} { const link = page.getByRole('link', { name: /Hochladen|Upload|Subir/i }); await expect.element(link).toHaveAttribute('href', '/documents/new'); }); + + it('is hidden for a user without WRITE_ALL', async () => { + render(Layout, { data: makeData({ canWrite: false }), children: emptySnippet }); + await expect + .element(page.getByRole('link', { name: /Hochladen|Upload|Subir/i })) + .not.toBeInTheDocument(); + }); }); // ─── Dropdown ───────────────────────────────────────────────────────────────── -- 2.49.1 From 97274beba003f4be2ceba2257c5e4656e1796d08 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 31 May 2026 11:08:51 +0200 Subject: [PATCH 2/4] test(layout): lock upload-button gate against ANNOTATE_ALL-only users (#696) Documents that the gate keys on lack of WRITE_ALL, not on being READ_ALL: an ANNOTATE_ALL-only user (canWrite=false) must still not see the upload link. The writer-sees-it contract is already covered by the existing upload-link tests. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/routes/layout.svelte.spec.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/frontend/src/routes/layout.svelte.spec.ts b/frontend/src/routes/layout.svelte.spec.ts index dc8c50d2..e0594d1f 100644 --- a/frontend/src/routes/layout.svelte.spec.ts +++ b/frontend/src/routes/layout.svelte.spec.ts @@ -90,6 +90,16 @@ describe('Layout – upload link', () => { .element(page.getByRole('link', { name: /Hochladen|Upload|Subir/i })) .not.toBeInTheDocument(); }); + + it('is hidden for an ANNOTATE_ALL-only user (gate is lack of WRITE_ALL, not READ_ALL)', async () => { + render(Layout, { + data: makeData({ canWrite: false, canAnnotate: true }), + children: emptySnippet + }); + await expect + .element(page.getByRole('link', { name: /Hochladen|Upload|Subir/i })) + .not.toBeInTheDocument(); + }); }); // ─── Dropdown ───────────────────────────────────────────────────────────────── -- 2.49.1 From 5edefdd0827def47cbfbfcd05db90597110f6b70 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 31 May 2026 11:11:17 +0200 Subject: [PATCH 3/4] test(document): document READ_ALL -> 403 on document write endpoints (#696) Hiding the header upload button is UI polish; the real control is endpoint authz. Add explicit READ_ALL-only 403 boundary tests for POST /api/documents and POST /api/documents/quick-upload, matching the reader-only convention already used elsewhere in this suite. Co-Authored-By: Claude Sonnet 4.6 --- .../document/DocumentControllerTest.java | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentControllerTest.java index 7c9b28a1..8a09ed73 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentControllerTest.java @@ -297,6 +297,13 @@ class DocumentControllerTest { .andExpect(status().isForbidden()); } + @Test + @WithMockUser(authorities = "READ_ALL") + void createDocument_returns403_forReaderOnly() throws Exception { + mockMvc.perform(multipart("/api/documents").with(csrf())) + .andExpect(status().isForbidden()); + } + @Test @WithMockUser(authorities = "WRITE_ALL") void createDocument_returns200_whenHasWritePermission() throws Exception { @@ -414,6 +421,13 @@ class DocumentControllerTest { .andExpect(status().isForbidden()); } + @Test + @WithMockUser(authorities = "READ_ALL") + void quickUpload_returns403_forReaderOnly() throws Exception { + mockMvc.perform(multipart("/api/documents/quick-upload").with(csrf())) + .andExpect(status().isForbidden()); + } + @Test @WithMockUser(authorities = "WRITE_ALL") void quickUpload_returns200_withValidPdfFile() throws Exception { -- 2.49.1 From 944370dcfd4b1ac4db14abf47b62cd7113217ebc Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 31 May 2026 11:22:09 +0200 Subject: [PATCH 4/4] refactor(layout): extract canUpload derived for the upload-button gate (#696) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the inline {#if data?.user && data.canWrite} condition into a named $derived, matching the existing isAdmin / isAuthPage derivations in the same file. No behaviour change — the 11 layout specs stay green. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/routes/+layout.svelte | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 9dd0d37c..136eb060 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -43,6 +43,8 @@ const isAdmin = $derived( data?.user?.groups?.some((g: { permissions: string[] }) => g.permissions.includes('ADMIN')) ); +const canUpload = $derived(Boolean(data?.user && data.canWrite)); + // Set after client-side hydration completes. Used by E2E tests to know the // page is interactive (event handlers registered) before they interact with it. let hydrated = $state(false); @@ -75,7 +77,7 @@ const userInitials = $derived.by(() => {
- {#if data?.user && data.canWrite} + {#if canUpload}