feat(admin): assign groups when creating an invite link #566
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?
User story
As an admin, I want to select one or more groups when creating an invite link, so that the invited user is immediately placed in the right group (e.g. WRITE_ALL) the moment they claim their account — without needing a second manual step.
Current behaviour
The "Neue Einladung" form only accepts label, max uses, expiry, and prefill name/email. There is no group picker. After the invited user registers, they have no group and therefore no permissions. The admin must then open the user profile and assign groups manually.
Desired behaviour
The invite creation form shows a checkbox list of all groups (identical to the one used when creating a user directly). Any checked groups are stored on the
InviteTokenand assigned to theAppUserautomatically on registration.Acceptance criteria
Implementation notes
Backend — no changes needed.
CreateInviteRequestalready hasgroupIds: List<UUID>andInviteService.redeemInvite()already passestoken.getGroupIds()touserService.createUser().Frontend — 3 files + i18n:
frontend/src/routes/admin/invites/+page.server.tsload: fetch groups in parallel with the invites list:createaction: collect and forward groupIds:frontend/src/routes/admin/invites/+page.svelteUserGroupsSectionfrom$lib/user/UserGroupsSection.sveltegroupsto thedatatypei18n (
frontend/messages/de.json,en.json,es.json)Add after
"admin_new_invite_expires":admin_new_invite_groups"Gruppen (optional)"admin_new_invite_groups"Groups (optional)"admin_new_invite_groups"Grupos (opcional)"Verification
docker-compose up -d/admin/invites→ open the new invite form → groups checkboxes visible/admin/users→ new user already has the selected groupcd frontend && npm run check— no type errors👨💻 Felix Brandt — Senior Fullstack Developer
Observations
CreateInviteRequest.groupIds,InviteService.redeemInvite()→userService.createUser()with groups). Zero backend work needed — this is a clean frontend-only change./admin/users/newroute is the exact reference implementation for this pattern:load()callsapi.GET('/api/groups'),UserGroupsSectionrenders the checkboxes,formData.getAll('groupIds')collects the values. The issue correctly identifies this.groupsResult.data ?? []but doesn't check!groupsResult.response.okfirst. Per project conventions, the pattern is: checkresponse.ok, then accessdata!. Usingdata ?? []silently swallows a 403 or 500, which would make the groups section silently disappear in production without any indication of failure.fetch(...)for invites withapi.GET(...)for groups insidePromise.all. Look at what the existing+page.server.tsalready does for invites — if it already usescreateApiClient, use that for both. If it uses rawfetch, either keep consistency or migrate both in the same PR.load()returnsgroupsalongside invites (mockapi.GET('/api/groups'), assertgroupsin return value)createaction extractsgroupIdsviaformData.getAll('groupIds')and forwards them in the JSON bodycreateaction sendsgroupIds: []when no checkboxes are checked (preserves existing no-group behavior)Recommendations
+page.server.tsor+page.svelte.UserGroupsSectionimport exactly as used in/admin/users/new— no wrapper, no abstraction. Reuse as-is.🏗️ Markus Keller — Application Architect
Observations
InviteTokenalready carries aSet<UUID> groupIdsvia@ElementCollectionwith eager fetch. The backend service layer is complete. This is a frontend-only PR with near-zero architectural surface.GET /api/groups→ server load function → component props. No client-side API calls, no cross-domain repository access.fetch(apiUrl + '/api/invites?...')(raw fetch) alongsideapi.GET('/api/groups')(typed client) inside the samePromise.all. This mixes two fetch styles in one function. The typed client should be used for both, or neither — the existing invite fetch in the file dictates which direction to standardize.GET /api/groupsendpoint requiresADMIN_PERMISSION: the invite creation page is admin-only, so the server load runs with the admin session cookie. This works correctly. No special wiring needed.CLAUDE.md, C4 diagrams) do not need updating.UserGroupsSection.svelteacross two admin forms is appropriate — it's not accidental duplication, it's a shared domain component.Recommendations
+page.server.tsalready uses for the invites list and match it for the groups fetch. Don't introduce a second style.groupsreturn value fromload()should be typed explicitly with the generatedUserGroup[]type. That keeps TypeScript's compile-time coverage strong and avoids relying on inference through thedata ?? []fallback.🔐 Nora "NullX" Steiner — Application Security Engineer
Observations
InviteService.createInvite()callsuserService.findGroupsByIds(groupIds)which validates that the submitted UUIDs correspond to real groups before storing them on the token. An attacker cannot inject arbitrary UUIDs via the form and get them silently persisted.GET /api/groupsis protected byADMIN_PERMISSION. The SvelteKit server load function runs with the admin session cookie, so this is correctly authorized. The browser never directly calls/api/groups.groupIdsas an explicit string array viaformData.getAll('groupIds')— same pattern as/admin/users/new. Only UUID strings can appear here (checkbox values are set by the server-rendered group list, not free-form input). No risk of unexpected field injection.ADMIN— to an invited user. This is intentional: only admins reach this form. Verify that invite creation requires a specific permission (@RequirePermissionon thecreateaction in the server file, or equivalent). If creating an invite requires only being logged in (notADMINspecifically), a non-admin user who somehow reaches the form could escalate privileges.InviteService.createInvite()emits an audit log entry and whether it includes the assignedgroupIds. If not, this is a gap — an admin assigningADMINto an invite token should be auditable.Recommendations
@RequirePermissionannotation guards the invite creation endpoint (POST /api/invites)? It should beADMINorADMIN_USER—WRITE_ALLwould be too permissive for a privilege-granting action.@WebMvcTesttest:POST /api/inviteswith a user carrying onlyWRITE_ALLshould return 403. This test should exist regardless of this issue, but adding group assignment makes the permission boundary more consequential.InviteService.createInvite()and verify thatgroupIdsappears in the log entry.🧪 Sara Holt — QA Engineer
Observations
Test plan (what should be written before implementation)
Unit (SvelteKit
+page.server.tsdirect import):load()returnsgroupsarray from/api/groupsalongside invitesload()returnsgroups: []when groups API returns non-OK (form resilience)createaction extractsgroupIdsviaformData.getAll('groupIds')and includes them in the POST bodycreateaction sendsgroupIds: []when no checkboxes are tickedIntegration (MockMvc / service layer, already backend-covered but verify):
5.
redeemInvite()with storedgroupIds→AppUserhas those groups assignedE2E (Playwright, critical user journey):
6. Admin creates invite with
WRITE_ALLchecked → invited user registers → user appears in/admin/userswithWRITE_ALLgroup7. Admin creates invite with no groups → invited user registers → user has no groups
Recommendations
🎨 Leonie Voss — UX Designer & Accessibility Strategist
Observations
UserGroupsSection.svelteis the right call. Consistency across/admin/users/newand/admin/invitesmeans admins learn the pattern once. This is especially important for the senior admin audience who rely on muscle memory and calm, predictable layouts."Gruppen (optional)"is correct UX: it explicitly signals that no selection is valid, reducing cognitive load. Don't change it to just "Gruppen".font-sans text-xs font-bold tracking-widest text-ink-2 uppercase— notetext-ink-2, nottext-ink-3. All other section titles in the codebase usetext-ink-3. Verify which token is used on the surrounding fields in+page.svelteand match it exactly.text-ink-2is darker — using the wrong token breaks visual rhythm.sm:col-span-2wrapper: correct for a full-width section in a 2-column grid. Verify the invite form uses a 2-column grid (sm:grid-cols-2) — if it's single-column, the span wrapper is a no-op and can be omitted.UserGroupsSection: verify the component uses a<fieldset>with<legend>to group the checkboxes semantically. A bare<div>wrapping checkboxes is not accessible — screen readers cannot associate the "Gruppen (optional)" label with the individual checkboxes unless a<fieldset>/<legend>or explicitaria-labelledbyis used. This is a WCAG 1.3.1 failure if missing.UserGroupsSectionmust meet 44×44px minimum for the senior admin audience. Verify the component addspy-2or equivalent padding to each checkbox row.Recommendations
UserGroupsSection.svelteand verify: (1) it uses<fieldset>/<legend>, (2) each checkbox row meets 44px touch target, (3) focus ring is visible (focus-visible:ring-2).UserGroupsSectionfirst — the fix benefits both the users/new and invites forms.text-ink-3for the section label to match the visual tone of other form sections unless the surrounding fields already usetext-ink-2.🛠️ Tobias Wendt — DevOps & Platform Engineer
Observations
GET /api/groupsendpoint is already live in the running backend. This is as clean as a change gets from an ops perspective.docker-compose up -d→ manual test flow →npm run check. Nothing tricky here.Observations (positive — worth noting)
+page.server.ts) rather than in the browser. This means the admin's session cookie is used for auth, no API key is exposed to the browser, and the page works without client-side JavaScript.Recommendations
npm run checkandcd backend && ./mvnw testlocally before pushing, since the typed client must match the current OpenAPI spec. Nonpm run generate:apishould be needed here (no backend model changes), but confirm the/api/groupsroute is already present in the generated types.📋 Elicit — Requirements Engineer
Observations
GET /api/groupsfails (503, network timeout, permission error). Should the form still render and allow invite creation without the group picker? Or should the form display an error? A requirement like: "Given the groups API is unavailable, when the admin opens the new invite form, then the form renders without the groups section and invite creation without groups remains possible" would close this gap.InviteService.createInvite()validates group UUIDs at creation time, butredeemInvite()may skip validation (it just passes the stored UUIDs tocreateUser()). This is a requirements gap that could surface as a silent failure in production. Recommend adding: "If a group associated with an invite is deleted before redemption, the invite can still be redeemed; the deleted group is silently skipped." (Or alternatively: the redemption fails with a clear error — whichever behavior is intended.)Recommendations
🗳️ Decision Queue — Action Required
2 decisions need your input before implementation starts.
Requirements / Business Logic
Behavior when groups API fails at form load time — The groups field is labeled "optional," so the natural default is: form still renders, group picker silently absent, admin can still create the invite without groups. But this should be an explicit AC, not an assumption. Options: (A) groups section hidden, form fully usable — recommended, lowest friction; (B) error banner shown, invite creation blocked. (Raised by: Elicit, Felix, Sara)
Behavior when a group is deleted between invite creation and redemption — At creation time,
InviteServicevalidates that the group UUIDs exist. At redemption time, the stored UUIDs are passed straight tocreateUser()without re-validation. If a group is deleted in the meantime: (A) redemption succeeds and the deleted group is silently skipped (user gets fewer groups than invited, no error); (B) redemption fails with a clear error (admin must re-issue the invite). This is a business decision — (A) is more resilient and matches how similar systems behave, but (B) surfaces the inconsistency explicitly. (Raised by: Elicit, Nora)Form should still work, but we should show a warning why the groups are missing
A. the group service should probably check if there are pending invites with the group it wants to delete and throw an error if there are invites, so an admin cannot delete a group that is in a pending invite