feat(admin): informative empty states on master-detail pages (Users · Groups · Tags · Invites) #326

Open
opened 2026-04-24 13:26:41 +02:00 by marcel · 10 comments
Owner

Context

Every /admin/* master-detail page shows the same static empty state on the right pane when no item is selected:

"Select a user from the list."

(and the analogous variants for groups, tags, invites). Captured in screenshots 10-admin-* during Phase B2 audit on 2026-04-24. This is:

  • Dead space on wide screens (most of the admin viewport is empty).
  • Unhelpful for first-time admins — nothing guides them on what can actually be done.
  • A missed surface for a count, a CTA, or a pointer to related work.

Non-goals

  • No change to the select/detail behaviour when an item IS selected.
  • No dashboard-level aggregation across admin areas — that's #324.
  • No auto-select of the first list item (considered and rejected — an admin who forgets what they were looking at would be confused).

Proposed design

Replace the generic "Select a … from the list." with a small contextual panel:

Users empty state

  • Heading: "Benutzerverwaltung"
  • Total count: "5 aktive Benutzer · 0 ausstehende Einladungen"
  • Primary CTA: + Neuen Benutzer einladen
  • Secondary links:
    • "Gruppen & Rollen verwalten" → /admin/groups
    • "Berechtigungen-Übersicht" → (if one exists, else skip)
  • Recent activity (3 items): "Neuer Benutzer: …", "Berechtigung geändert: …"

Groups empty state

  • Heading: "Gruppen"
  • Total count: "3 Gruppen: Administrators (7 Rechte), Editor (3 Rechte), Leser (1 Recht)"
  • Primary CTA: + Neue Gruppe
  • Secondary: "Benutzer-zu-Gruppen-Zuordnung ansehen" → /admin/users

Tags empty state

  • Heading: "Schlagwörter"
  • Total count: "25 Schlagwörter gesamt · 8 Oberbegriffe · 17 Unterbegriffe"
  • Per-root breakdown (all roots with at least one child, sorted by child count desc):
    "Ereignisse (7) · Dokumententyp (6) · Reise (3) · Alltag (1)"
  • Orphan signal: "2 Schlagwörter ohne Dokument" — purely informational, no click action
  • Top-3 most used: "Meist verwendet: Brautbriefe (129) · Kondolenz (60) · Enkelkinder an Eugenie (48)"
  • Primary CTA: + Neues Schlagwort

Invites empty state

  • Heading: "Einladungen"
  • Total count: "0 ausstehende Einladungen"
  • Primary CTA: + Einladung versenden
  • If any pending: "Alle widerrufen" destructive action behind a confirm modal

Implementation plan

Frontend

  • New frontend/src/lib/components/AdminEmptyState.svelte:
    • Props: kind: 'users' | 'groups' | 'tags' | 'invites', stats: { … }, primaryCta: { label, href }, secondaryLinks: { label, href }[], recentActivity?: { … }[].
    • Renders title + stats + CTA + secondary links + (optional) recent activity list.
  • Each of /admin/users/+page.svelte, /admin/groups/+page.svelte, /admin/tags/+page.svelte, /admin/invites/+page.svelte:
    • When selectedItem === null, render <AdminEmptyState kind="..." stats={…} primaryCta={…} … />.
    • The +page.server.ts loader fetches the small stats blob alongside the list (one extra query per page).

Backend

Each page already loads its list — extend each list endpoint (or introduce a small /stats sub-endpoint) so counts are available without a separate round-trip:

  • /api/admin/users returns { items, pendingInvites, recentActivity } where activity is the existing audit log (if any) or an empty array if not.
  • /api/admin/groups returns { items } — no new data; permission counts already in the group payload.
  • /api/admin/tags returns { items, stats: { total, rootCount, childCount, orphanCount, topTags: [{ name, docCount }×3], rootsWithChildren: [{ name, childCount }] } }.
  • /api/admin/invites returns { items, pendingCount }.

All permission-gated to ADMIN.

i18n

12–15 new Paraglide keys covering all four empty-state headings, CTAs, secondary labels, and the small activity blurbs.

Tests

  • Component: AdminEmptyState renders each kind with sample data; CTA click dispatches navigation.
  • E2E: visit /admin/users with no selection, confirm empty state visible with CTA. Repeat for each of the four.

Verification

Manual walk: click into each admin sub-route with no item selected; observe the contextual empty state, the counts match the master list, and the CTA lands where expected.

Acceptance criteria

  • AdminEmptyState component exists and is reused by all four pages
  • Each empty state shows: heading, count, primary CTA, secondary link(s)
  • Counts come from the server, not hardcoded
  • Stats refresh when the master list changes (e.g. delete a user → count drops)
  • i18n complete for de/en/es
  • No regression on the selected-item view
  • axe-core passes on all four empty-state pages

Critical files

frontend/src/lib/components/AdminEmptyState.svelte                          (new)
frontend/src/routes/admin/users/+page.svelte                                (wire)
frontend/src/routes/admin/users/+page.server.ts                             (stats)
frontend/src/routes/admin/groups/+page.svelte                               (wire)
frontend/src/routes/admin/groups/+page.server.ts                            (stats)
frontend/src/routes/admin/tags/+page.svelte                                 (wire)
frontend/src/routes/admin/tags/+page.server.ts                              (stats)
frontend/src/routes/admin/invites/+page.svelte                              (wire)
frontend/src/routes/admin/invites/+page.server.ts                           (stats)
backend/src/main/java/org/raddatz/familienarchiv/controller/*               (extend list payloads)
frontend/messages/{de,en,es}.json
  • #324 (admin dashboard) — this issue is the sub-page complement.
  • #325 (tag taxonomy) — closed/dropped; tags remain a flat freeform tree. The tags empty state above is self-contained and has no dependency on #325.
## Context Every `/admin/*` master-detail page shows the same static empty state on the right pane when no item is selected: > "Select a user from the list." (and the analogous variants for groups, tags, invites). Captured in screenshots `10-admin-*` during Phase B2 audit on 2026-04-24. This is: - **Dead space** on wide screens (most of the admin viewport is empty). - **Unhelpful** for first-time admins — nothing guides them on what can actually be done. - **A missed surface** for a count, a CTA, or a pointer to related work. ## Non-goals - No change to the select/detail behaviour when an item IS selected. - No dashboard-level aggregation across admin areas — that's #324. - No auto-select of the first list item (considered and rejected — an admin who forgets what they were looking at would be confused). ## Proposed design Replace the generic "Select a … from the list." with a small contextual panel: ### Users empty state - Heading: "Benutzerverwaltung" - Total count: "5 aktive Benutzer · 0 ausstehende Einladungen" - Primary CTA: **+ Neuen Benutzer einladen** - Secondary links: - "Gruppen & Rollen verwalten" → `/admin/groups` - "Berechtigungen-Übersicht" → (if one exists, else skip) - Recent activity (3 items): "Neuer Benutzer: …", "Berechtigung geändert: …" ### Groups empty state - Heading: "Gruppen" - Total count: "3 Gruppen: Administrators (7 Rechte), Editor (3 Rechte), Leser (1 Recht)" - Primary CTA: **+ Neue Gruppe** - Secondary: "Benutzer-zu-Gruppen-Zuordnung ansehen" → `/admin/users` ### Tags empty state - Heading: "Schlagwörter" - Total count: "25 Schlagwörter gesamt · 8 Oberbegriffe · 17 Unterbegriffe" - Per-root breakdown (all roots with at least one child, sorted by child count desc): "Ereignisse (7) · Dokumententyp (6) · Reise (3) · Alltag (1)" - Orphan signal: "2 Schlagwörter ohne Dokument" — purely informational, no click action - Top-3 most used: "Meist verwendet: Brautbriefe (129) · Kondolenz (60) · Enkelkinder an Eugenie (48)" - Primary CTA: **+ Neues Schlagwort** ### Invites empty state - Heading: "Einladungen" - Total count: "0 ausstehende Einladungen" - Primary CTA: **+ Einladung versenden** - If any pending: "Alle widerrufen" destructive action behind a confirm modal ## Implementation plan ### Frontend - New `frontend/src/lib/components/AdminEmptyState.svelte`: - Props: `kind: 'users' | 'groups' | 'tags' | 'invites'`, `stats: { … }`, `primaryCta: { label, href }`, `secondaryLinks: { label, href }[]`, `recentActivity?: { … }[]`. - Renders title + stats + CTA + secondary links + (optional) recent activity list. - Each of `/admin/users/+page.svelte`, `/admin/groups/+page.svelte`, `/admin/tags/+page.svelte`, `/admin/invites/+page.svelte`: - When `selectedItem === null`, render `<AdminEmptyState kind="..." stats={…} primaryCta={…} … />`. - The `+page.server.ts` loader fetches the small stats blob alongside the list (one extra query per page). ### Backend Each page already loads its list — extend each list endpoint (or introduce a small `/stats` sub-endpoint) so counts are available without a separate round-trip: - `/api/admin/users` returns `{ items, pendingInvites, recentActivity }` where activity is the existing audit log (if any) or an empty array if not. - `/api/admin/groups` returns `{ items }` — no new data; permission counts already in the group payload. - `/api/admin/tags` returns `{ items, stats: { total, rootCount, childCount, orphanCount, topTags: [{ name, docCount }×3], rootsWithChildren: [{ name, childCount }] } }`. - `/api/admin/invites` returns `{ items, pendingCount }`. All permission-gated to `ADMIN`. ### i18n 12–15 new Paraglide keys covering all four empty-state headings, CTAs, secondary labels, and the small activity blurbs. ## Tests - **Component:** `AdminEmptyState` renders each kind with sample data; CTA click dispatches navigation. - **E2E:** visit `/admin/users` with no selection, confirm empty state visible with CTA. Repeat for each of the four. ## Verification Manual walk: click into each admin sub-route with no item selected; observe the contextual empty state, the counts match the master list, and the CTA lands where expected. ## Acceptance criteria - [ ] `AdminEmptyState` component exists and is reused by all four pages - [ ] Each empty state shows: heading, count, primary CTA, secondary link(s) - [ ] Counts come from the server, not hardcoded - [ ] Stats refresh when the master list changes (e.g. delete a user → count drops) - [ ] i18n complete for de/en/es - [ ] No regression on the selected-item view - [ ] axe-core passes on all four empty-state pages ## Critical files ``` frontend/src/lib/components/AdminEmptyState.svelte (new) frontend/src/routes/admin/users/+page.svelte (wire) frontend/src/routes/admin/users/+page.server.ts (stats) frontend/src/routes/admin/groups/+page.svelte (wire) frontend/src/routes/admin/groups/+page.server.ts (stats) frontend/src/routes/admin/tags/+page.svelte (wire) frontend/src/routes/admin/tags/+page.server.ts (stats) frontend/src/routes/admin/invites/+page.svelte (wire) frontend/src/routes/admin/invites/+page.server.ts (stats) backend/src/main/java/org/raddatz/familienarchiv/controller/* (extend list payloads) frontend/messages/{de,en,es}.json ``` ## Related - #324 (admin dashboard) — this issue is the sub-page complement. - #325 (tag taxonomy) — closed/dropped; tags remain a flat freeform tree. The tags empty state above is self-contained and has no dependency on #325.
marcel added the P3-laterfeatureui labels 2026-04-24 13:28:15 +02:00
Author
Owner

Cross-reference fix: the "F-08 (admin dashboard)" mentioned in the body is issue #324. The "F-09 (tag taxonomy)" is issue #325.

Cross-reference fix: the "F-08 (admin dashboard)" mentioned in the body is issue #324. The "F-09 (tag taxonomy)" is issue #325.
Author
Owner

🏗️ Markus Keller — Application Architect

Observations

  • kind prop is a premature abstraction. A single component with four kind branches means adding a fifth kind requires touching AdminEmptyState.svelte itself. Each kind has different stats shapes, different secondary links, and optional recent activity — these are four distinct components pretending to be one. Consider either (a) separate components (UsersEmptyState.svelte, TagsEmptyState.svelte, etc.) composed from shared primitives, or (b) a purely data-driven component with no kind awareness that renders whatever the parent passes.

  • Tag stats need no backend change. The tags/+layout.server.ts already fetches the full tag tree via GET /api/tags/tree, including documentCount per node. Every stat in the issue can be computed frontend-side from data.tags:

    • total = data.tags.length
    • rootCount = data.tags.filter(t => !t.parentId).length
    • childCount = data.tags.filter(t => !!t.parentId).length
    • orphanCount = data.tags.filter(t => t.documentCount === 0).length
    • topTags = [...data.tags].sort((a,b) => b.documentCount - a.documentCount).slice(0, 3)
    • rootsWithChildren — group from tree structure
      Adding a backend stats endpoint for tags would be pure overhead.
  • Groups stats also need no backend change. groups/+layout.server.ts already loads data.groups, which contains the full permission sets. Count and permission breakdown are derivable frontend-side.

  • Users layout already has count; invite count is a single extra call. users/+layout.server.ts loads data.users. For the pending invite count, add api.GET('/api/invites?status=active') in the same layout loader — no new endpoint needed. The stats live in the layout server, not a +page.server.ts.

  • "Recent activity" for users requires new AuditKind values. The existing AuditKind enum covers only document/content events (FILE_UPLOADED, STATUS_CHANGED, TEXT_SAVED, etc.) — there is no USER_CREATED, PERMISSION_CHANGED, or any user-management event. Surfacing "Neuer Benutzer: …, Berechtigung geändert: …" requires new enum values AND new audit logging in UserService/GroupService. This is a substantial undiscussed scope expansion.

  • Permission inconsistency. The issue says all new stats endpoints are "gated to ADMIN", but the existing user controller uses ADMIN_USER and the tag controller uses ADMIN_TAG. Stats endpoints must use the same permission as their parent list — don't introduce a new permission level inconsistency.

Recommendations

  • Drop or defer the "recent activity" row. The existing audit infrastructure doesn't cover user management events. Adding it now expands scope significantly. Ship the empty state panel without the activity list; add a follow-up issue for user management audit events.
  • Compute tag and group stats frontend-side. Zero backend changes for tags and groups. Only users/invites need a layout-loader change (add invite count fetch).
  • Use separate named components or a pure data-driven component instead of a kind-switched monolith. The rule: if a prop controls which of four completely different templates renders, it's four components, not one.
  • If a dedicated stats sub-endpoint is added for users (user count + pending invites), match the permission ADMIN_USER. No new /stats endpoints for tags or groups — frontend derivation is simpler and avoids API contract changes.
## 🏗️ Markus Keller — Application Architect ### Observations - **`kind` prop is a premature abstraction.** A single component with four `kind` branches means adding a fifth kind requires touching `AdminEmptyState.svelte` itself. Each kind has different stats shapes, different secondary links, and optional recent activity — these are four distinct components pretending to be one. Consider either (a) separate components (`UsersEmptyState.svelte`, `TagsEmptyState.svelte`, etc.) composed from shared primitives, or (b) a purely data-driven component with no `kind` awareness that renders whatever the parent passes. - **Tag stats need no backend change.** The `tags/+layout.server.ts` already fetches the full tag tree via `GET /api/tags/tree`, including `documentCount` per node. Every stat in the issue can be computed frontend-side from `data.tags`: - `total = data.tags.length` - `rootCount = data.tags.filter(t => !t.parentId).length` - `childCount = data.tags.filter(t => !!t.parentId).length` - `orphanCount = data.tags.filter(t => t.documentCount === 0).length` - `topTags = [...data.tags].sort((a,b) => b.documentCount - a.documentCount).slice(0, 3)` - `rootsWithChildren` — group from tree structure Adding a backend stats endpoint for tags would be pure overhead. - **Groups stats also need no backend change.** `groups/+layout.server.ts` already loads `data.groups`, which contains the full permission sets. Count and permission breakdown are derivable frontend-side. - **Users layout already has count; invite count is a single extra call.** `users/+layout.server.ts` loads `data.users`. For the pending invite count, add `api.GET('/api/invites?status=active')` in the same layout loader — no new endpoint needed. The stats live in the layout server, not a `+page.server.ts`. - **"Recent activity" for users requires new AuditKind values.** The existing `AuditKind` enum covers only document/content events (`FILE_UPLOADED`, `STATUS_CHANGED`, `TEXT_SAVED`, etc.) — there is no `USER_CREATED`, `PERMISSION_CHANGED`, or any user-management event. Surfacing "Neuer Benutzer: …, Berechtigung geändert: …" requires new enum values AND new audit logging in `UserService`/`GroupService`. This is a substantial undiscussed scope expansion. - **Permission inconsistency.** The issue says all new stats endpoints are "gated to `ADMIN`", but the existing user controller uses `ADMIN_USER` and the tag controller uses `ADMIN_TAG`. Stats endpoints must use the same permission as their parent list — don't introduce a new permission level inconsistency. ### Recommendations - **Drop or defer the "recent activity" row.** The existing audit infrastructure doesn't cover user management events. Adding it now expands scope significantly. Ship the empty state panel without the activity list; add a follow-up issue for user management audit events. - **Compute tag and group stats frontend-side.** Zero backend changes for tags and groups. Only users/invites need a layout-loader change (add invite count fetch). - **Use separate named components or a pure data-driven component** instead of a `kind`-switched monolith. The rule: if a prop controls which of four completely different templates renders, it's four components, not one. - If a dedicated stats sub-endpoint is added for users (user count + pending invites), match the permission `ADMIN_USER`. No new `/stats` endpoints for tags or groups — frontend derivation is simpler and avoids API contract changes.
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Observations

  • The component has five responsibilities. AdminEmptyState.svelte as spec'd renders: (1) section heading, (2) count statistics, (3) primary CTA, (4) secondary links, (5) optional recent activity list. Five distinct concerns. If recent activity stays in scope, extract it to AdminRecentActivity.svelte. The outer shell can stay as one component for the first four — that's coherent.

  • Tag stats are pure $derived — no backend call. The tags layout already provides data.tags (flat array with documentCount). All five tag stats can be computed as $derived values in tags/+page.svelte:

    const totalTags = $derived(data.tags.length);
    const rootTags = $derived(data.tags.filter(t => !t.parentId));
    const orphanTags = $derived(data.tags.filter(t => t.documentCount === 0));
    const topTags = $derived(
      [...data.tags].sort((a,b) => b.documentCount - a.documentCount).slice(0, 3)
    );
    

    No new backend endpoint needed. No OpenAPI regen needed. No layout loader change needed.

  • Stats live in layout servers, not page servers. The issue says "the +page.server.ts loader fetches the small stats blob." But data.users, data.groups, data.tags are all loaded in their respective +layout.server.ts files. Stats for the empty state should be derived there (or computed from what's already there), not fetched a second time in +page.server.ts.

  • stats: { … } is untyped in the issue. The prop type varies per kind. Pin the TypeScript interface before implementation. The cleanest approach: don't pass a kind prop at all; pass only what the component actually uses:

    // Props for AdminEmptyState
    type Props = {
      heading: string;
      countLine: string;
      primaryCta: { label: string; href: string };
      secondaryLinks?: { label: string; href: string }[];
      recentActivity?: { label: string }[];
    };
    

    Each page constructs these from its own $derived data. The component remains kind-agnostic and reusable.

  • Pending invite count for users empty state: add api.GET('/api/invites?status=active') to users/+layout.server.ts alongside the existing users fetch. No new backend endpoint required — InviteController already supports this query.

  • Groups empty state: groups layout loads data.groups. Count is data.groups.length. Permission breakdown per group (e.g. "Administrators (7 Rechte)") is derivable from group.permissions.size. No backend change needed.

Recommendations

  • Drop the kind prop. Pass typed string/data props instead — the component stays reusable without routing logic.
  • Compute all tag and group stats in $derived — zero backend changes for those two pages.
  • Extract recent activity into its own component if it survives scope triage (Markus flags it as a scope expansion).
  • Use {#each secondaryLinks as link (link.href)} with a key expression to prevent position-based reconciliation bugs.
  • Write the component test first: render with sample props, assert heading text, CTA href, and secondary link count. Then implement.
## 👨‍💻 Felix Brandt — Senior Fullstack Developer ### Observations - **The component has five responsibilities.** `AdminEmptyState.svelte` as spec'd renders: (1) section heading, (2) count statistics, (3) primary CTA, (4) secondary links, (5) optional recent activity list. Five distinct concerns. If recent activity stays in scope, extract it to `AdminRecentActivity.svelte`. The outer shell can stay as one component for the first four — that's coherent. - **Tag stats are pure `$derived` — no backend call.** The tags layout already provides `data.tags` (flat array with `documentCount`). All five tag stats can be computed as `$derived` values in `tags/+page.svelte`: ```svelte const totalTags = $derived(data.tags.length); const rootTags = $derived(data.tags.filter(t => !t.parentId)); const orphanTags = $derived(data.tags.filter(t => t.documentCount === 0)); const topTags = $derived( [...data.tags].sort((a,b) => b.documentCount - a.documentCount).slice(0, 3) ); ``` No new backend endpoint needed. No OpenAPI regen needed. No layout loader change needed. - **Stats live in layout servers, not page servers.** The issue says "the `+page.server.ts` loader fetches the small stats blob." But `data.users`, `data.groups`, `data.tags` are all loaded in their respective `+layout.server.ts` files. Stats for the empty state should be derived there (or computed from what's already there), not fetched a second time in `+page.server.ts`. - **`stats: { … }` is untyped in the issue.** The prop type varies per kind. Pin the TypeScript interface before implementation. The cleanest approach: don't pass a `kind` prop at all; pass only what the component actually uses: ```typescript // Props for AdminEmptyState type Props = { heading: string; countLine: string; primaryCta: { label: string; href: string }; secondaryLinks?: { label: string; href: string }[]; recentActivity?: { label: string }[]; }; ``` Each page constructs these from its own `$derived` data. The component remains kind-agnostic and reusable. - **Pending invite count for users empty state:** add `api.GET('/api/invites?status=active')` to `users/+layout.server.ts` alongside the existing users fetch. No new backend endpoint required — `InviteController` already supports this query. - **Groups empty state:** groups layout loads `data.groups`. Count is `data.groups.length`. Permission breakdown per group (e.g. "Administrators (7 Rechte)") is derivable from `group.permissions.size`. No backend change needed. ### Recommendations - Drop the `kind` prop. Pass typed string/data props instead — the component stays reusable without routing logic. - Compute all tag and group stats in `$derived` — zero backend changes for those two pages. - Extract recent activity into its own component if it survives scope triage (Markus flags it as a scope expansion). - Use `{#each secondaryLinks as link (link.href)}` with a key expression to prevent position-based reconciliation bugs. - Write the component test first: render with sample props, assert heading text, CTA href, and secondary link count. Then implement.
Author
Owner

🔒 Nora Steiner — Application Security Engineer

Observations

  • "Alle widerrufen" needs proper gating. The existing per-invite revoke uses a browser confirm(). A bulk "revoke all" is a high-impact destructive action — do not reduce it to the same confirm(). It should be a proper confirmation dialog (modal) with an explicit cancel path and a clearly labeled destructive button. Browser native dialogs can't be tested by Playwright and are not screen-reader friendly.

  • AuditKind has no user management events. If "recent activity" (Neuer Benutzer, Berechtigung geändert) is implemented by extending the audit log, the new AuditKind values must NOT log sensitive data. Specifically: USER_CREATED should log the user ID and email, never the plaintext password. PERMISSION_CHANGED should log old and new group membership, not the raw permission set. Review the audit event payload design before implementation.

  • Stats endpoint permissions must match existing ones. The issue says all stats are "gated to ADMIN." Do not use ADMIN — it's broader than necessary. Use:

    • Users stats: @RequirePermission(Permission.ADMIN_USER) — same as GET /api/users
    • Tags stats: @RequirePermission(Permission.ADMIN_TAG) — same as GET /api/tags/tree
    • Groups stats: same as GET /api/groups
      Using ADMIN would silently expand the permission surface without justification.
  • Tag stats exposure: low risk. "Top 3 most used" and "orphan count" reveal tag usage patterns. In a family archive where admins already have READ_ALL, this is appropriate — no additional access is granted.

  • The invite count (pending invites) in the users empty state comes from the same GET /api/invites endpoint already gated to ADMIN_USER. No new attack surface introduced if loaded in the layout server on the server side.

  • No IDOR risk in the empty state. The panel shows aggregate counts only, no individual item IDs or names in the statistics rows. The secondary links point to known admin routes already gated. No new attack surface introduced.

Recommendations

  • Replace the per-invite confirm() for revoke with a proper <dialog> component throughout — both single-item and the planned "Alle widerrufen" bulk action. This is also a UX and a11y improvement (Leonie will flag it too).
  • Define the USER_CREATED/PERMISSION_CHANGED audit payload schema before writing audit service code — document what is and is not captured. Reference the existing payload docstrings on AuditKind as the template.
  • Use ADMIN_USER/ADMIN_TAG (not ADMIN) on any new stats endpoints. Match existing permissions exactly.
## 🔒 Nora Steiner — Application Security Engineer ### Observations - **"Alle widerrufen" needs proper gating.** The existing per-invite revoke uses a browser `confirm()`. A bulk "revoke all" is a high-impact destructive action — do not reduce it to the same `confirm()`. It should be a proper confirmation dialog (modal) with an explicit cancel path and a clearly labeled destructive button. Browser native dialogs can't be tested by Playwright and are not screen-reader friendly. - **AuditKind has no user management events.** If "recent activity" (Neuer Benutzer, Berechtigung geändert) is implemented by extending the audit log, the new `AuditKind` values must NOT log sensitive data. Specifically: `USER_CREATED` should log the user ID and email, never the plaintext password. `PERMISSION_CHANGED` should log old and new group membership, not the raw permission set. Review the audit event payload design before implementation. - **Stats endpoint permissions must match existing ones.** The issue says all stats are "gated to `ADMIN`." Do not use `ADMIN` — it's broader than necessary. Use: - Users stats: `@RequirePermission(Permission.ADMIN_USER)` — same as `GET /api/users` - Tags stats: `@RequirePermission(Permission.ADMIN_TAG)` — same as `GET /api/tags/tree` - Groups stats: same as `GET /api/groups` Using `ADMIN` would silently expand the permission surface without justification. - **Tag stats exposure: low risk.** "Top 3 most used" and "orphan count" reveal tag usage patterns. In a family archive where admins already have `READ_ALL`, this is appropriate — no additional access is granted. - **The invite count (pending invites) in the users empty state** comes from the same `GET /api/invites` endpoint already gated to `ADMIN_USER`. No new attack surface introduced if loaded in the layout server on the server side. - **No IDOR risk in the empty state.** The panel shows aggregate counts only, no individual item IDs or names in the statistics rows. The secondary links point to known admin routes already gated. No new attack surface introduced. ### Recommendations - Replace the per-invite `confirm()` for revoke with a proper `<dialog>` component throughout — both single-item and the planned "Alle widerrufen" bulk action. This is also a UX and a11y improvement (Leonie will flag it too). - Define the `USER_CREATED`/`PERMISSION_CHANGED` audit payload schema before writing audit service code — document what is and is not captured. Reference the existing payload docstrings on `AuditKind` as the template. - Use `ADMIN_USER`/`ADMIN_TAG` (not `ADMIN`) on any new stats endpoints. Match existing permissions exactly.
Author
Owner

🧪 Sara Holt — QA Engineer

Observations

  • The test plan is underspecified. "Component: renders each kind with sample data; CTA click dispatches navigation" covers the happy path only. Needed test cases:

    • Renders heading, count line, and CTA for each page variant
    • CTA href routes to the correct target
    • Secondary links render when provided, are absent when not provided
    • Recent activity list renders when non-empty, is absent when empty/omitted
    • Zero-count edge case: 0 aktive Benutzer · 0 ausstehende Einladungen renders without crash (no divide-by-zero, no undefined access)
    • Tags: orphanCount === 0 doesn't render the orphan signal line (confirm intentional suppression)
  • "Stats refresh when master list changes" is an untested acceptance criterion. This is the most fragile AC. Test it explicitly in E2E:

    1. Load /admin/users with no item selected — observe count N
    2. Create a new user via the CTA
    3. Navigate back to /admin/users with no item selected — assert count is N+1
      SvelteKit's load re-runs on invalidateAll() after form actions. Verify this actually happens — the layout loader must be invalidated, not just the page loader.
  • E2E tests require deterministic seed data. The counts displayed come from real DB data. E2E seeds must establish known state: a fixed number of users, groups, tags before asserting count strings. Document the required seed state in the test file or a shared fixture.

  • "Alle widerrufen" confirm flow is not Playwright-testable as currently designed. Browser confirm() dialogs require page.on('dialog', ...) handling in Playwright — this works but is fragile and easy to forget. If it moves to a proper <dialog> element, the test becomes straightforward getByRole('dialog') assertions.

  • Tag stat derivations need a unit test. If stats are computed as $derived values in the page component (as they should be — no backend needed), test the derivation logic in isolation. Either test the computed values via a Vitest component test with known data.tags input, or extract the computation to a pure function and unit-test that.

  • Load function test: add a +page.server test (import and call load directly) for any page variant that does make an API call (e.g., users layout adding invite count). Mock the API client; assert the returned stats shape.

Recommendations

  • Write the unit test for tag stat derivation before any implementation — treat it as a pure function computeTagStats(tags: FlatTag[]): TagStats and test it with 0 tags, 1 root tag, mixed orphan/non-orphan inputs.
  • Add a specific E2E scenario for "count updates after creation" for the users page — this is the highest-risk AC.
  • Pre-seed E2E with a fixed dataset (via reset-db.sh or a dedicated seed action) so count assertions are deterministic.
  • Replace browser confirm() for the "Alle widerrufen" action with a <dialog> — this improves testability, a11y, and UX simultaneously.
## 🧪 Sara Holt — QA Engineer ### Observations - **The test plan is underspecified.** "Component: renders each kind with sample data; CTA click dispatches navigation" covers the happy path only. Needed test cases: - Renders heading, count line, and CTA for each page variant - CTA href routes to the correct target - Secondary links render when provided, are absent when not provided - Recent activity list renders when non-empty, is absent when empty/omitted - **Zero-count edge case**: `0 aktive Benutzer · 0 ausstehende Einladungen` renders without crash (no divide-by-zero, no undefined access) - Tags: `orphanCount === 0` doesn't render the orphan signal line (confirm intentional suppression) - **"Stats refresh when master list changes" is an untested acceptance criterion.** This is the most fragile AC. Test it explicitly in E2E: 1. Load `/admin/users` with no item selected — observe count N 2. Create a new user via the CTA 3. Navigate back to `/admin/users` with no item selected — assert count is N+1 SvelteKit's `load` re-runs on `invalidateAll()` after form actions. Verify this actually happens — the layout loader must be invalidated, not just the page loader. - **E2E tests require deterministic seed data.** The counts displayed come from real DB data. E2E seeds must establish known state: a fixed number of users, groups, tags before asserting count strings. Document the required seed state in the test file or a shared fixture. - **"Alle widerrufen" confirm flow is not Playwright-testable** as currently designed. Browser `confirm()` dialogs require `page.on('dialog', ...)` handling in Playwright — this works but is fragile and easy to forget. If it moves to a proper `<dialog>` element, the test becomes straightforward `getByRole('dialog')` assertions. - **Tag stat derivations need a unit test.** If stats are computed as `$derived` values in the page component (as they should be — no backend needed), test the derivation logic in isolation. Either test the computed values via a Vitest component test with known `data.tags` input, or extract the computation to a pure function and unit-test that. - **Load function test:** add a `+page.server` test (import and call `load` directly) for any page variant that does make an API call (e.g., users layout adding invite count). Mock the API client; assert the returned stats shape. ### Recommendations - Write the unit test for tag stat derivation before any implementation — treat it as a pure function `computeTagStats(tags: FlatTag[]): TagStats` and test it with 0 tags, 1 root tag, mixed orphan/non-orphan inputs. - Add a specific E2E scenario for "count updates after creation" for the users page — this is the highest-risk AC. - Pre-seed E2E with a fixed dataset (via `reset-db.sh` or a dedicated seed action) so count assertions are deterministic. - Replace browser `confirm()` for the "Alle widerrufen" action with a `<dialog>` — this improves testability, a11y, and UX simultaneously.
Author
Owner

🎨 Leonie Voss — UX Designer & Accessibility Strategist

Observations

  • Invites page is not a master-detail layout — the AdminEmptyState pattern doesn't apply. The /admin/invites route is a full standalone table page, not a split-panel layout. There is no right-pane detail slot. The invite table already handles zero-invites with a centered empty message. Placing an AdminEmptyState there would require restructuring the entire page around a pattern it doesn't use. Either (a) drop invites from scope and handle only Users/Groups/Tags, or (b) redefine the invites "empty state" as an enhanced zero-row state within the existing table layout.

  • "Alle widerrufen" should be a proper dialog, not window.confirm(). Browser native dialogs are inaccessible to screen readers on many platforms, can't be styled to match the brand, and are unreliable for touch-first users (seniors on tablets). Use a <dialog> element with a clear heading ("Wirklich alle widerrufen?"), a cancel button, and a destructive confirm button styled in red with a ≥44px touch target.

  • Touch targets on secondary links. The secondary links ("Gruppen & Rollen verwalten →", "Berechtigungen-Übersicht →") as inline <a> tags will likely be shorter than 44px tall if given only default padding. Add min-h-[44px] flex items-center or py-2.5 to ensure WCAG 2.2 compliance for the senior audience.

  • "Orphan signal" styling. The "2 Schlagwörter ohne Dokument" line is informational — it describes a fact, not a problem. Style it with text-ink-3 (neutral gray), not amber or red. Do not add a warning icon. A tag existing without any associated document is normal during onboarding.

  • Heading hierarchy. The <h2> heading inside the empty state panel ("Benutzerverwaltung", "Gruppen", etc.) must fit within the existing page heading structure. Check what heading level the surrounding admin layout uses — if the layout has an implicit <h1> (via the nav bar or a visually-hidden heading), the panel heading should be <h2>. If the layout has no heading, the panel heading can be <h1>.

  • Mobile is already handled. The existing +layout.svelte files for users, groups, and tags already implement the mobile split: list-only on /admin/users, detail-only when a child route is active. The empty state lives in the detail slot — it will correctly be hidden on mobile when the list is shown. No additional responsive work is required.

  • CTA button styling. Use bg-primary text-primary-fg tokens (consistent with the existing "Neuen Benutzer einladen" and "+ Neue Gruppe" buttons in the admin area), not custom colors.

  • Brand-consistent card container. The empty state panel should use the project's card pattern: bg-white shadow-sm border border-brand-sand rounded-sm p-6. Do not introduce new shadow or border colors.

Recommendations

  • Remove invites from this issue's scope, or redefine its empty state as the existing table's zero-row case with an enhanced inline CTA (no structural change to the page needed).
  • Replace window.confirm() on the revoke flow with a <dialog>-based confirmation — this is a prerequisite for the bulk "Alle widerrufen" action being accessible.
  • Add aria-live="polite" to the count stats line if counts update client-side after a mutation, so screen readers announce the change.
  • Wrap the empty state panel in a <section aria-label="..."> landmark with a localized label matching the heading text — gives screen reader users a named navigation target.
## 🎨 Leonie Voss — UX Designer & Accessibility Strategist ### Observations - **Invites page is not a master-detail layout — the AdminEmptyState pattern doesn't apply.** The `/admin/invites` route is a full standalone table page, not a split-panel layout. There is no right-pane detail slot. The invite table already handles zero-invites with a centered empty message. Placing an `AdminEmptyState` there would require restructuring the entire page around a pattern it doesn't use. Either (a) drop invites from scope and handle only Users/Groups/Tags, or (b) redefine the invites "empty state" as an enhanced zero-row state within the existing table layout. - **"Alle widerrufen" should be a proper dialog, not `window.confirm()`.** Browser native dialogs are inaccessible to screen readers on many platforms, can't be styled to match the brand, and are unreliable for touch-first users (seniors on tablets). Use a `<dialog>` element with a clear heading ("Wirklich alle widerrufen?"), a cancel button, and a destructive confirm button styled in red with a ≥44px touch target. - **Touch targets on secondary links.** The secondary links ("Gruppen & Rollen verwalten →", "Berechtigungen-Übersicht →") as inline `<a>` tags will likely be shorter than 44px tall if given only default padding. Add `min-h-[44px] flex items-center` or `py-2.5` to ensure WCAG 2.2 compliance for the senior audience. - **"Orphan signal" styling.** The "2 Schlagwörter ohne Dokument" line is informational — it describes a fact, not a problem. Style it with `text-ink-3` (neutral gray), not amber or red. Do not add a warning icon. A tag existing without any associated document is normal during onboarding. - **Heading hierarchy.** The `<h2>` heading inside the empty state panel ("Benutzerverwaltung", "Gruppen", etc.) must fit within the existing page heading structure. Check what heading level the surrounding admin layout uses — if the layout has an implicit `<h1>` (via the nav bar or a visually-hidden heading), the panel heading should be `<h2>`. If the layout has no heading, the panel heading can be `<h1>`. - **Mobile is already handled.** The existing `+layout.svelte` files for users, groups, and tags already implement the mobile split: list-only on `/admin/users`, detail-only when a child route is active. The empty state lives in the detail slot — it will correctly be hidden on mobile when the list is shown. No additional responsive work is required. - **CTA button styling.** Use `bg-primary text-primary-fg` tokens (consistent with the existing "Neuen Benutzer einladen" and "+ Neue Gruppe" buttons in the admin area), not custom colors. - **Brand-consistent card container.** The empty state panel should use the project's card pattern: `bg-white shadow-sm border border-brand-sand rounded-sm p-6`. Do not introduce new shadow or border colors. ### Recommendations - Remove invites from this issue's scope, or redefine its empty state as the existing table's zero-row case with an enhanced inline CTA (no structural change to the page needed). - Replace `window.confirm()` on the revoke flow with a `<dialog>`-based confirmation — this is a prerequisite for the bulk "Alle widerrufen" action being accessible. - Add `aria-live="polite"` to the count stats line if counts update client-side after a mutation, so screen readers announce the change. - Wrap the empty state panel in a `<section aria-label="...">` landmark with a localized label matching the heading text — gives screen reader users a named navigation target.
Author
Owner

🖥️ Tobias Wendt — DevOps & Platform Engineer

Observations

  • No new infrastructure required. This is purely application-layer work — no new Docker services, no new volumes, no schema migrations. The existing PostgreSQL 16 instance handles all stat queries via the TagRepository CTEs and AppUserRepository.

  • OpenAPI regeneration required if any backend endpoint changes. If a stats sub-endpoint is added for users (e.g. GET /api/admin/users/stats), the sequence must be followed: build the JAR (./mvnw clean package -DskipTests), start with --spring.profiles.active=dev, run npm run generate:api. If tag and group stats are computed frontend-side from existing layout data (recommended), no regen needed for those.

  • Frontend-side tag stats computation eliminates a backend query entirely. This is the right call at current scale (25 tags). The recursive CTE queries in TagRepository already run on every page load for the tree view — no additional DB round-trips.

  • No Flyway migration needed. All stats are derived from existing tables and columns. No new schema changes.

  • The pending invite count for the users empty state is a single additional GET /api/invites?status=active call in users/+layout.server.ts. This is already served by the existing InviteController — no new endpoint. Ensure the layout server handles a failure gracefully (non-fatal: show "–" for the invite count if the request fails, don't let it block the page load).

  • No observability gaps introduced. The new stat calls use the same Actuator health and Spring Boot metrics as existing API calls. No additional dashboards or alerts needed.

Recommendations

  • If the users layout loader is modified to add the invite count fetch, run both requests in parallel using Promise.all([api.GET('/api/users'), api.GET('/api/invites?status=active')]) to avoid sequential latency.
  • Document the npm run generate:api step in the implementation PR if any backend changes are made — it's easy to forget and causes type mismatch errors in CI.
  • No infrastructure changes to review. From a platform perspective this issue is low risk and low operational overhead.
## 🖥️ Tobias Wendt — DevOps & Platform Engineer ### Observations - **No new infrastructure required.** This is purely application-layer work — no new Docker services, no new volumes, no schema migrations. The existing PostgreSQL 16 instance handles all stat queries via the TagRepository CTEs and AppUserRepository. - **OpenAPI regeneration required if any backend endpoint changes.** If a stats sub-endpoint is added for users (e.g. `GET /api/admin/users/stats`), the sequence must be followed: build the JAR (`./mvnw clean package -DskipTests`), start with `--spring.profiles.active=dev`, run `npm run generate:api`. If tag and group stats are computed frontend-side from existing layout data (recommended), no regen needed for those. - **Frontend-side tag stats computation eliminates a backend query entirely.** This is the right call at current scale (25 tags). The recursive CTE queries in `TagRepository` already run on every page load for the tree view — no additional DB round-trips. - **No Flyway migration needed.** All stats are derived from existing tables and columns. No new schema changes. - **The pending invite count for the users empty state** is a single additional `GET /api/invites?status=active` call in `users/+layout.server.ts`. This is already served by the existing `InviteController` — no new endpoint. Ensure the layout server handles a failure gracefully (non-fatal: show "–" for the invite count if the request fails, don't let it block the page load). - **No observability gaps introduced.** The new stat calls use the same Actuator health and Spring Boot metrics as existing API calls. No additional dashboards or alerts needed. ### Recommendations - If the users layout loader is modified to add the invite count fetch, run both requests in parallel using `Promise.all([api.GET('/api/users'), api.GET('/api/invites?status=active')])` to avoid sequential latency. - Document the `npm run generate:api` step in the implementation PR if any backend changes are made — it's easy to forget and causes type mismatch errors in CI. - No infrastructure changes to review. From a platform perspective this issue is low risk and low operational overhead.
Author
Owner

📋 Elicit — Requirements Engineer

Observations

Gap 1: Invites is not a master-detail page — the empty state pattern doesn't apply.
The /admin/invites route is a full standalone table page with its own list, filters, and create form. There is no right-pane detail slot where AdminEmptyState would render. The current page already handles data.invites.length === 0 with a centered empty message. The proposed "Heading: Einladungen, CTA: + Einladung versenden" panel would require restructuring the invites page around a split-panel pattern it doesn't use. This is a structural mismatch.

Gap 2: "Recent activity" requires new audit infrastructure that isn't scoped.
The AuditKind enum covers document/content events only (FILE_UPLOADED, STATUS_CHANGED, TEXT_SAVED, etc.). There is no USER_CREATED, PERMISSION_CHANGED, or any user-management event. Surfacing "Neuer Benutzer: …, Berechtigung geändert: …" in the users empty state requires: new AuditKind values, new audit logging calls in UserService/GroupService, and a new query to retrieve recent user-management events. This is a distinct feature with its own scope and risk. It is not mentioned in the implementation plan or the acceptance criteria.

Ambiguity: AC "Stats refresh when master list changes" lacks a trigger definition.
"Master list changes" could mean: creating a user, deleting a user, changing a user's group, revoking an invite, or all of the above. The acceptance criterion should list the specific mutations that must trigger a count update and the mechanism (SvelteKit invalidateAll() after form actions, or explicit route invalidation).

Missing AC: What happens when a stats fetch fails?
If the invite count request fails, should the users empty state still render (showing "–" for the pending count) or show an error? This edge case is not addressed in the acceptance criteria.

Missing AC: Zero-count behavior.
When there are 0 users or 0 tags, the count line renders "0 aktive Benutzer · 0 ausstehende Einladungen" and the top-3 list is empty. The component must not crash. Add an explicit AC: "Component renders correctly when all counts are zero."

Ambiguity: "recentActivity?" described as optional, but condition for display is undefined.
The prop is listed as optional. When should the list be shown — only if recentActivity.length > 0? What label appears when the array is empty? Is the section header hidden or shown with an empty state of its own? Define the display rule.

Recommendations

  • Remove invites from scope or redefine the invites empty state as an enhanced zero-row state within the existing table (no structural change to the page). Add a dedicated issue if a master-detail layout for invites is desired.
  • Defer "recent activity" to a follow-up issue: "audit: track user management events (USER_CREATED, PERMISSION_CHANGED)." Remove it from this issue's scope and acceptance criteria.
  • Add to AC: "If the stats fetch for pending invites fails, the users empty state renders with '–' for the invite count, without blocking the page."
  • Add to AC: "Component renders correctly when all counts are zero — no crash, no missing elements."
  • Specify the invalidation trigger in the "Stats refresh" AC: "After a create, delete, or update action completes via a SvelteKit form action, the layout loader re-runs and the count reflects the updated state."
## 📋 Elicit — Requirements Engineer ### Observations **Gap 1: Invites is not a master-detail page — the empty state pattern doesn't apply.** The `/admin/invites` route is a full standalone table page with its own list, filters, and create form. There is no right-pane detail slot where `AdminEmptyState` would render. The current page already handles `data.invites.length === 0` with a centered empty message. The proposed "Heading: Einladungen, CTA: + Einladung versenden" panel would require restructuring the invites page around a split-panel pattern it doesn't use. This is a structural mismatch. **Gap 2: "Recent activity" requires new audit infrastructure that isn't scoped.** The `AuditKind` enum covers document/content events only (`FILE_UPLOADED`, `STATUS_CHANGED`, `TEXT_SAVED`, etc.). There is no `USER_CREATED`, `PERMISSION_CHANGED`, or any user-management event. Surfacing "Neuer Benutzer: …, Berechtigung geändert: …" in the users empty state requires: new `AuditKind` values, new audit logging calls in `UserService`/`GroupService`, and a new query to retrieve recent user-management events. This is a distinct feature with its own scope and risk. It is not mentioned in the implementation plan or the acceptance criteria. **Ambiguity: AC "Stats refresh when master list changes" lacks a trigger definition.** "Master list changes" could mean: creating a user, deleting a user, changing a user's group, revoking an invite, or all of the above. The acceptance criterion should list the specific mutations that must trigger a count update and the mechanism (SvelteKit `invalidateAll()` after form actions, or explicit route invalidation). **Missing AC: What happens when a stats fetch fails?** If the invite count request fails, should the users empty state still render (showing "–" for the pending count) or show an error? This edge case is not addressed in the acceptance criteria. **Missing AC: Zero-count behavior.** When there are 0 users or 0 tags, the count line renders "0 aktive Benutzer · 0 ausstehende Einladungen" and the top-3 list is empty. The component must not crash. Add an explicit AC: "Component renders correctly when all counts are zero." **Ambiguity: "recentActivity?" described as optional, but condition for display is undefined.** The prop is listed as optional. When should the list be shown — only if `recentActivity.length > 0`? What label appears when the array is empty? Is the section header hidden or shown with an empty state of its own? Define the display rule. ### Recommendations - **Remove invites from scope** or redefine the invites empty state as an enhanced zero-row state within the existing table (no structural change to the page). Add a dedicated issue if a master-detail layout for invites is desired. - **Defer "recent activity"** to a follow-up issue: "audit: track user management events (USER_CREATED, PERMISSION_CHANGED)." Remove it from this issue's scope and acceptance criteria. - **Add to AC:** "If the stats fetch for pending invites fails, the users empty state renders with '–' for the invite count, without blocking the page." - **Add to AC:** "Component renders correctly when all counts are zero — no crash, no missing elements." - **Specify the invalidation trigger** in the "Stats refresh" AC: "After a create, delete, or update action completes via a SvelteKit form action, the layout loader re-runs and the count reflects the updated state."
Author
Owner

🗳️ Decision Queue — Action Required

2 decisions need your input before implementation starts.

Scope

  • Invites page: drop or redefine? The /admin/invites route is a self-contained table page, not a master-detail split. The AdminEmptyState right-pane pattern structurally doesn't fit. Two options:

    • (a) Drop invites from this issue. The existing zero-row empty message stays as-is. Add a separate issue if a master-detail layout for invites is ever desired.
    • (b) Redefine invites empty state as an enhanced zero-row state within the existing table layout: add a heading, a count line ("0 ausstehende Einladungen"), and a more prominent "+ Einladung versenden" CTA inline — no structural change to the page, no new component needed.

    Option (a) is the lower-risk path. Option (b) adds value without restructuring.
    (Raised by: Leonie, Elicit)

  • "Recent activity" for Users: include or defer? Showing "Neuer Benutzer: …, Berechtigung geändert: …" requires new AuditKind enum values, new audit logging in UserService/GroupService, and a new query. The existing AuditKind covers document/content events only — user management events don't exist yet. Two options:

    • (a) Defer to a follow-up issue. Ship the users empty state with heading + count + CTA + secondary links only — no recent activity row. Open a follow-up: "audit: track user management events."
    • (b) Include in this issue. Accept the expanded scope (new AuditKind values, new logging calls, new query). Significantly more backend work than the rest of the issue combined.

    Option (a) is strongly recommended by both Markus and Elicit — the scope difference is substantial.
    (Raised by: Markus, Elicit)

## 🗳️ Decision Queue — Action Required _2 decisions need your input before implementation starts._ ### Scope - **Invites page: drop or redefine?** The `/admin/invites` route is a self-contained table page, not a master-detail split. The `AdminEmptyState` right-pane pattern structurally doesn't fit. Two options: - **(a) Drop invites from this issue.** The existing zero-row empty message stays as-is. Add a separate issue if a master-detail layout for invites is ever desired. - **(b) Redefine invites empty state** as an enhanced zero-row state within the existing table layout: add a heading, a count line ("0 ausstehende Einladungen"), and a more prominent "+ Einladung versenden" CTA inline — no structural change to the page, no new component needed. Option (a) is the lower-risk path. Option (b) adds value without restructuring. _(Raised by: Leonie, Elicit)_ - **"Recent activity" for Users: include or defer?** Showing "Neuer Benutzer: …, Berechtigung geändert: …" requires new `AuditKind` enum values, new audit logging in `UserService`/`GroupService`, and a new query. The existing `AuditKind` covers document/content events only — user management events don't exist yet. Two options: - **(a) Defer to a follow-up issue.** Ship the users empty state with heading + count + CTA + secondary links only — no recent activity row. Open a follow-up: "audit: track user management events." - **(b) Include in this issue.** Accept the expanded scope (new AuditKind values, new logging calls, new query). Significantly more backend work than the rest of the issue combined. Option (a) is strongly recommended by both Markus and Elicit — the scope difference is substantial. _(Raised by: Markus, Elicit)_
Author
Owner

We can drop the invite CTA, . Defer to a follow up

We can drop the invite CTA, . Defer to a follow up
Sign in to join this conversation.
No Label P3-later feature ui
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#326