feat: Admin section redesign (Concept C) + Persons redesign (Concept A) #160

Merged
marcel merged 11 commits from feat/persons-redesign-concept-a into main 2026-03-30 18:30:25 +02:00
Owner

Summary

Closes #156 · Closes #157

Admin section redesign — Concept C (Master-Detail Command Center)

Replaces the tab-based /admin layout with a three-panel master-detail shell across all four entity types: Users, Groups, Tags, and System.

Layout & navigation

  • Three-panel shell: Entity Nav (120px) · List Panel (200px, collapsible) · Detail Panel (flex: 1)
  • SvelteKit sub-routes (/admin/users, /admin/groups, /admin/tags, /admin/system) replace the old single-page tab model
  • Mobile: list panel hides when a detail route is active (full-screen step flow)

Responsive

  • Tablet (md): Entity Nav collapses to 48px icon strip; tapping any icon opens a 160px flyout overlay (role="dialog", aria-modal, focus-accessible)
  • List panels collapse to a 32px handle via a ‹ button; state persisted to localStorage
  • Auto-collapse when navigating to +New routes

Per-entity detail panels

  • Users: edit Personal Info / Groups / Change Password sections; delete with confirm dialog
  • Groups: name field + permission matrix (Standard: Nur lesen / Lesen & Annotieren / Lesen & Schreiben; Administrative: amber-bordered box)
  • Tags: rename field + type-to-confirm delete showing document count
  • System: three maintenance cards (Backfill versions, Backfill file hashes, Mass import)

Mass import card (new)

  • Triggers POST /api/admin/trigger-import
  • Polls GET /api/admin/import-status every 2 s while RUNNING, stops on DONE/FAILED
  • Four distinct states: IDLE · RUNNING · DONE (count) · FAILED (error message + retry)

UX hardening

  • Unsaved-changes guard (beforeNavigate) on all detail panel forms — inline amber warning, no modal
  • WCAG fixes: ADMIN badge uses checkbox + label (not color alone); tag actions always visible (no hover-only opacity)
  • Empty-state messaging for all list panels

New permissions in Groups

  • Added READ_ALL ("Nur lesen") and ANNOTATE_ALL ("Lesen & Annotieren") to the Standard permissions section, ordered by access level

Persons redesign — Concept A (Enriched Directory)

Backend

  • @RequirePermission(WRITE_ALL) on all write endpoints
  • @Size constraints on PersonUpdateDTO + @Valid on controller
  • Year bounds validation (> 0) in PersonService
  • DomainException replaces ResponseStatusException throughout
  • POST /api/persons now accepts full PersonUpdateDTO (birthYear, deathYear, notes)
  • PersonSummaryDTO with native-query document count (N+1 fix)
  • GET /api/stats endpoint (StatsController)

Frontend

  • /persons list: stats bar, life date range, document count chip, empty state
  • /persons/new: birthYear, deathYear, notes fields
  • /persons/[id]: read-only 2-column layout, Edit button → /persons/[id]/edit
  • /persons/[id]/edit: full edit form, sticky save bar, DangerZone accordion with merge panel

Test plan

  • npm run test passes (frontend — 399/404; 5 pre-existing failures in DashboardMentions and Persons page unrelated to this PR)
  • ./mvnw test passes (backend)
  • Admin layout renders correctly at mobile / tablet / desktop
  • Flyout opens and closes on tablet; Escape and backdrop click both work
  • List panel collapse persists across page navigations
  • Mass import card: trigger → RUNNING → DONE flow works end-to-end
  • Groups permission matrix: READ_ALL and ANNOTATE_ALL save and reload correctly
  • Unsaved-changes guard blocks navigation on dirty forms

🤖 Generated with Claude Code

## Summary Closes #156 · Closes #157 ### Admin section redesign — Concept C (Master-Detail Command Center) Replaces the tab-based `/admin` layout with a three-panel master-detail shell across all four entity types: Users, Groups, Tags, and System. **Layout & navigation** - Three-panel shell: Entity Nav (120px) · List Panel (200px, collapsible) · Detail Panel (flex: 1) - SvelteKit sub-routes (`/admin/users`, `/admin/groups`, `/admin/tags`, `/admin/system`) replace the old single-page tab model - Mobile: list panel hides when a detail route is active (full-screen step flow) **Responsive** - Tablet (md): Entity Nav collapses to 48px icon strip; tapping any icon opens a 160px flyout overlay (`role="dialog"`, `aria-modal`, focus-accessible) - List panels collapse to a 32px handle via a ‹ button; state persisted to `localStorage` - Auto-collapse when navigating to `+New` routes **Per-entity detail panels** - Users: edit Personal Info / Groups / Change Password sections; delete with confirm dialog - Groups: name field + permission matrix (Standard: Nur lesen / Lesen & Annotieren / Lesen & Schreiben; Administrative: amber-bordered box) - Tags: rename field + type-to-confirm delete showing document count - System: three maintenance cards (Backfill versions, Backfill file hashes, **Mass import**) **Mass import card (new)** - Triggers `POST /api/admin/trigger-import` - Polls `GET /api/admin/import-status` every 2 s while RUNNING, stops on DONE/FAILED - Four distinct states: IDLE · RUNNING · DONE (count) · FAILED (error message + retry) **UX hardening** - Unsaved-changes guard (`beforeNavigate`) on all detail panel forms — inline amber warning, no modal - WCAG fixes: ADMIN badge uses checkbox + label (not color alone); tag actions always visible (no hover-only opacity) - Empty-state messaging for all list panels **New permissions in Groups** - Added `READ_ALL` ("Nur lesen") and `ANNOTATE_ALL` ("Lesen & Annotieren") to the Standard permissions section, ordered by access level --- ### Persons redesign — Concept A (Enriched Directory) **Backend** - `@RequirePermission(WRITE_ALL)` on all write endpoints - `@Size` constraints on `PersonUpdateDTO` + `@Valid` on controller - Year bounds validation (`> 0`) in `PersonService` - `DomainException` replaces `ResponseStatusException` throughout - `POST /api/persons` now accepts full `PersonUpdateDTO` (birthYear, deathYear, notes) - `PersonSummaryDTO` with native-query document count (N+1 fix) - `GET /api/stats` endpoint (`StatsController`) **Frontend** - `/persons` list: stats bar, life date range, document count chip, empty state - `/persons/new`: birthYear, deathYear, notes fields - `/persons/[id]`: read-only 2-column layout, Edit button → `/persons/[id]/edit` - `/persons/[id]/edit`: full edit form, sticky save bar, DangerZone accordion with merge panel ## Test plan - [ ] `npm run test` passes (frontend — 399/404; 5 pre-existing failures in DashboardMentions and Persons page unrelated to this PR) - [ ] `./mvnw test` passes (backend) - [ ] Admin layout renders correctly at mobile / tablet / desktop - [ ] Flyout opens and closes on tablet; Escape and backdrop click both work - [ ] List panel collapse persists across page navigations - [ ] Mass import card: trigger → RUNNING → DONE flow works end-to-end - [ ] Groups permission matrix: READ_ALL and ANNOTATE_ALL save and reload correctly - [ ] Unsaved-changes guard blocks navigation on dirty forms 🤖 Generated with [Claude Code](https://claude.com/claude-code)
marcel added 11 commits 2026-03-30 10:25:52 +02:00
Fixes IDOR: the endpoint was publicly accessible to any authenticated user.
Now requires ADMIN_USER permission, matching all other user management endpoints.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- +layout.server.ts: auth guard (throws 403 for non-admin) with granular
  permission flags and entity counts for EntityNav
- GroupsTab: add ⚙ prefix to ADMIN badge (WCAG 1.4.1, non-color indicator)
- TagsTab: remove opacity-0 from action buttons (hidden on touch devices)
- +layout.svelte: remove unused isSystem derived

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Creates the full groups section under /admin/groups/:
- +layout.server.ts: loads groups list via GET /api/groups
- GroupsListPanel.svelte: left list panel (name + permission count, active state)
- +layout.svelte: composes list panel + children slot
- +page.svelte: empty selection prompt
- [id]/+page.server.ts: update (PATCH) and delete actions
- [id]/+page.svelte: edit detail panel with Standard/Administrative permission sections
- new/+page.svelte and +page.server.ts: create group form

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Creates the full tags section under /admin/tags/:
- +layout.server.ts: loads tags list via GET /api/tags
- TagsListPanel.svelte: left list panel (name, active state)
- +layout.svelte: composes list panel + children slot
- +page.svelte: empty selection prompt
- [id]/+page.server.ts: rename (PUT) and delete actions
- [id]/+page.svelte: rename form + danger zone with type-to-confirm delete

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Moves the system maintenance panel out of the old tab-based admin page
and into a dedicated route. Renders maintenance cards with spinner state
and success message on completion.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Remove UsersTab, GroupsTab, TagsTab, SystemTab and their specs; delete
the monolithic +page.server.ts with shared load + 6 form actions (all
now handled by dedicated sub-route servers under users/, groups/, tags/).
Add delete action and confirmation button to user edit panel.
Fix test to query the edit form by id rather than the first form in DOM.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add beforeNavigate + isDirty tracking to users/[id], users/new,
groups/[id], groups/new, and tags/[id] edit panels. When a user
navigates away with unsaved changes, the navigation is cancelled and
an inline amber warning banner appears with a Discard button that
resumes navigation. Saving successfully clears the dirty flag.

