feat(admin): informative empty states on master-detail pages (Users · Groups · Tags · Invites) #326
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Context
Every
/admin/*master-detail page shows the same static empty state on the right pane when no item is selected:(and the analogous variants for groups, tags, invites). Captured in screenshots
10-admin-*during Phase B2 audit on 2026-04-24. This is:Non-goals
Proposed design
Replace the generic "Select a … from the list." with a small contextual panel:
Users empty state
/admin/groupsGroups empty state
/admin/usersTags empty state
"Ereignisse (7) · Dokumententyp (6) · Reise (3) · Alltag (1)"
Invites empty state
Implementation plan
Frontend
frontend/src/lib/components/AdminEmptyState.svelte:kind: 'users' | 'groups' | 'tags' | 'invites',stats: { … },primaryCta: { label, href },secondaryLinks: { label, href }[],recentActivity?: { … }[]./admin/users/+page.svelte,/admin/groups/+page.svelte,/admin/tags/+page.svelte,/admin/invites/+page.svelte:selectedItem === null, render<AdminEmptyState kind="..." stats={…} primaryCta={…} … />.+page.server.tsloader 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
/statssub-endpoint) so counts are available without a separate round-trip:/api/admin/usersreturns{ items, pendingInvites, recentActivity }where activity is the existing audit log (if any) or an empty array if not./api/admin/groupsreturns{ items }— no new data; permission counts already in the group payload./api/admin/tagsreturns{ items, stats: { total, rootCount, childCount, orphanCount, topTags: [{ name, docCount }×3], rootsWithChildren: [{ name, childCount }] } }./api/admin/invitesreturns{ 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
AdminEmptyStaterenders each kind with sample data; CTA click dispatches navigation./admin/userswith 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
AdminEmptyStatecomponent exists and is reused by all four pagesCritical files
Related
Cross-reference fix: the "F-08 (admin dashboard)" mentioned in the body is issue #324. The "F-09 (tag taxonomy)" is issue #325.
🏗️ Markus Keller — Application Architect
Observations
kindprop is a premature abstraction. A single component with fourkindbranches means adding a fifth kind requires touchingAdminEmptyState.svelteitself. 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 nokindawareness that renders whatever the parent passes.Tag stats need no backend change. The
tags/+layout.server.tsalready fetches the full tag tree viaGET /api/tags/tree, includingdocumentCountper node. Every stat in the issue can be computed frontend-side fromdata.tags:total = data.tags.lengthrootCount = data.tags.filter(t => !t.parentId).lengthchildCount = data.tags.filter(t => !!t.parentId).lengthorphanCount = data.tags.filter(t => t.documentCount === 0).lengthtopTags = [...data.tags].sort((a,b) => b.documentCount - a.documentCount).slice(0, 3)rootsWithChildren— group from tree structureAdding a backend stats endpoint for tags would be pure overhead.
Groups stats also need no backend change.
groups/+layout.server.tsalready loadsdata.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.tsloadsdata.users. For the pending invite count, addapi.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
AuditKindenum covers only document/content events (FILE_UPLOADED,STATUS_CHANGED,TEXT_SAVED, etc.) — there is noUSER_CREATED,PERMISSION_CHANGED, or any user-management event. Surfacing "Neuer Benutzer: …, Berechtigung geändert: …" requires new enum values AND new audit logging inUserService/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 usesADMIN_USERand the tag controller usesADMIN_TAG. Stats endpoints must use the same permission as their parent list — don't introduce a new permission level inconsistency.Recommendations
kind-switched monolith. The rule: if a prop controls which of four completely different templates renders, it's four components, not one.ADMIN_USER. No new/statsendpoints for tags or groups — frontend derivation is simpler and avoids API contract changes.👨💻 Felix Brandt — Senior Fullstack Developer
Observations
The component has five responsibilities.
AdminEmptyState.svelteas 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 toAdminRecentActivity.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 providesdata.tags(flat array withdocumentCount). All five tag stats can be computed as$derivedvalues intags/+page.svelte: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.tsloader fetches the small stats blob." Butdata.users,data.groups,data.tagsare all loaded in their respective+layout.server.tsfiles. 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 akindprop at all; pass only what the component actually uses:Each page constructs these from its own
$deriveddata. The component remains kind-agnostic and reusable.Pending invite count for users empty state: add
api.GET('/api/invites?status=active')tousers/+layout.server.tsalongside the existing users fetch. No new backend endpoint required —InviteControlleralready supports this query.Groups empty state: groups layout loads
data.groups. Count isdata.groups.length. Permission breakdown per group (e.g. "Administrators (7 Rechte)") is derivable fromgroup.permissions.size. No backend change needed.Recommendations
kindprop. Pass typed string/data props instead — the component stays reusable without routing logic.$derived— zero backend changes for those two pages.{#each secondaryLinks as link (link.href)}with a key expression to prevent position-based reconciliation bugs.🔒 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 sameconfirm(). 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
AuditKindvalues must NOT log sensitive data. Specifically:USER_CREATEDshould log the user ID and email, never the plaintext password.PERMISSION_CHANGEDshould 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 useADMIN— it's broader than necessary. Use:@RequirePermission(Permission.ADMIN_USER)— same asGET /api/users@RequirePermission(Permission.ADMIN_TAG)— same asGET /api/tags/treeGET /api/groupsUsing
ADMINwould 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/invitesendpoint already gated toADMIN_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
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).USER_CREATED/PERMISSION_CHANGEDaudit payload schema before writing audit service code — document what is and is not captured. Reference the existing payload docstrings onAuditKindas the template.ADMIN_USER/ADMIN_TAG(notADMIN) on any new stats endpoints. Match existing permissions exactly.🧪 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:
0 aktive Benutzer · 0 ausstehende Einladungenrenders without crash (no divide-by-zero, no undefined access)orphanCount === 0doesn'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:
/admin/userswith no item selected — observe count N/admin/userswith no item selected — assert count is N+1SvelteKit's
loadre-runs oninvalidateAll()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 requirepage.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 straightforwardgetByRole('dialog')assertions.Tag stat derivations need a unit test. If stats are computed as
$derivedvalues 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 knowndata.tagsinput, or extract the computation to a pure function and unit-test that.Load function test: add a
+page.servertest (import and callloaddirectly) 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
computeTagStats(tags: FlatTag[]): TagStatsand test it with 0 tags, 1 root tag, mixed orphan/non-orphan inputs.reset-db.shor a dedicated seed action) so count assertions are deterministic.confirm()for the "Alle widerrufen" action with a<dialog>— this improves testability, a11y, and UX simultaneously.🎨 Leonie Voss — UX Designer & Accessibility Strategist
Observations
Invites page is not a master-detail layout — the AdminEmptyState pattern doesn't apply. The
/admin/invitesroute 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 anAdminEmptyStatethere 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. Addmin-h-[44px] flex items-centerorpy-2.5to 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.sveltefiles 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-fgtokens (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
window.confirm()on the revoke flow with a<dialog>-based confirmation — this is a prerequisite for the bulk "Alle widerrufen" action being accessible.aria-live="polite"to the count stats line if counts update client-side after a mutation, so screen readers announce the change.<section aria-label="...">landmark with a localized label matching the heading text — gives screen reader users a named navigation target.🖥️ 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, runnpm 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
TagRepositoryalready 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=activecall inusers/+layout.server.ts. This is already served by the existingInviteController— 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
Promise.all([api.GET('/api/users'), api.GET('/api/invites?status=active')])to avoid sequential latency.npm run generate:apistep in the implementation PR if any backend changes are made — it's easy to forget and causes type mismatch errors in CI.📋 Elicit — Requirements Engineer
Observations
Gap 1: Invites is not a master-detail page — the empty state pattern doesn't apply.
The
/admin/invitesroute is a full standalone table page with its own list, filters, and create form. There is no right-pane detail slot whereAdminEmptyStatewould render. The current page already handlesdata.invites.length === 0with 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
AuditKindenum covers document/content events only (FILE_UPLOADED,STATUS_CHANGED,TEXT_SAVED, etc.). There is noUSER_CREATED,PERMISSION_CHANGED, or any user-management event. Surfacing "Neuer Benutzer: …, Berechtigung geändert: …" in the users empty state requires: newAuditKindvalues, new audit logging calls inUserService/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
🗳️ Decision Queue — Action Required
2 decisions need your input before implementation starts.
Scope
Invites page: drop or redefine? The
/admin/invitesroute is a self-contained table page, not a master-detail split. TheAdminEmptyStateright-pane pattern structurally doesn't fit. Two options: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
AuditKindenum values, new audit logging inUserService/GroupService, and a new query. The existingAuditKindcovers document/content events only — user management events don't exist yet. Two options:Option (a) is strongly recommended by both Markus and Elicit — the scope difference is substantial.
(Raised by: Markus, Elicit)
We can drop the invite CTA, . Defer to a follow up