fix(ui): hide write/edit controls from READ_ALL (read-only) users (#696) #699
Reference in New Issue
Block a user
Delete Branch "feat/issue-696-hide-write-controls"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #696.
What
Closes the one confirmed write-control leak in the global chrome: the header "Hochladen" (upload) button was gated only on
{#if data?.user}, so a reader withoutWRITE_ALLsaw it, clicked it, and got bounced by the server-side redirect indocuments/new— confusing friction on the main read journey.Changes
c3652f5b+layout.sveltegate changed to{#if data?.user && data.canWrite}(canWritealready on layout data). Red-first via acanWrite: false→ no-upload-link test.97274bebANNOTATE_ALL-only user (canWrite: false) still sees no upload link, proving the gate keys on lack ofWRITE_ALL, not on beingREAD_ALL.5edefdd0@WithMockUser(authorities = "READ_ALL")403 boundary tests forPOST /api/documentsandPOST /api/documents/quick-upload— the control that actually matters.Verification
layout.svelte.spec.ts(client project): 11 passed — new test confirmed red before the gate, green after.DocumentControllerTest: 104 passed (102 + 2 new READ_ALL boundary tests).svelte-check: no errors in the touched files. (32 repo-wide errors are pre-existing onmaininusers/[id]/admin/users, untouched here.)Scope
No backend model change, no i18n change, no API regeneration (
upload_actionalready exists), no doc updates (no new route/permission/ErrorCode).DropZone.svelteleft without an internal guard per the issue's defensive note.canAnnotaterewiring and read-only transcription (#697) are out of scope.🤖 Generated with Claude Code
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 <noreply@anthropic.com>🛡️ Nora "NullX" Steiner — Application Security Engineer
Verdict: ✅ Approved
This is exactly the shape a UI-hardening fix should take: the visual gate is cosmetic, and the real control (server-side 403) is the part that gets the explicit regression tests. The PR understands that hiding a button is not a security boundary — denying the request is.
What I checked
+layout.sveltenow reads{#if data?.user && data.canWrite}, andcanWriteis derived server-side in+layout.server.tsfrom the user'sWRITE_ALLpermission — not client-trusted state. Hiding the link removes UI friction; it does not become the access control.DocumentControllerTestcases assert@WithMockUser(authorities = "READ_ALL")→ 403 forPOST /api/documentsandPOST /api/documents/quick-upload. This is precisely the "test that the unauthorized user is blocked, not just that the authorized one succeeds" pattern. CWE-862 (Missing Authorization) regression coverage — good.@RequestMappingwithout a method, no permission magic strings, no error-message leakage. The change is purely subtractive on the client and additive on the test side.Notes (not blockers)
isForbiddentest above each insertion). If it is not, I'd want aPOSTwith no auth → 401 alongside these — but that's a pre-existing-coverage question, not something this PR regressed.DropZone.svelteis intentionally left without an internal guard per the issue. I'm comfortable with that: the server rejects the upload regardless, so the component guard would be redundant belt-and-suspenders, not a missing control.Solid, minimal, well-tested. No security objections.
👨💻 Felix Brandt — Senior Fullstack Developer
Verdict: ✅ Approved
Clean, disciplined, TDD-shaped change. The fix is a one-token condition tweak, and the tests precede and justify it. Nothing here violates the project's clean-code or Svelte 5 rules.
TDD evidence — good
canWrite: false→ no-upload-link test asserted red before the gate). The two READ_ALL backend tests are pure boundary additions. This is the right red/green order.Frontend (
+layout.svelte){#if data?.user}to{#if data?.user && data.canWrite}. The condition is short and reads as intent. Borderline on my own "no business logic in template markup" rule — but two flags with self-documenting names is fine; extracting a$derived const canUpload = data?.user && data.canWritewould be marginally cleaner and consistent with howisAdmin/isAuthPageare already$derivedin this same file. Suggestion, not a blocker — if you touch this again, derive it.data, andcanWriteis a real layout-data field, so no new coupling.Tests (
layout.svelte.spec.ts)makeData({ canWrite: false })factory with overrides — exactly the factory pattern I want. Names read as sentences. The second test ("ANNOTATE_ALL-only … gate is lack of WRITE_ALL, not READ_ALL") is a genuinely valuable breadth guard, not a duplicate — it pins the reason the gate fires.getByRole('link')….not.toBeInTheDocument()), not internals.Backend (
DocumentControllerTest.java)should-less naming (createDocument_returns403_forReaderOnly) matches the existing convention in this file, so consistency wins over my personalshould_preference..with(csrf())is correctly included so the request reaches the authorization layer rather than failing CSRF first. Good.No blockers.
🧪 Sara Holt — Senior QA Engineer
Verdict: ✅ Approved
The test strategy here is textbook: the behavior is verified at the layer where it lives. UI visibility → component test; authorization → controller slice test. No test pushed up the pyramid unnecessarily, no E2E bloat for a one-line gate.
Coverage assessment
vitest-browser-sveltespecs cover the negative cases (canWrite: false, andANNOTATE_ALL-only). Combined with the pre-existing positive specs ("navigates to /documents/new", "has aria-label"), the upload-link visibility matrix is now: write-user sees it / reader doesn't / annotator-only doesn't. That's the full truth table that matters.@WithMockUser(authorities = "READ_ALL")→isForbidden()for both upload endpoints. This is the permission-boundary test I always ask for — proving the endpoint rejects the unauthorized role, not just that it accepts the authorized one. Sits next to existingWRITE_ALL → 200tests, so each endpoint now has matched accept/reject coverage.Determinism / hygiene
makeDatafactory and the establishedbeforeEach/afterEach(fetch stub +cleanup()+unstubAllGlobals()), so no new flakiness vector is introduced. The fetch stub that prevents the/loginredirect race is already in place and the new tests inherit it..with(csrf()), so they exercise the authorization filter rather than short-circuiting on CSRF — correct and deterministic.Minor notes (not blockers)
loading the layout getscanWrite: falsewould pin the server derivation — but the controller 403 tests already cover the security-critical half, so this is optional polish.Quality gate: pass.
🏛️ Markus Keller — Application Architect
Verdict: ✅ Approved
No architectural concerns. This is a leaf-level UI gate plus test coverage — it touches no module boundary, no layer rule, no schema, and no transport choice.
Boundaries & layering
data.canWrite, which is already derived in+layout.server.tsfrom the session user's permissions. The component consumes a server-computed flag rather than re-deriving permission logic in the template — that's the correct direction of dependency. No business/authorization logic leaked into the view beyond a boolean read.@RequirePermissionon the controller, exercised by the new 403 tests). UI gate and server gate are layered correctly: cosmetic in the client, enforced in the backend. This is exactly how application-layer access control should be split.Documentation currency
I ran the PR against my doc-trigger checklist:
ErrorCodeorPermissionvalue (WRITE_ALLandcanWritealready exist). ✅seq-document-upload.pumlandseq-auth-flow.pumldiagrams describe the request/response sequence, which is unchanged. No diagram update required.No ADR warranted — this is a bug fix correcting an entry-point that leaked past a
canWriteflag, not a lasting structural decision.Boring, correct, minimal. Approved.
🎨 Leonie Voss — UX & Accessibility Lead
Verdict: ✅ Approved
This is a real UX win, and it's the right fix from a user-journey standpoint. A reader without
WRITE_ALLpreviously saw a "Hochladen" button, clicked it, and got bounced by a server redirect — that's a dead-end interaction (Nielsen #5, Error Prevention, and #1, Visibility of System Status). Removing an affordance the user can't act on is better than letting them hit a wall.Accessibility / brand checks
aria-label={m.upload_action()},aria-hidden="true"on the decorative SVG, and the i18n label span. The change only gates whether it renders — it doesn't degrade the control's accessibility when it does render. No regression there.One thing I'd flag for the broader effort (not a blocker here)
The PR scopes itself to the header upload button only and explicitly defers the
documents/[id]/editcontrols and read-only transcription (#697). That's a reasonable slice. But from a UX-consistency view, a read-only user will now have a coherent header (no upload CTA) yet may still encounter edit/save affordances deeper in the app that also dead-end. That's a known follow-up per the issue, so I'm not holding this PR for it — just noting the journey isn't fully consistent until #697 lands. Worth keeping the follow-up visible so readers don't hit the same dead-end one level down.For what it changes: clean, accessible, and it removes friction on the primary read path. Approved.
⚙️ Tobias Wendt — DevOps & Platform Engineer
Verdict: ✅ Approved (LGTM)
Nothing in my domain to flag. I checked for the things I always check:
docker-compose*.ymlor documented inDEPLOYMENT.md.canWriteis computed at runtime from session data — zero deployment config.@WebMvcTest-style slices (per the surrounding file), so they don't spin up Testcontainers/MinIO — no added CI resource pressure.No reproducibility, secrets, or pipeline concerns. Ship it.
📋 Elicit — Requirements Engineer & Business Analyst
Verdict: ⚠️ Approved with one traceability concern
The change is well-scoped and the implementation matches the stated intent. My lens is requirements completeness and traceability, and there's a small gap between the issue title and what this PR delivers.
What's good
WRITE_ALLsaw the upload button, clicked it, and got bounced" — a real friction point on the primary read journey. The fix is testable and tested (component visibility + 403 boundary). Definition of Done is effectively met for the header upload control.canAnnotaterewiring, read-only transcription #697). Naming non-goals is exactly the scope-creep discipline I want to see.The concern — title vs. delivered scope (traceability)
The PR title is "hide write/edit controls from READ_ALL (read-only) users (#696)" — plural "controls", and it names edit explicitly. What actually ships is a single control: the header upload button. Edit controls (
documents/[id]/edit, save/delete affordances) are deferred.This is an ambiguity/traceability flag, not a code problem:
Recommendation (not a blocker for the code): Either (a) narrow this PR's framing to "header upload button" and keep #696 open for the remaining edit controls, or (b) confirm #696's acceptance criteria were explicitly scoped down to just this control and the rest tracked under #697. Make sure the "done" state of #696 is unambiguous — right now the title promises more than the diff delivers.
NFR checklist (quick pass)
The implementation is sound; my only ask is that the backlog's "done" reflects reality. Resolve the title-vs-scope question and this is a clean close.
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 <noreply@anthropic.com>