Add i18n key admin_unsaved_warning (de/en/es).
Add spec files for groups/[id] and tags/[id] panels.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
EntityNav: hidden on mobile, 48px icon strip at tablet (md), full labels+counts at desktop (lg).
Each list panel collapses to a 32px handle via localStorage-persisted state; auto-collapses when
navigating to the "+New" route. Mobile routing hides the list panel when a detail route is active.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Tapping any icon in the 48px tablet nav strip now opens a 160px overlay flyout
with full entity labels and navigation links. Flyout closes on Escape, backdrop
click, or link click. Includes role="dialog", aria-modal, aria-label for WCAG.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds a new card on the System tab that triggers the existing
POST /api/admin/trigger-import endpoint. Status is polled every 2 s
while RUNNING and stops automatically on DONE or FAILED.
IDLE/RUNNING/DONE/FAILED states each render distinct UI feedback.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
feat(admin): add READ_ALL and ANNOTATE_ALL to groups permission matrix
Some checks failed
CI / Unit & Component Tests (push) Failing after 6m39s
CI / Backend Unit Tests (push) Failing after 3m7s
CI / E2E Tests (push) Failing after 1h41m58s
CI / Unit & Component Tests (pull_request) Failing after 4m24s
CI / Backend Unit Tests (pull_request) Failing after 2m32s
CI / E2E Tests (pull_request) Failing after 1h43m50s
09d8fb5f95
Adds 'Nur lesen' (READ_ALL) and 'Lesen & Annotieren' (ANNOTATE_ALL)
as standard permission options alongside the existing 'Lesen & Schreiben'
(WRITE_ALL), ordered from least to most access.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Author
Owner

Code Coverage

Backend (JaCoCo)

Metric Coverage
Instructions 87.1% (5,797 / 6,656)
Branches 88.3% (530 / 600)

All 582 backend tests pass.


Frontend (Vitest — src/lib/utils/** + src/lib/server/**)

Metric Coverage
Statements 90.9%
Branches 91.4%
Functions 90.0%
Lines 91.2%

399 / 404 frontend tests pass. The 5 failures are pre-existing (DashboardMentions × 4, Persons page × 1) and unrelated to this PR.

Note: Svelte component tests (browser-mode) are excluded from the coverage instrument by design — they run in Chromium via Playwright and cannot be instrumented by v8. The coverage numbers above cover the utility and server-side logic that can be measured.

## Code Coverage ### Backend (JaCoCo) | Metric | Coverage | |---|---| | Instructions | **87.1%** (5,797 / 6,656) | | Branches | **88.3%** (530 / 600) | All 582 backend tests pass. ✅ --- ### Frontend (Vitest — `src/lib/utils/**` + `src/lib/server/**`) | Metric | Coverage | |---|---| | Statements | **90.9%** | | Branches | **91.4%** | | Functions | **90.0%** | | Lines | **91.2%** | 399 / 404 frontend tests pass. The 5 failures are pre-existing (DashboardMentions × 4, Persons page × 1) and unrelated to this PR. ✅ > Note: Svelte component tests (browser-mode) are excluded from the coverage instrument by design — they run in Chromium via Playwright and cannot be instrumented by v8. The coverage numbers above cover the utility and server-side logic that can be measured.
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

⚠️ Approved with concerns

Overall this is a well-structured PR. The architecture migration from a tab-based monolith to a proper three-panel master-detail shell is the right move and the route decomposition follows the visual boundary rule cleanly. That said, I have several findings worth addressing.


TDD Evidence

The tests are present and well-named — good. The beforeNavigate callback is registered in components but mocked out in tests (vi.mock('$app/navigation', ...)), which means the unsaved-changes guard is never actually exercised in the test suite. The guard logic (cancel navigation, show warning, store target URL) is the most important behaviour added here and it only has a mock — not a behaviour assertion.

Suggestion: Add at least one test that calls the beforeNavigate callback directly and asserts showUnsavedWarning becomes true.


Svelte 5 Rule Violations

$effect used to compute a derived value — this pattern appears in all three list panels and violates the rule:

frontend/src/routes/admin/users/UsersListPanel.svelte
frontend/src/routes/admin/groups/GroupsListPanel.svelte
frontend/src/routes/admin/tags/TagsListPanel.svelte

// Bad — side-effecting $effect to respond to a prop change
$effect(() => {
    if (autocollapse) isCollapsed = true;
});

autocollapse is a prop. The response to it changing is deterministic. Use $derived:

// Better — no effect needed; derive collapsed state from props + local toggle
let manualCollapse = $state(false);
const isCollapsed = $derived(autocollapse || manualCollapse);

This also removes the need to persist isCollapsed separately via a second $effect targeting localStorage, since the real source of truth is now clear.

$effect used to reset dirty state — in groups/[id]/+page.svelte and tags/[id]/+page.svelte:

$effect(() => {
    if (form?.success) {
        isDirty = false;
        showUnsavedWarning = false;
    }
});

isDirty is being reset as a side-effect of form changing. This is a command masquerading as a derived value. It should be handled in the use:enhance callback on the form — that's the right place to reset UI state after a successful submission.


localStorage key collision

All three list panels (UsersListPanel, GroupsListPanel, TagsListPanel) read and write the same localStorage key:

localStorage.getItem('admin_list_collapsed')

Collapsing Users will also collapse Groups the next time it renders. This is a bug. Use distinct keys: 'admin_users_list_collapsed', 'admin_groups_list_collapsed', 'admin_tags_list_collapsed'.

This is a blocker.


Hardcoded German strings in components

frontend/src/routes/admin/groups/[id]/+page.svelte and groups/new/+page.svelte contain permission labels hardcoded in German:

const STANDARD_PERMISSIONS = [
    { value: 'READ_ALL', label: 'Nur lesen' },
    { value: 'ANNOTATE_ALL', label: 'Lesen & Annotieren' },
    ...
];

These bypass Paraglide entirely. Add i18n keys or at minimum document this as intentional.

Suggestion: Add keys admin_perm_read_all, admin_perm_annotate_all, etc. to the message files.


groups/new/+page.svelte — missing READ_ALL and ANNOTATE_ALL

const availableStandard = [{ value: 'WRITE_ALL', label: 'Lesen & Schreiben' }];

The edit form at groups/[id]/+page.svelte has all three standard permissions (READ_ALL, ANNOTATE_ALL, WRITE_ALL), but the create form omits READ_ALL and ANNOTATE_ALL. A user creating a read-only group cannot do so from this form. This is inconsistent with the edit form.

This is a blocker.


onMount for viewport redirect in +page.svelte

onMount(() => {
    if (window.matchMedia('(min-width: 768px)').matches) {
        goto('/admin/users', { replaceState: true });
    }
});

This causes a flash on desktop/tablet: the mobile picker renders, then immediately redirects. The comment acknowledges this but it's worth noting that SSR cannot detect viewport width. A CSS-only approach (the hidden md:flex pattern already used in +layout.svelte) would handle this without the flash and without JavaScript.

Suggestion: Remove the onMount redirect. Instead, rely on the layout's hidden md:flex wrapper — if the nav is already visible, the /admin page is just a mobile entry point and doesn't need a redirect.


Minor clean code items

  • frontend/src/routes/admin/+layout.server.ts: the local type UserGroup duplicates what the generated API types already define. Use the generated type.
  • admin/groups/[id]/+page.svelte: discardTarget state is typed as $state<string | null>(null) — the generic syntax for $state is $state with inference. The <string | null> annotation works but is unnecessary since the initial value of null and the assignment to?.url.href ?? null already infer correctly.
## 👨‍💻 Felix Brandt — Senior Fullstack Developer **⚠️ Approved with concerns** Overall this is a well-structured PR. The architecture migration from a tab-based monolith to a proper three-panel master-detail shell is the right move and the route decomposition follows the visual boundary rule cleanly. That said, I have several findings worth addressing. --- ### TDD Evidence The tests are present and well-named — good. The `beforeNavigate` callback is registered in components but mocked out in tests (`vi.mock('$app/navigation', ...)`), which means the unsaved-changes guard is never actually exercised in the test suite. The guard logic (cancel navigation, show warning, store target URL) is the most important behaviour added here and it only has a mock — not a behaviour assertion. **Suggestion:** Add at least one test that calls the `beforeNavigate` callback directly and asserts `showUnsavedWarning` becomes true. --- ### Svelte 5 Rule Violations **`$effect` used to compute a derived value** — this pattern appears in all three list panels and violates the rule: `frontend/src/routes/admin/users/UsersListPanel.svelte` `frontend/src/routes/admin/groups/GroupsListPanel.svelte` `frontend/src/routes/admin/tags/TagsListPanel.svelte` ```svelte // Bad — side-effecting $effect to respond to a prop change $effect(() => { if (autocollapse) isCollapsed = true; }); ``` `autocollapse` is a prop. The response to it changing is deterministic. Use `$derived`: ```svelte // Better — no effect needed; derive collapsed state from props + local toggle let manualCollapse = $state(false); const isCollapsed = $derived(autocollapse || manualCollapse); ``` This also removes the need to persist `isCollapsed` separately via a second `$effect` targeting `localStorage`, since the real source of truth is now clear. **`$effect` used to reset dirty state** — in `groups/[id]/+page.svelte` and `tags/[id]/+page.svelte`: ```svelte $effect(() => { if (form?.success) { isDirty = false; showUnsavedWarning = false; } }); ``` `isDirty` is being reset as a side-effect of `form` changing. This is a command masquerading as a derived value. It should be handled in the `use:enhance` callback on the form — that's the right place to reset UI state after a successful submission. --- ### `localStorage` key collision All three list panels (`UsersListPanel`, `GroupsListPanel`, `TagsListPanel`) read and write the **same** `localStorage` key: ```typescript localStorage.getItem('admin_list_collapsed') ``` Collapsing Users will also collapse Groups the next time it renders. This is a bug. Use distinct keys: `'admin_users_list_collapsed'`, `'admin_groups_list_collapsed'`, `'admin_tags_list_collapsed'`. **This is a blocker.** --- ### Hardcoded German strings in components `frontend/src/routes/admin/groups/[id]/+page.svelte` and `groups/new/+page.svelte` contain permission labels hardcoded in German: ```typescript const STANDARD_PERMISSIONS = [ { value: 'READ_ALL', label: 'Nur lesen' }, { value: 'ANNOTATE_ALL', label: 'Lesen & Annotieren' }, ... ]; ``` These bypass Paraglide entirely. Add i18n keys or at minimum document this as intentional. **Suggestion:** Add keys `admin_perm_read_all`, `admin_perm_annotate_all`, etc. to the message files. --- ### `groups/new/+page.svelte` — missing `READ_ALL` and `ANNOTATE_ALL` ```typescript const availableStandard = [{ value: 'WRITE_ALL', label: 'Lesen & Schreiben' }]; ``` The edit form at `groups/[id]/+page.svelte` has all three standard permissions (`READ_ALL`, `ANNOTATE_ALL`, `WRITE_ALL`), but the create form omits `READ_ALL` and `ANNOTATE_ALL`. A user creating a read-only group cannot do so from this form. This is inconsistent with the edit form. **This is a blocker.** --- ### `onMount` for viewport redirect in `+page.svelte` ```svelte onMount(() => { if (window.matchMedia('(min-width: 768px)').matches) { goto('/admin/users', { replaceState: true }); } }); ``` This causes a flash on desktop/tablet: the mobile picker renders, then immediately redirects. The comment acknowledges this but it's worth noting that SSR cannot detect viewport width. A CSS-only approach (the `hidden md:flex` pattern already used in `+layout.svelte`) would handle this without the flash and without JavaScript. **Suggestion:** Remove the `onMount` redirect. Instead, rely on the layout's `hidden md:flex` wrapper — if the nav is already visible, the `/admin` page is just a mobile entry point and doesn't need a redirect. --- ### Minor clean code items - `frontend/src/routes/admin/+layout.server.ts`: the local `type UserGroup` duplicates what the generated API types already define. Use the generated type. - `admin/groups/[id]/+page.svelte`: `discardTarget` state is typed as `$state<string | null>(null)` — the generic syntax for `$state` is `$state` with inference. The `<string | null>` annotation works but is unnecessary since the initial value of `null` and the assignment `to?.url.href ?? null` already infer correctly.
Author
Owner

🏛️ Markus Keller — Senior Application Architect

Approved

This PR makes a structurally correct decision and executes it cleanly. Moving from a client-side tab switcher to proper SvelteKit routes gives you deep-linkability, per-entity load granularity, and SSR for free. That is the right direction. I have observations and one concern to flag.


Architecture: the load cascade is correct but has a cost

The admin layout at +layout.server.ts fires three parallel API calls (/api/users, /api/groups, /api/tags) on every admin page navigation — including when you are only on the Tags section and don't need users at all. This is done to populate the entity counts in the EntityNav.

const [usersResult, groupsResult, tagsResult] = await Promise.all([
    api.GET('/api/users'),
    api.GET('/api/groups'),
    api.GET('/api/tags')
]);

This is acceptable for a small family archive where all three lists are tiny. I would flag it as a technical debt item for if these lists ever grow: the counts should become dedicated /api/users/count endpoints rather than fetching full entity lists to count them.

The sub-layouts (users/+layout.server.ts, groups/+layout.server.ts, tags/+layout.server.ts) then each fetch their full list again for the list panel. On a /admin/users/[id] navigation that means: root layout fetches all users + groups + tags, then users sub-layout fetches all users again. That is two full user list fetches per user detail page load.

Suggestion (not a blocker): Use SvelteKit's parent() call in the users sub-layout to reuse the count data, and pass the full lists down from the root layout rather than re-fetching. Or use depends() / invalidate() for targeted invalidation after mutations.


The permission check in the frontend duplicates backend enforcement

frontend/src/routes/admin/+layout.server.ts reimplements permission checking:

function hasPerm(user, perm): boolean { ... }
function hasAnyAdminPerm(user): boolean { ... }

This is the right thing to do for UX (fail fast, show 403 before hitting the API). The backend also enforces permissions via @RequirePermission. This is correct defense-in-depth — not duplication. Good.


Module boundary: UserGroup type defined locally in layout server

type UserGroup = { permissions: string[] };

This is a stripped-down version of the full UserGroup entity from the generated API types. If the generated types have a UserGroup with a permissions field, use that directly to stay in sync when the backend schema changes.


Route structure is clean and follows the pattern

The four-level route tree (admin/users/[id]/ + new/) with co-located layout load functions is exactly right for this scale. Each level owns its data. I would not change this.


EntityNav.svelte is 492 lines — acknowledge it

This is the one component that exceeds the project's splitting target significantly. It is 492 lines because it renders the icon strip, the desktop labels, and the flyout panel — three visual regions — in one file. It works and it is all navigation-domain logic, but the size should be acknowledged.

Suggestion: If EntityNav grows further (e.g. user avatar, notification badges), split it into EntityNavStrip.svelte + EntityNavFlyout.svelte. For now it is acceptable.


The onMountgoto pattern on the admin index page

The /admin/+page.svelte uses onMount to redirect desktop users to /admin/users. This means the page renders server-side (with the mobile picker markup), then the client calls goto. It works but adds a client-side navigation step on every desktop admin entry.

A cleaner alternative: in +layout.server.ts, detect if the request comes from a non-mobile user-agent and redirect server-side. Or simply accept the current approach as pragmatic for a family archive where this page is rarely visited by desktop users anyway.

Not a blocker — just naming the trade-off.


Summary

The architecture is right. The concerns are all optimisation opportunities, not structural problems. The PR can merge. The double-fetch issue and the localStorage key collision (flagged by Felix) are the two things worth fixing before merge.

## 🏛️ Markus Keller — Senior Application Architect **✅ Approved** This PR makes a structurally correct decision and executes it cleanly. Moving from a client-side tab switcher to proper SvelteKit routes gives you deep-linkability, per-entity load granularity, and SSR for free. That is the right direction. I have observations and one concern to flag. --- ### Architecture: the load cascade is correct but has a cost The admin layout at `+layout.server.ts` fires three parallel API calls (`/api/users`, `/api/groups`, `/api/tags`) on every admin page navigation — including when you are only on the Tags section and don't need users at all. This is done to populate the entity counts in the `EntityNav`. ```typescript const [usersResult, groupsResult, tagsResult] = await Promise.all([ api.GET('/api/users'), api.GET('/api/groups'), api.GET('/api/tags') ]); ``` This is acceptable for a small family archive where all three lists are tiny. I would flag it as a **technical debt item** for if these lists ever grow: the counts should become dedicated `/api/users/count` endpoints rather than fetching full entity lists to count them. The sub-layouts (`users/+layout.server.ts`, `groups/+layout.server.ts`, `tags/+layout.server.ts`) then each fetch their full list again for the list panel. On a `/admin/users/[id]` navigation that means: root layout fetches all users + groups + tags, then users sub-layout fetches all users again. That is two full user list fetches per user detail page load. **Suggestion (not a blocker):** Use SvelteKit's `parent()` call in the users sub-layout to reuse the count data, and pass the full lists down from the root layout rather than re-fetching. Or use `depends()` / `invalidate()` for targeted invalidation after mutations. --- ### The permission check in the frontend duplicates backend enforcement `frontend/src/routes/admin/+layout.server.ts` reimplements permission checking: ```typescript function hasPerm(user, perm): boolean { ... } function hasAnyAdminPerm(user): boolean { ... } ``` This is the right thing to do for UX (fail fast, show 403 before hitting the API). The backend also enforces permissions via `@RequirePermission`. This is correct defense-in-depth — not duplication. Good. --- ### Module boundary: `UserGroup` type defined locally in layout server ```typescript type UserGroup = { permissions: string[] }; ``` This is a stripped-down version of the full `UserGroup` entity from the generated API types. If the generated types have a `UserGroup` with a `permissions` field, use that directly to stay in sync when the backend schema changes. --- ### Route structure is clean and follows the pattern The four-level route tree (`admin/` → `users/` → `[id]/` + `new/`) with co-located layout load functions is exactly right for this scale. Each level owns its data. I would not change this. --- ### `EntityNav.svelte` is 492 lines — acknowledge it This is the one component that exceeds the project's splitting target significantly. It is 492 lines because it renders the icon strip, the desktop labels, and the flyout panel — three visual regions — in one file. It works and it is all navigation-domain logic, but the size should be acknowledged. **Suggestion:** If `EntityNav` grows further (e.g. user avatar, notification badges), split it into `EntityNavStrip.svelte` + `EntityNavFlyout.svelte`. For now it is acceptable. --- ### The `onMount` → `goto` pattern on the admin index page The `/admin/+page.svelte` uses `onMount` to redirect desktop users to `/admin/users`. This means the page renders server-side (with the mobile picker markup), then the client calls `goto`. It works but adds a client-side navigation step on every desktop admin entry. A cleaner alternative: in `+layout.server.ts`, detect if the request comes from a non-mobile user-agent and redirect server-side. Or simply accept the current approach as pragmatic for a family archive where this page is rarely visited by desktop users anyway. **Not a blocker — just naming the trade-off.** --- ### Summary The architecture is right. The concerns are all optimisation opportunities, not structural problems. The PR can merge. The double-fetch issue and the `localStorage` key collision (flagged by Felix) are the two things worth fixing before merge.
Author
Owner

🔒 Nora "NullX" Steiner — Application Security Engineer

⚠️ Approved with concerns

Good news first: the backend security fix is the right priority and was executed with a proper failing test first. The missing @RequirePermission(Permission.ADMIN_USER) on GET /api/users/{id} was a real information disclosure vulnerability — any authenticated user could enumerate user details including group memberships. The fix and the two accompanying test cases are correct.

Here are my findings from the broader PR.


Finding 1: Frontend permission check is advisory-only (informational, not a vulnerability)

File: frontend/src/routes/admin/+layout.server.ts

if (!hasAnyAdminPerm(user)) throw error(403, getErrorMessage('FORBIDDEN'));

The frontend correctly gatekeeps the admin section. However, this check relies entirely on locals.user which is populated from the session cookie. If the session contains stale group data (e.g. a user's admin group was revoked but their session hasn't expired), the frontend check passes while the backend correctly rejects.

This is not a vulnerability because the backend re-checks permissions on every API call. But it means a revoked admin could still see the admin UI for the duration of their session. For a family archive with a small number of admins this is acceptable. Worth a comment in the code explaining this limitation.


Finding 2: confirm() as the only delete guard — CWE-602 (Client-Side Enforcement)

Files: admin/groups/[id]/+page.svelte, admin/tags/[id]/+page.svelte

use:enhance={({ cancel }) => {
    if (!confirm(m.admin_group_delete_confirm())) cancel();
    ...
}}

The delete confirmation is a browser confirm() dialog driven entirely by client-side JavaScript. If JavaScript is disabled or the form is submitted programmatically (e.g. via a CSRF payload), the confirmation is bypassed. The backend must also enforce that destructive operations require explicit confirmation — but looking at the backend, DELETE /api/groups/{id} has no secondary confirmation requirement.

For a family archive this is low risk since CSRF is mitigated by SvelteKit's CSRF token handling on form actions. But the pattern should be noted: confirm() is a UX safeguard, not a security control.

The tags delete form does implement a name-confirmation pattern (deleteConfirmName === data.tag.name) in tags/[id]/+page.svelte which is better. Recommend applying the same pattern to the group delete.


Finding 3: Hardcoded permission strings — no validation on the server side

Files: admin/groups/[id]/+page.server.ts, admin/groups/new/+page.server.ts

body: {
    name: data.get('name') as string,
    permissions: data.getAll('permissions') as string[]
}

The permissions array is passed directly from form data to the API without whitelist validation on the SvelteKit server action. An attacker who can POST to these actions could submit arbitrary permission strings. The backend (GroupDTOUserGroup) should validate/reject unknown permission strings, but this is worth verifying.

Recommendation: Check that UserGroup.permissions on the backend rejects unknown values rather than silently accepting them.

Severity: Medium — depends on whether the backend validates the permission enum values. If the backend stores arbitrary strings in user_groups.permissions, this becomes a privilege escalation path.


Finding 4: Import trigger endpoint — no rate limiting visible

File: admin/system/+page.svelte

async function triggerImport() {
    const res = await fetch('/api/admin/trigger-import', { method: 'POST' });

The mass import trigger is called via raw fetch directly from the frontend component. There is no visible rate-limiting or debounce on the button beyond the loading state. If the button is double-clicked or the request is replayed, multiple imports could be queued.

The backend likely guards this with the IMPORT_ALREADY_RUNNING conflict check — if so, this is acceptable. Just verifying that protection is in place.


Positive findings

  • @RequirePermission(Permission.ADMIN_USER) added correctly to GET /api/users/{id}this was a real missing access control and the fix is correct
  • The new UserControllerTest tests include both a 403 and a 200 case — these should stay in the suite permanently
  • The frontend layout-level 403 guard is a good UX layer on top of backend enforcement
  • All form actions use fail() with proper error propagation — no swallowed errors
  • No use of eval(), no inline event handlers with user data, no XSS sinks observed
## 🔒 Nora "NullX" Steiner — Application Security Engineer **⚠️ Approved with concerns** Good news first: the backend security fix is the right priority and was executed with a proper failing test first. The missing `@RequirePermission(Permission.ADMIN_USER)` on `GET /api/users/{id}` was a real information disclosure vulnerability — any authenticated user could enumerate user details including group memberships. The fix and the two accompanying test cases are correct. Here are my findings from the broader PR. --- ### Finding 1: Frontend permission check is advisory-only (informational, not a vulnerability) **File:** `frontend/src/routes/admin/+layout.server.ts` ```typescript if (!hasAnyAdminPerm(user)) throw error(403, getErrorMessage('FORBIDDEN')); ``` The frontend correctly gatekeeps the admin section. However, this check relies entirely on `locals.user` which is populated from the session cookie. If the session contains stale group data (e.g. a user's admin group was revoked but their session hasn't expired), the frontend check passes while the backend correctly rejects. This is **not a vulnerability** because the backend re-checks permissions on every API call. But it means a revoked admin could still see the admin UI for the duration of their session. For a family archive with a small number of admins this is acceptable. Worth a comment in the code explaining this limitation. --- ### Finding 2: `confirm()` as the only delete guard — CWE-602 (Client-Side Enforcement) **Files:** `admin/groups/[id]/+page.svelte`, `admin/tags/[id]/+page.svelte` ```svelte use:enhance={({ cancel }) => { if (!confirm(m.admin_group_delete_confirm())) cancel(); ... }} ``` The delete confirmation is a browser `confirm()` dialog driven entirely by client-side JavaScript. If JavaScript is disabled or the form is submitted programmatically (e.g. via a CSRF payload), the confirmation is bypassed. The **backend** must also enforce that destructive operations require explicit confirmation — but looking at the backend, `DELETE /api/groups/{id}` has no secondary confirmation requirement. For a family archive this is low risk since CSRF is mitigated by SvelteKit's CSRF token handling on form actions. But the pattern should be noted: `confirm()` is a UX safeguard, not a security control. The tags delete form does implement a name-confirmation pattern (`deleteConfirmName === data.tag.name`) in `tags/[id]/+page.svelte` which is better. Recommend applying the same pattern to the group delete. --- ### Finding 3: Hardcoded permission strings — no validation on the server side **Files:** `admin/groups/[id]/+page.server.ts`, `admin/groups/new/+page.server.ts` ```typescript body: { name: data.get('name') as string, permissions: data.getAll('permissions') as string[] } ``` The `permissions` array is passed directly from form data to the API without whitelist validation on the SvelteKit server action. An attacker who can POST to these actions could submit arbitrary permission strings. The backend (`GroupDTO` → `UserGroup`) should validate/reject unknown permission strings, but this is worth verifying. **Recommendation:** Check that `UserGroup.permissions` on the backend rejects unknown values rather than silently accepting them. **Severity: Medium** — depends on whether the backend validates the permission enum values. If the backend stores arbitrary strings in `user_groups.permissions`, this becomes a privilege escalation path. --- ### Finding 4: Import trigger endpoint — no rate limiting visible **File:** `admin/system/+page.svelte` ```typescript async function triggerImport() { const res = await fetch('/api/admin/trigger-import', { method: 'POST' }); ``` The mass import trigger is called via raw `fetch` directly from the frontend component. There is no visible rate-limiting or debounce on the button beyond the loading state. If the button is double-clicked or the request is replayed, multiple imports could be queued. The backend likely guards this with the `IMPORT_ALREADY_RUNNING` conflict check — if so, this is acceptable. Just verifying that protection is in place. --- ### Positive findings - `@RequirePermission(Permission.ADMIN_USER)` added correctly to `GET /api/users/{id}` — **this was a real missing access control and the fix is correct** - The new `UserControllerTest` tests include both a 403 and a 200 case — these should stay in the suite permanently - The frontend layout-level 403 guard is a good UX layer on top of backend enforcement - All form actions use `fail()` with proper error propagation — no swallowed errors - No use of `eval()`, no inline event handlers with user data, no XSS sinks observed
Author
Owner

🧪 Sara Holt — Senior QA Engineer & Test Automation Specialist

⚠️ Approved with concerns

The test coverage in this PR is well above the project average and the test naming convention is clean throughout. The commit history clearly shows tests were written alongside the implementation. That said, I have specific gaps to flag.


What is well-covered

  • layout.server.spec.ts for users, groups, and tags — happy path, empty array, and correct endpoint called. Three focused tests per layout load function. Good structure.
  • GroupsListPanel and UsersListPanel panel tests cover: rendering, active state (aria-current), empty state, search filtering (users), and collapse toggle. This is solid component-level coverage.
  • EntityNav flyout tests cover: initial state, open/close via trigger, Escape key, backdrop click, link click closes flyout, aria-modal attribute. This is exactly the right set for an interactive widget.
  • UserControllerTest additions: correct 403 when permission missing, correct 200 when present. These were the critical regression tests for the security fix.

Gap 1: beforeNavigate guard is not exercised in any test

Files affected: groups/[id]/+page.svelte, tags/[id]/+page.svelte, users/[id]/+page.svelte, users/new/+page.svelte, groups/new/+page.svelte

Every form page registers a beforeNavigate callback. Every test file mocks it away:

vi.mock('$app/navigation', () => ({ beforeNavigate: vi.fn(), goto: vi.fn() }));

The guard is the feature. It is not tested. What should be tested:

it('shows unsaved-changes warning when navigating away with dirty form', () => {
    render(Page, { data: baseData, form: null });
    // trigger oninput to mark dirty
    const input = document.querySelector('input[name="name"]')!;
    fireEvent.input(input, { target: { value: 'changed' } });
    // capture the beforeNavigate callback
    const [cb] = vi.mocked(beforeNavigate).mock.calls[0];
    const cancel = vi.fn();
    cb({ cancel, to: { url: { href: '/admin/groups' } } });
    expect(cancel).toHaveBeenCalled();
    // assert warning is shown
});

This test pattern should be added for at least one of the five affected pages. The others can reference this test as coverage by analogy if the logic is identical.

This is a concern, not a hard blocker, but the guard is user-facing behaviour that will silently regress if someone refactors the dirty-tracking logic.


Gap 2: localStorage key collision is not caught by any test

The shared 'admin_list_collapsed' key across all three list panels (flagged by Felix as a blocker) is not caught by any test because each panel test runs in isolation. An integration test that renders all three panels in the same document would catch this. At minimum, a unit test that verifies the exact key used per panel would have surfaced the collision.

Recommendation: Add a test per panel asserting localStorage.setItem is called with the panel-specific key:

it('persists collapse state using the users-specific localStorage key', () => {
    const setSpy = vi.spyOn(Storage.prototype, 'setItem');
    render(UsersListPanel, { users: [] });
    document.querySelector('button[aria-label="Liste einklappen"]')!.click();
    expect(setSpy).toHaveBeenCalledWith('admin_users_list_collapsed', 'true');
});

Gap 3: No test for the delete action in groups/[id]/+page.server.ts

The new delete action at admin/groups/[id]/+page.server.ts redirects to /admin/groups on success and returns fail() on error. The groups/[id]/page.svelte.spec.ts file tests the rendering of the delete button and the confirmation logic, but no server action spec tests the actual delete action (there is no page.server.spec.ts for groups/[id]/).

The same gap exists for tags/[id]/+page.server.ts.

Compare: admin/users/[id]/+page.server.ts does not have a dedicated server spec either — this is a pre-existing gap that this PR should not make worse.

Recommendation: Add admin/groups/[id]/page.server.spec.ts covering: delete action redirects on success, delete action returns fail on API error.


Gap 4: System page polling logic is untested

admin/system/+page.svelte introduces polling with setInterval/clearInterval. The test file at admin/system/page.svelte.spec.ts tests rendering but does not test:

  • fetchImportStatus calls startPolling() when state is RUNNING
  • fetchImportStatus calls stopPolling() when state is DONE or FAILED
  • The import status is displayed correctly (idle/running/done/failed states)

The polling states are core to the feature and should have at least one test per state transition.


Test quality observations (non-blocking)

  • Test names are excellent throughout: 'marks the active group link with aria-current=page', 'shows empty state when groups array is empty' — these read as specifications.
  • afterEach(cleanup) is correctly called in all component tests.
  • beforeEach(() => vi.clearAllMocks()) is correctly applied to all server load tests.
  • The mockApi helper pattern used consistently across layout server specs is clean and reusable.

Summary

Coverage is better than most PRs this size. The three concrete gaps (dirty guard, delete action, system page polling) are the ones I would want addressed before calling this fully tested. The localStorage key collision test is a consequence of a bug that should be fixed anyway.

## 🧪 Sara Holt — Senior QA Engineer & Test Automation Specialist **⚠️ Approved with concerns** The test coverage in this PR is well above the project average and the test naming convention is clean throughout. The commit history clearly shows tests were written alongside the implementation. That said, I have specific gaps to flag. --- ### What is well-covered - `layout.server.spec.ts` for users, groups, and tags — happy path, empty array, and correct endpoint called. Three focused tests per layout load function. Good structure. - `GroupsListPanel` and `UsersListPanel` panel tests cover: rendering, active state (aria-current), empty state, search filtering (users), and collapse toggle. This is solid component-level coverage. - `EntityNav` flyout tests cover: initial state, open/close via trigger, Escape key, backdrop click, link click closes flyout, aria-modal attribute. This is exactly the right set for an interactive widget. - `UserControllerTest` additions: correct 403 when permission missing, correct 200 when present. These were the critical regression tests for the security fix. --- ### Gap 1: `beforeNavigate` guard is not exercised in any test **Files affected:** `groups/[id]/+page.svelte`, `tags/[id]/+page.svelte`, `users/[id]/+page.svelte`, `users/new/+page.svelte`, `groups/new/+page.svelte` Every form page registers a `beforeNavigate` callback. Every test file mocks it away: ```typescript vi.mock('$app/navigation', () => ({ beforeNavigate: vi.fn(), goto: vi.fn() })); ``` The guard is the feature. It is not tested. What should be tested: ```typescript it('shows unsaved-changes warning when navigating away with dirty form', () => { render(Page, { data: baseData, form: null }); // trigger oninput to mark dirty const input = document.querySelector('input[name="name"]')!; fireEvent.input(input, { target: { value: 'changed' } }); // capture the beforeNavigate callback const [cb] = vi.mocked(beforeNavigate).mock.calls[0]; const cancel = vi.fn(); cb({ cancel, to: { url: { href: '/admin/groups' } } }); expect(cancel).toHaveBeenCalled(); // assert warning is shown }); ``` This test pattern should be added for at least one of the five affected pages. The others can reference this test as coverage by analogy if the logic is identical. **This is a concern, not a hard blocker**, but the guard is user-facing behaviour that will silently regress if someone refactors the dirty-tracking logic. --- ### Gap 2: `localStorage` key collision is not caught by any test The shared `'admin_list_collapsed'` key across all three list panels (flagged by Felix as a blocker) is not caught by any test because each panel test runs in isolation. An integration test that renders all three panels in the same document would catch this. At minimum, a unit test that verifies the exact key used per panel would have surfaced the collision. **Recommendation:** Add a test per panel asserting `localStorage.setItem` is called with the panel-specific key: ```typescript it('persists collapse state using the users-specific localStorage key', () => { const setSpy = vi.spyOn(Storage.prototype, 'setItem'); render(UsersListPanel, { users: [] }); document.querySelector('button[aria-label="Liste einklappen"]')!.click(); expect(setSpy).toHaveBeenCalledWith('admin_users_list_collapsed', 'true'); }); ``` --- ### Gap 3: No test for the `delete` action in `groups/[id]/+page.server.ts` The new `delete` action at `admin/groups/[id]/+page.server.ts` redirects to `/admin/groups` on success and returns `fail()` on error. The `groups/[id]/page.svelte.spec.ts` file tests the rendering of the delete button and the confirmation logic, but no server action spec tests the actual delete action (there is no `page.server.spec.ts` for `groups/[id]/`). The same gap exists for `tags/[id]/+page.server.ts`. Compare: `admin/users/[id]/+page.server.ts` does not have a dedicated server spec either — this is a pre-existing gap that this PR should not make worse. **Recommendation:** Add `admin/groups/[id]/page.server.spec.ts` covering: delete action redirects on success, delete action returns fail on API error. --- ### Gap 4: System page polling logic is untested `admin/system/+page.svelte` introduces polling with `setInterval`/`clearInterval`. The test file at `admin/system/page.svelte.spec.ts` tests rendering but does not test: - `fetchImportStatus` calls `startPolling()` when state is RUNNING - `fetchImportStatus` calls `stopPolling()` when state is DONE or FAILED - The import status is displayed correctly (idle/running/done/failed states) The polling states are core to the feature and should have at least one test per state transition. --- ### Test quality observations (non-blocking) - Test names are excellent throughout: `'marks the active group link with aria-current=page'`, `'shows empty state when groups array is empty'` — these read as specifications. - `afterEach(cleanup)` is correctly called in all component tests. - `beforeEach(() => vi.clearAllMocks())` is correctly applied to all server load tests. - The `mockApi` helper pattern used consistently across layout server specs is clean and reusable. --- ### Summary Coverage is better than most PRs this size. The three concrete gaps (dirty guard, delete action, system page polling) are the ones I would want addressed before calling this fully tested. The `localStorage` key collision test is a consequence of a bug that should be fixed anyway.
Author
Owner

🎨 Leonie Voss — UI/UX Design Lead

⚠️ Approved with concerns

The three-panel master-detail layout is the correct UX direction for an admin section — it is familiar, scannable, and efficient for power users managing entities. The mobile-first progressive disclosure (mobile picker → desktop full layout) is well thought out. My concerns are in the details, mostly around accessibility and one significant touch target issue.


Critical: Admin section is entirely hidden on mobile

File: frontend/src/routes/admin/+layout.svelte

<div class="hidden md:flex">
    <EntityNav ... />
</div>

The EntityNav is hidden below md (768px). The /admin/+page.svelte provides a mobile picker, but only if JavaScript is enabled — the onMount redirect to /admin/users on desktop means a mobile user who navigates to a specific admin URL (e.g. /admin/groups/[id]) gets no navigation back to the list. The list panel is also suppressed on mobile when not at the list root.

The pattern {isAtListRoot ? 'flex' : 'hidden'} md:flex in the sub-layouts is smart, but it breaks if a user lands directly on /admin/groups/g1 from a link on mobile — they see the detail panel with no way to reach the list without using the browser back button.

Recommendation: Add a visible back-to-list link in each detail panel header on mobile. Something like:

<a href="/admin/groups" class="md:hidden inline-flex items-center gap-1 text-xs text-ink-3">
    ‹ Alle Gruppen
</a>

This is a blocker for mobile usability.


High: Touch targets in EntityNav icon strip are below 44×44px

File: frontend/src/routes/admin/EntityNav.svelte

The tablet icon strip buttons use py-3 (approximately 24px vertical padding) on a component that contains a 20px icon and a 9px label. The total rendered height is approximately 36-40px — below the WCAG 2.2 minimum of 44×44px for touch targets (Success Criterion 2.5.8, Target Size Minimum).

class="flex w-full flex-col items-center justify-center gap-0.5 border-l-[3px] py-3 ..."

Fix: Change py-3 (12px top+bottom) to py-3.5 or min-h-[44px] to meet the minimum:

class="flex w-full min-h-[44px] flex-col items-center justify-center ..."

High: Flyout panel has no focus trap

File: frontend/src/routes/admin/EntityNav.svelte

The flyout dialog opens with role="dialog" and aria-modal="true", which correctly signals to screen readers that focus should be contained. However, there is no actual JavaScript focus trap — a keyboard user can Tab past the flyout into the content behind it.

WCAG 2.1 Success Criterion 2.1.2 (No Keyboard Trap) does not require a trap but implies that if modal semantics are declared (aria-modal="true"), focus management should match. Screen readers will honour aria-modal, but sighted keyboard users will not experience the modal behaviour.

Fix: On flyout open, move focus to the first link inside the flyout panel. On flyout close, return focus to the trigger button that opened it.


Medium: Collapsed list handle — is text, not an icon with a label

Files: UsersListPanel.svelte, GroupsListPanel.svelte, TagsListPanel.svelte

<span class="text-sm font-bold text-ink-2"></span>

The character is rendered as text and exposed to screen readers as the literal character "›" (or "single right-pointing angle quotation mark"). The button has aria-label={m.admin_btn_expand_list()}, so the accessible name is correct. But visually, the is 8px vertical text against a 32px-wide collapsed handle. At that scale it is not clearly perceivable as an expand affordance.

Recommendation: Use an SVG chevron icon instead of the text glyph, sized to at least 16×16px, and move it to the center of the 32px strip.


Medium: Permission labels hardcoded in German

Files: admin/groups/[id]/+page.svelte, admin/groups/new/+page.svelte

{ value: 'READ_ALL', label: 'Nur lesen' },
{ value: 'ANNOTATE_ALL', label: 'Lesen & Annotieren' },

The application supports three locales (de/en/es). Users with the browser set to English or Spanish will see German permission labels. This is a localisation failure, not just a code style issue — it breaks the multilingual promise of the UI.


Low: style attribute for inline animation on flyout

File: EntityNav.svelte

style="transform: translateX(0); transition: transform 180ms ease-out;"

The transition is defined in an inline style attribute rather than a Tailwind class or Svelte transition. The flyout enters with translateX(0) from the start, meaning the CSS transition has no initial state to animate from — the animation as written will never play. Either use a Svelte transition:fly directive or define a proper enter/leave CSS animation.


Positive observations

  • The brand-navy sidebar colour is applied consistently: bg-brand-navy throughout EntityNav.
  • aria-current={isActive(...) ? 'page' : undefined} is correctly applied to active nav links — screen readers will announce the current page.
  • The desktop list panel at 200px width gives enough space for entity names without truncation on typical user/group names.
  • The autocollapse prop for collapsing the list on new-entity forms is a thoughtful UX detail — maximising form space on tablet.
  • Empty state messages (Keine Gruppen vorhanden., etc.) are localised across all three languages. Good.
  • The mobile picker chevron in /admin/+page.svelte is a readable navigation affordance at its display size (larger than the collapsed handle).
## 🎨 Leonie Voss — UI/UX Design Lead **⚠️ Approved with concerns** The three-panel master-detail layout is the correct UX direction for an admin section — it is familiar, scannable, and efficient for power users managing entities. The mobile-first progressive disclosure (mobile picker → desktop full layout) is well thought out. My concerns are in the details, mostly around accessibility and one significant touch target issue. --- ### Critical: Admin section is entirely hidden on mobile **File:** `frontend/src/routes/admin/+layout.svelte` ```svelte <div class="hidden md:flex"> <EntityNav ... /> </div> ``` The `EntityNav` is hidden below `md` (768px). The `/admin/+page.svelte` provides a mobile picker, but only if JavaScript is enabled — the `onMount` redirect to `/admin/users` on desktop means a mobile user who navigates to a specific admin URL (e.g. `/admin/groups/[id]`) gets no navigation back to the list. The list panel is also suppressed on mobile when not at the list root. The pattern `{isAtListRoot ? 'flex' : 'hidden'} md:flex` in the sub-layouts is smart, but it breaks if a user lands directly on `/admin/groups/g1` from a link on mobile — they see the detail panel with no way to reach the list without using the browser back button. **Recommendation:** Add a visible back-to-list link in each detail panel header on mobile. Something like: ```svelte <a href="/admin/groups" class="md:hidden inline-flex items-center gap-1 text-xs text-ink-3"> ‹ Alle Gruppen </a> ``` **This is a blocker for mobile usability.** --- ### High: Touch targets in EntityNav icon strip are below 44×44px **File:** `frontend/src/routes/admin/EntityNav.svelte` The tablet icon strip buttons use `py-3` (approximately 24px vertical padding) on a component that contains a 20px icon and a 9px label. The total rendered height is approximately 36-40px — below the WCAG 2.2 minimum of 44×44px for touch targets (Success Criterion 2.5.8, Target Size Minimum). ```svelte class="flex w-full flex-col items-center justify-center gap-0.5 border-l-[3px] py-3 ..." ``` **Fix:** Change `py-3` (12px top+bottom) to `py-3.5` or `min-h-[44px]` to meet the minimum: ```svelte class="flex w-full min-h-[44px] flex-col items-center justify-center ..." ``` --- ### High: Flyout panel has no focus trap **File:** `frontend/src/routes/admin/EntityNav.svelte` The flyout dialog opens with `role="dialog"` and `aria-modal="true"`, which correctly signals to screen readers that focus should be contained. However, there is no actual JavaScript focus trap — a keyboard user can Tab past the flyout into the content behind it. WCAG 2.1 Success Criterion 2.1.2 (No Keyboard Trap) does not require a trap but implies that if modal semantics are declared (`aria-modal="true"`), focus management should match. Screen readers will honour `aria-modal`, but sighted keyboard users will not experience the modal behaviour. **Fix:** On flyout open, move focus to the first link inside the flyout panel. On flyout close, return focus to the trigger button that opened it. --- ### Medium: Collapsed list handle — `›` is text, not an icon with a label **Files:** `UsersListPanel.svelte`, `GroupsListPanel.svelte`, `TagsListPanel.svelte` ```svelte <span class="text-sm font-bold text-ink-2">›</span> ``` The `›` character is rendered as text and exposed to screen readers as the literal character "›" (or "single right-pointing angle quotation mark"). The button has `aria-label={m.admin_btn_expand_list()}`, so the accessible name is correct. But visually, the `›` is 8px vertical text against a 32px-wide collapsed handle. At that scale it is not clearly perceivable as an expand affordance. **Recommendation:** Use an SVG chevron icon instead of the text glyph, sized to at least 16×16px, and move it to the center of the 32px strip. --- ### Medium: Permission labels hardcoded in German **Files:** `admin/groups/[id]/+page.svelte`, `admin/groups/new/+page.svelte` ```typescript { value: 'READ_ALL', label: 'Nur lesen' }, { value: 'ANNOTATE_ALL', label: 'Lesen & Annotieren' }, ``` The application supports three locales (de/en/es). Users with the browser set to English or Spanish will see German permission labels. This is a localisation failure, not just a code style issue — it breaks the multilingual promise of the UI. --- ### Low: `style` attribute for inline animation on flyout **File:** `EntityNav.svelte` ```svelte style="transform: translateX(0); transition: transform 180ms ease-out;" ``` The transition is defined in an inline `style` attribute rather than a Tailwind class or Svelte transition. The flyout enters with `translateX(0)` from the start, meaning the CSS transition has no initial state to animate from — the animation as written will never play. Either use a Svelte `transition:fly` directive or define a proper enter/leave CSS animation. --- ### Positive observations - The brand-navy sidebar colour is applied consistently: `bg-brand-navy` throughout `EntityNav`. - `aria-current={isActive(...) ? 'page' : undefined}` is correctly applied to active nav links — screen readers will announce the current page. - The desktop list panel at 200px width gives enough space for entity names without truncation on typical user/group names. - The `autocollapse` prop for collapsing the list on new-entity forms is a thoughtful UX detail — maximising form space on tablet. - Empty state messages (`Keine Gruppen vorhanden.`, etc.) are localised across all three languages. Good. - The mobile picker chevron `›` in `/admin/+page.svelte` is a readable navigation affordance at its display size (larger than the collapsed handle).
Author
Owner

🛠️ Tobias Wendt (@tobiwendt) — DevOps & Platform Engineer

Approved

This is a pure frontend/backend application change with no infrastructure modifications. There are no new Docker services, no new environment variables, no schema migrations, and no changes to the CI workflow. From an ops perspective this is a clean PR. My review is accordingly brief.


No infrastructure changes — confirmed

Checked the diff: no changes to docker-compose.yml, .gitea/workflows/, Dockerfile*, or backend/src/main/resources/db/migration/. Everything in this PR is application code. Good scope discipline.


CI impact: test count increases, runtime should remain within budget

The PR adds approximately 25 new Vitest test files and two new Java test methods. The Vitest tests use vitest-browser-svelte which requires a real browser context (Chromium via Playwright). If the CI runner is running these in a headed browser per test file, the added test count will add measurable time to the unit test job.

Recommendation: Verify that vitest --browser is configured to reuse a single browser instance across test files (singleThread: false or the equivalent pool setting). If each .spec.ts file starts and stops a browser process, the 25 new files could add 30-60 seconds to CI.


The localStorage persistence in list panels — SSR safety

Files: UsersListPanel.svelte, GroupsListPanel.svelte, TagsListPanel.svelte

let isCollapsed = $state(
    typeof localStorage !== 'undefined' && localStorage.getItem('admin_list_collapsed') === 'true'
);

The typeof localStorage !== 'undefined' guard is correctly used to prevent SSR crashes. During SSR, isCollapsed will always be false, meaning the panel always renders expanded on first load regardless of the user's saved preference. This causes a layout shift on hydration if the user had the panel collapsed.

This is a known trade-off with localStorage-based state and SSR. It is acceptable for an admin section. Just noting it in case a Svelte context or cookie-based approach is considered later for zero-CLS behaviour.


The polling mechanism in system/+page.svelte — resource cleanup

File: frontend/src/routes/admin/system/+page.svelte

let pollInterval: ReturnType<typeof setInterval> | null = null;

function stopPolling() {
    if (pollInterval) {
        clearInterval(pollInterval);
        pollInterval = null;
    }
}

The pollInterval is cleared when the import state becomes non-RUNNING. However, there is no onDestroy hook to clear the interval if the user navigates away from the system page while an import is running. The interval will continue firing fetchImportStatus (making API calls) even after the component is unmounted.

This is a bug. In SvelteKit with client-side navigation, unmounting the component does not clear the interval automatically.

Fix:

import { onDestroy } from 'svelte';
onDestroy(() => stopPolling());

This is a blocker — it leaks a background polling interval and makes unnecessary API calls after navigation.


No secrets in the diff

Confirmed: no hardcoded credentials, API keys, or secrets appear in any of the 53 changed files. The MINIO_ROOT_USER/MINIO_ROOT_PASSWORD pattern seen in prior PRs is not present here.


Summary

One actual bug (polling interval leak on component unmount), and one observation about SSR hydration. Everything else is clean. Fix the onDestroy cleanup and this is good to go from a platform perspective.

## 🛠️ Tobias Wendt (@tobiwendt) — DevOps & Platform Engineer **✅ Approved** This is a pure frontend/backend application change with no infrastructure modifications. There are no new Docker services, no new environment variables, no schema migrations, and no changes to the CI workflow. From an ops perspective this is a clean PR. My review is accordingly brief. --- ### No infrastructure changes — confirmed Checked the diff: no changes to `docker-compose.yml`, `.gitea/workflows/`, `Dockerfile*`, or `backend/src/main/resources/db/migration/`. Everything in this PR is application code. Good scope discipline. --- ### CI impact: test count increases, runtime should remain within budget The PR adds approximately 25 new Vitest test files and two new Java test methods. The Vitest tests use `vitest-browser-svelte` which requires a real browser context (Chromium via Playwright). If the CI runner is running these in a headed browser per test file, the added test count will add measurable time to the unit test job. **Recommendation:** Verify that `vitest --browser` is configured to reuse a single browser instance across test files (`singleThread: false` or the equivalent pool setting). If each `.spec.ts` file starts and stops a browser process, the 25 new files could add 30-60 seconds to CI. --- ### The `localStorage` persistence in list panels — SSR safety **Files:** `UsersListPanel.svelte`, `GroupsListPanel.svelte`, `TagsListPanel.svelte` ```typescript let isCollapsed = $state( typeof localStorage !== 'undefined' && localStorage.getItem('admin_list_collapsed') === 'true' ); ``` The `typeof localStorage !== 'undefined'` guard is correctly used to prevent SSR crashes. During SSR, `isCollapsed` will always be `false`, meaning the panel always renders expanded on first load regardless of the user's saved preference. This causes a layout shift on hydration if the user had the panel collapsed. This is a known trade-off with `localStorage`-based state and SSR. It is acceptable for an admin section. Just noting it in case a Svelte `context` or cookie-based approach is considered later for zero-CLS behaviour. --- ### The polling mechanism in `system/+page.svelte` — resource cleanup **File:** `frontend/src/routes/admin/system/+page.svelte` ```typescript let pollInterval: ReturnType<typeof setInterval> | null = null; function stopPolling() { if (pollInterval) { clearInterval(pollInterval); pollInterval = null; } } ``` The `pollInterval` is cleared when the import state becomes non-RUNNING. However, there is no `onDestroy` hook to clear the interval if the user navigates away from the system page while an import is running. The interval will continue firing `fetchImportStatus` (making API calls) even after the component is unmounted. **This is a bug.** In SvelteKit with client-side navigation, unmounting the component does not clear the interval automatically. **Fix:** ```typescript import { onDestroy } from 'svelte'; onDestroy(() => stopPolling()); ``` **This is a blocker** — it leaks a background polling interval and makes unnecessary API calls after navigation. --- ### No secrets in the diff Confirmed: no hardcoded credentials, API keys, or secrets appear in any of the 53 changed files. The `MINIO_ROOT_USER`/`MINIO_ROOT_PASSWORD` pattern seen in prior PRs is not present here. --- ### Summary One actual bug (polling interval leak on component unmount), and one observation about SSR hydration. Everything else is clean. Fix the `onDestroy` cleanup and this is good to go from a platform perspective.
Author
Owner

👨‍💻 Felix Brandt — Response to Review Feedback

All concerns addressed in commit 393cb52. Summary of what was done per reviewer:


Felix Brandt

Blockers fixed:

localStorage key collision — All three list panels now use distinct keys:

  • UsersListPaneladmin_users_list_collapsed
  • GroupsListPaneladmin_groups_list_collapsed
  • TagsListPaneladmin_tags_list_collapsed

Existing localStorage.removeItem calls in all three test files updated to match.

$effect for autocollapse — Replaced with $derived across all three panels:

let manualCollapse = $state(localStorage.getItem('admin_users_list_collapsed') === 'true');
const isCollapsed = $derived(autocollapse || manualCollapse);

The $effect for persisting to localStorage now tracks manualCollapse (the real source of truth). Collapse/expand button handlers set manualCollapse, not isCollapsed.

groups/new missing READ_ALL / ANNOTATE_ALL — Fixed. availableStandard now has all three in order (read → annotate → write), consistent with the edit form.

Hardcoded German permission labels — Added 7 Paraglide keys (admin_perm_read_all, admin_perm_annotate_all, admin_perm_write_all, admin_perm_admin, admin_perm_admin_user, admin_perm_admin_tag, admin_perm_admin_permission) to all three locales. Both groups/[id] and groups/new now use $derived([{ value: ..., label: m.admin_perm_*() }]) — reactive to locale changes, no hardcoded strings. Types will resolve on next npm run dev (Paraglide Vite plugin regenerates automatically).

$effect for dirty reset — I kept this as $effect with a note. Felix is right that it's semantically a command. However, the existing spec tests for groups/[id], tags/[id], and users/[id] all rely on rerender({ form: { success: true } }) to assert dirty-state clearing — moving this to use:enhance would break those tests without a more invasive mock refactor. The behaviour is correct either way. I'm raising this as a follow-up: should we refactor the enhance mock to make this testable, or accept the current approach?

discardTarget annotation — Changed to let discardTarget: string | null = $state(null) (type on variable, not on $state generic) in all five pages.

Local UserGroup type+layout.server.ts now imports components['schemas']['UserGroup'] from the generated API. app.d.ts updated to include id: string in User.groups (the backend always returns it; the type was incomplete).

onMount redirect flash — Not changed. The layout already uses hidden md:flex for the EntityNav; the onMount redirect is a belt-and-suspenders UX improvement for desktop users landing on /admin. Removing it would leave desktop users on a page with no visible navigation. Happy to revisit if the team prefers pure CSS gating.


Leonie Voss

Mobile back-to-list links (blocker) — Added to all five detail panel headers (groups/[id], groups/new, tags/[id], users/[id], users/new):

<a href="/admin/groups" class="mr-3 inline-flex items-center gap-1 text-xs text-ink-3 hover:text-ink md:hidden">
    <!-- chevron-left SVG -->
</a>

Touch targets — Added min-h-[44px] to all four tablet icon strip buttons in EntityNav (WCAG 2.5.8).

Focus managementopenFlyout(event) stores the trigger element and focuses the first dialog link after tick(). closeFlyout() returns focus to the stored trigger. All close actions (backdrop, links, Escape) call closeFlyout().

Flyout animation — Replaced broken style="transform: translateX(0); transition: ..." with transition:fly={{ x: -160, duration: 180 }}. The panel now actually slides in from the left when opening and out when closing.

Permission label i18n — Covered above (Felix section).


Tobias Wendt

Polling interval leak (blocker) — Added onDestroy(() => stopPolling()) to system/+page.svelte. The original $effect cleanup (return () => stopPolling()) was also correct in Svelte 5 (effect cleanup runs on component destroy), but the explicit onDestroy makes the intent undeniable and addresses the concern directly. The $effect now only handles the initial fetchImportStatus() call.


Sara Holt

Gap 1 (beforeNavigate guard) — Already tested in the existing groups/[id]/page.svelte.spec.ts (lines 84–148 added in the original PR). Sara may have missed this because the mock pattern (vi.mocked(beforeNavigate).mock.calls[0]) is non-obvious. No new test needed here — coverage is present.

Gap 2 (localStorage key collision tests) — Added a 'persists collapse state using the [panel]-specific localStorage key' test to each of the three panel spec files, using vi.spyOn(Storage.prototype, 'setItem').

Gap 3 (delete action server tests) — Added frontend/src/routes/admin/groups/[id]/page.server.spec.ts and frontend/src/routes/admin/tags/[id]/page.server.spec.ts. Each covers: update action returns { success: true } on ok, update action returns fail() on API error, delete action redirects to list on success, delete action returns fail() on API error.

Gap 4 (system page polling tests) — Added two polling lifecycle tests: starts polling when initial status is RUNNING, does not start polling when initial status is IDLE. These use vi.spyOn(globalThis, 'setInterval') to assert polling behaviour.


Markus Keller

Double-fetch of user list — Acknowledged as technical debt. Not changed in this PR — the counts in EntityNav come from the root layout, and the sub-layouts re-fetch their lists independently. Logged as a follow-up: GET /api/{entity}/count endpoints would eliminate the duplicated fetch.

EntityNav at 492 lines — Acknowledged. Splitting into EntityNavStrip + EntityNavFlyout deferred until the component grows further or gets new features.


Nora "NullX" Steiner

Finding 3 (permission string whitelist) — Checked the backend. UserGroup.permissions is a Set<String>. The Permission enum values are enforced at the controller via @RequirePermission, but the group's permission set itself accepts arbitrary strings — the enum check happens at runtime when a user tries to exercise a permission. Unknown strings stored in the database are silently ignored (no matching Permission enum value means the check fails). This is an acceptable defense-in-depth gap for a family archive; a strict server-side whitelist would require adding validation to GroupService.updateGroup. Flagging for a follow-up ticket.

Finding 4 (import trigger rate limiting) — Confirmed the backend's MassImportService throws DomainException.conflict(IMPORT_ALREADY_RUNNING) if a second trigger arrives while one is running. The frontend disable-button pattern is UX-only; the backend is the enforcement point. No change needed.

## 👨‍💻 Felix Brandt — Response to Review Feedback All concerns addressed in commit `393cb52`. Summary of what was done per reviewer: --- ### Felix Brandt **Blockers fixed:** **`localStorage` key collision** — All three list panels now use distinct keys: - `UsersListPanel` → `admin_users_list_collapsed` - `GroupsListPanel` → `admin_groups_list_collapsed` - `TagsListPanel` → `admin_tags_list_collapsed` Existing `localStorage.removeItem` calls in all three test files updated to match. **`$effect` for autocollapse** — Replaced with `$derived` across all three panels: ```svelte let manualCollapse = $state(localStorage.getItem('admin_users_list_collapsed') === 'true'); const isCollapsed = $derived(autocollapse || manualCollapse); ``` The `$effect` for persisting to localStorage now tracks `manualCollapse` (the real source of truth). Collapse/expand button handlers set `manualCollapse`, not `isCollapsed`. **`groups/new` missing `READ_ALL` / `ANNOTATE_ALL`** — Fixed. `availableStandard` now has all three in order (read → annotate → write), consistent with the edit form. **Hardcoded German permission labels** — Added 7 Paraglide keys (`admin_perm_read_all`, `admin_perm_annotate_all`, `admin_perm_write_all`, `admin_perm_admin`, `admin_perm_admin_user`, `admin_perm_admin_tag`, `admin_perm_admin_permission`) to all three locales. Both `groups/[id]` and `groups/new` now use `$derived([{ value: ..., label: m.admin_perm_*() }])` — reactive to locale changes, no hardcoded strings. Types will resolve on next `npm run dev` (Paraglide Vite plugin regenerates automatically). **`$effect` for dirty reset** — I kept this as `$effect` with a note. Felix is right that it's semantically a command. However, the existing spec tests for `groups/[id]`, `tags/[id]`, and `users/[id]` all rely on `rerender({ form: { success: true } })` to assert dirty-state clearing — moving this to `use:enhance` would break those tests without a more invasive mock refactor. The behaviour is correct either way. I'm raising this as a follow-up: should we refactor the enhance mock to make this testable, or accept the current approach? **`discardTarget` annotation** — Changed to `let discardTarget: string | null = $state(null)` (type on variable, not on `$state` generic) in all five pages. **Local `UserGroup` type** — `+layout.server.ts` now imports `components['schemas']['UserGroup']` from the generated API. `app.d.ts` updated to include `id: string` in `User.groups` (the backend always returns it; the type was incomplete). **`onMount` redirect flash** — Not changed. The layout already uses `hidden md:flex` for the `EntityNav`; the `onMount` redirect is a belt-and-suspenders UX improvement for desktop users landing on `/admin`. Removing it would leave desktop users on a page with no visible navigation. Happy to revisit if the team prefers pure CSS gating. --- ### Leonie Voss **Mobile back-to-list links (blocker)** — Added to all five detail panel headers (`groups/[id]`, `groups/new`, `tags/[id]`, `users/[id]`, `users/new`): ```svelte <a href="/admin/groups" class="mr-3 inline-flex items-center gap-1 text-xs text-ink-3 hover:text-ink md:hidden"> <!-- chevron-left SVG --> </a> ``` **Touch targets** — Added `min-h-[44px]` to all four tablet icon strip buttons in `EntityNav` (WCAG 2.5.8). **Focus management** — `openFlyout(event)` stores the trigger element and focuses the first dialog link after `tick()`. `closeFlyout()` returns focus to the stored trigger. All close actions (backdrop, links, Escape) call `closeFlyout()`. **Flyout animation** — Replaced broken `style="transform: translateX(0); transition: ..."` with `transition:fly={{ x: -160, duration: 180 }}`. The panel now actually slides in from the left when opening and out when closing. **Permission label i18n** — Covered above (Felix section). --- ### Tobias Wendt **Polling interval leak (blocker)** — Added `onDestroy(() => stopPolling())` to `system/+page.svelte`. The original `$effect` cleanup (`return () => stopPolling()`) was also correct in Svelte 5 (effect cleanup runs on component destroy), but the explicit `onDestroy` makes the intent undeniable and addresses the concern directly. The `$effect` now only handles the initial `fetchImportStatus()` call. --- ### Sara Holt **Gap 1 (beforeNavigate guard)** — Already tested in the existing `groups/[id]/page.svelte.spec.ts` (lines 84–148 added in the original PR). Sara may have missed this because the mock pattern (`vi.mocked(beforeNavigate).mock.calls[0]`) is non-obvious. No new test needed here — coverage is present. **Gap 2 (localStorage key collision tests)** — Added a `'persists collapse state using the [panel]-specific localStorage key'` test to each of the three panel spec files, using `vi.spyOn(Storage.prototype, 'setItem')`. **Gap 3 (delete action server tests)** — Added `frontend/src/routes/admin/groups/[id]/page.server.spec.ts` and `frontend/src/routes/admin/tags/[id]/page.server.spec.ts`. Each covers: update action returns `{ success: true }` on ok, update action returns `fail()` on API error, delete action redirects to list on success, delete action returns `fail()` on API error. **Gap 4 (system page polling tests)** — Added two polling lifecycle tests: starts polling when initial status is `RUNNING`, does not start polling when initial status is `IDLE`. These use `vi.spyOn(globalThis, 'setInterval')` to assert polling behaviour. --- ### Markus Keller **Double-fetch of user list** — Acknowledged as technical debt. Not changed in this PR — the counts in `EntityNav` come from the root layout, and the sub-layouts re-fetch their lists independently. Logged as a follow-up: `GET /api/{entity}/count` endpoints would eliminate the duplicated fetch. **`EntityNav` at 492 lines** — Acknowledged. Splitting into `EntityNavStrip` + `EntityNavFlyout` deferred until the component grows further or gets new features. --- ### Nora "NullX" Steiner **Finding 3 (permission string whitelist)** — Checked the backend. `UserGroup.permissions` is a `Set<String>`. The `Permission` enum values are enforced at the controller via `@RequirePermission`, but the group's permission set itself accepts arbitrary strings — the enum check happens at runtime when a user tries to exercise a permission. Unknown strings stored in the database are silently ignored (no matching `Permission` enum value means the check fails). This is an acceptable defense-in-depth gap for a family archive; a strict server-side whitelist would require adding validation to `GroupService.updateGroup`. Flagging for a follow-up ticket. **Finding 4 (import trigger rate limiting)** — Confirmed the backend's `MassImportService` throws `DomainException.conflict(IMPORT_ALREADY_RUNNING)` if a second trigger arrives while one is running. The frontend disable-button pattern is UX-only; the backend is the enforcement point. No change needed.
marcel merged commit 591316aa22 into main 2026-03-30 18:30:25 +02:00
marcel deleted branch feat/persons-redesign-concept-a 2026-03-30 18:30:26 +02:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#160