feat: invite-based self-service registration #269
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?
Invite-Based Registration
Context
Familienarchiv currently has no self-service registration. All users are created by admins via the admin UI (
POST /api/users,ADMIN_USERpermission). The goal is to allow invited family members to register themselves — without opening public registration. Admins generate short invite codes; recipients click a link (or type the code) and fill in a pre-populated form.Design Summary
Data Model — V43 migration
New table
invite_tokens:idcodeXXXXX-XXXXXfor displaylabelmax_usesuse_countprefill_first_nameprefill_last_nameprefill_emailgroup_idsexpires_atcreated_bycreated_atrevokedAPI Endpoints
Public (no auth):
GET /api/auth/invite/{code}— validate code, return{ firstName, lastName, email }only (no group/internal data)POST /api/auth/register— body:{ code, username, password, firstName, lastName, email }; validates code atomically, creates user, assigns groups, incrementsuse_countAdmin (
ADMIN_USER):GET /api/invites— list all invites (code, label, use_count, max_uses, status, expiry)POST /api/invites— create invite, returns generated code + shareable URLDELETE /api/invites/{id}— revoke (setsrevoked = true)No update endpoint — revoke + recreate.
Frontend Routes
Public:
/register— added toPUBLIC_PATHSinhooks.server.ts?token=param, callsGET /api/auth/invite/{code}, pre-fills name/email/loginwith success messageAdmin:
/admin/invites— new page in existing admin nav1 / 1,3 / ∞), expiry, status badge (active / exhausted / revoked / expired), revoke buttonSecurity
SecureRandom, 10 uppercase alphanumeric chars, stored without dash, displayed asXXXXX-XXXXXHandlerInterceptorwithConcurrentHashMapcounter — 10 attempts/min on both public invite endpointsDataIntegrityViolationException(existing DB constraint); email uniqueness same; password minimum length enforced backend-sideGET /invite/{code}is UX only —POST /registerre-validates the token before creating the userImplementation Steps
Backend
V43 migration —
backend/src/main/resources/db/migration/V43__add_invite_tokens.sqlinvite_tokenstable (schema above)codeInviteTokenentity —backend/src/main/java/org/raddatz/familienarchiv/model/InviteToken.javagroupIdsas@ElementCollectionin a join table (follows existinggroup_permissionspattern inUserGroup)InviteTokenRepository—backend/src/main/java/org/raddatz/familienarchiv/repository/InviteTokenRepository.javafindByCode(String code)— for public lookupInviteService—backend/src/main/java/org/raddatz/familienarchiv/service/InviteService.javagenerateCode()—SecureRandom, 10 chars, uniqueness retry loopvalidateCode(String code)— throwsDomainException.notFound/conflicton invalid/exhausted/revoked/expiredcreateInvite(CreateInviteRequest dto, AppUser creator)→ returns saved entityredeemInvite(RegisterRequest dto)— validates code, creates user, assigns groups, incrementsuse_count;@TransactionalrevokeInvite(UUID id)listInvites()— returns all, sorted bycreatedAtdescRateLimitInterceptor—backend/src/main/java/org/raddatz/familienarchiv/config/RateLimitInterceptor.javaConcurrentHashMap<String, Deque<Instant>>keyed by IP/api/auth/invite/**and/api/auth/registerAuthController— add tobackend/src/main/java/org/raddatz/familienarchiv/controller/AuthController.javaGET /api/auth/invite/{code}— callsInviteService.validateCode, returns pre-fill DTOPOST /api/auth/register— callsInviteService.redeemInviteInviteController—backend/src/main/java/org/raddatz/familienarchiv/controller/InviteController.javaGET /api/invites—@RequirePermission(ADMIN_USER)POST /api/invites—@RequirePermission(ADMIN_USER)DELETE /api/invites/{id}—@RequirePermission(ADMIN_USER)ErrorCodeadditions —INVITE_NOT_FOUND,INVITE_EXHAUSTED,INVITE_REVOKED,INVITE_EXPIRED; mirror in frontenderrors.tsand translation filesOpenAPI regen — rebuild backend, run
npm run generate:apiFrontend
/registerroute —frontend/src/routes/register/+page.svelte++page.server.ts+page.server.ts: read?tokenparam, callGET /api/auth/invite/{code}, pass pre-fill data to page (or error state)+page.svelte: form (username, password, firstName, lastName, email — pre-filled where available); submit callsPOST /api/auth/register; on success redirect to/login?registered=1hooks.server.ts— add/registertoPUBLIC_PATHS/loginpage — show success banner when?registered=1query param present/admin/invitesroute —frontend/src/routes/admin/invites/+page.svelte++page.server.tsGET /api/invitesAdmin nav — add "Einladungen" link to the admin navigation
Translation keys — add
de.json,en.json,es.jsonentries for new error codes and UI stringsTests
InviteServiceTest— unit tests: code generation uniqueness, valid/invalid/exhausted/revoked/expired token states, user creation and group assignmentAuthControllerTest—@WebMvcTestslice: public invite lookup endpoint, register endpoint (happy path + error cases)InviteControllerTest—@WebMvcTestslice: list/create/revoke with and withoutADMIN_USERpermissionVerification
docker-compose up -d && cd backend && ./mvnw spring-boot:runcd frontend && npm run dev/admin/invites→ create a personal invite with pre-fill data/loginwith success message → log in with new accountcd backend && ./mvnw test👨💻 Felix Brandt — Senior Fullstack Developer
Observations
UUID[]JPA impedance mismatch: Thegroup_idscolumn is described asUUID[](PostgreSQL native array), butUserGroupalready uses@ElementCollection+ a join table (group_permissions). A nativeuuid[]column requires a customAttributeConverteror Hibernate dialect config — the@ElementCollectionjoin table is the proven pattern in this codebase and far simpler.redeemInviteis doing too much: validate code + create user + assign groups + increment use_count in a single method. Validate separately so the service stays testable in isolation.?token=is already used by the password reset flow. Using it for invites creates confusion inhooks.server.tsrouting and shared link-handling logic. Prefer?code=or?invite=to keep the two flows distinct.PUBLIC_API_PATHSgap:hooks.server.tshas aPUBLIC_API_PATHSlist for paths that don't need the auth header injected./api/auth/invite/**and/api/auth/registermust be added — otherwise thehandleFetchhook will attempt to inject a null auth token on the registration page.AuthControllerTestexists in the current codebase — this PR must create one. The standard pattern is@WebMvcTest+@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class}).Recommendations
@ElementCollectionin a join table (invite_token_group_ids) for group assignments — matches existinggroup_permissionspattern exactly.?code=throughout to avoid ambiguity with password reset.redeemInviteintovalidateAndLockCode(code)+createUserFromInvite(code, dto)— atomic lock viaSELECT ... FOR UPDATEon the token row prevents TOCTOU on concurrent redemptions.🏗️ Markus Keller — Senior Application Architect
Observations
@ElementCollectionis the right call forgroup_ids— native PostgreSQLuuid[]with JPA requires either a custom converter or a Hibernate-specific annotation. The@ElementCollectionjoin table is already the project pattern (group_permissionsinUserGroup) and can be extracted into a module boundary later if needed.RateLimitInterceptorregistration: the issue mentions aHandlerInterceptor, but it must be registered via aWebMvcConfigurerbean'saddInterceptors()to scope it correctly to only the two public paths. Registering it as a@ComponentwithoutaddInterceptors()will not apply it.GET /api/auth/invite/{code}: the issue says "invalid/exhausted/expired token → clear error." Returning a proper 4xx (404,409,410) is preferable to a200with an error flag — it avoids the frontend having to inspect the body to decide the render path, and it keeps the contract consistent with the rest of the API.SecurityConfig.permitAll(): two new paths (/api/auth/invite/**and/api/auth/register) must be added to thepermitAll()list inSecurityConfig. The issue implementation checklist doesn't call this out explicitly — it belongs in step 6 (AuthController), but should be an explicit sub-bullet.Recommendations
@ElementCollectionjoin table choice in the entity — one-line comment is enough since the native array alternative would surprise any Spring developer.RateLimitInterceptoris registered viaWebMvcConfigurer.addInterceptors()scoped only to the two public endpoints — not applied globally.404(code not found),409(already exhausted/revoked),410(expired). These map cleanly toDomainException.notFoundandDomainException.conflict.🔒 Nora Steiner — Security Engineer
Observations
ConcurrentHashMap<String, Deque<Instant>>keyed by IP grows unboundedly. Bots rotating IPs will fill the heap. The deque entries are only cleaned up on the next request from the same IP. Bound the map size or use a TTL-evicting structure (e.g. Caffeine cache withexpireAfterWrite).validateCodereadsuse_count, thenredeemInviteincrements it in a separate step. Two concurrent requests with the same code can both pass theuse_count < max_usescheck. Fix: use an atomicUPDATE invite_tokens SET use_count = use_count + 1 WHERE code = ? AND use_count < max_uses AND NOT revoked AND (expires_at IS NULL OR expires_at > NOW())and check affected rows — if 0, the code was just exhausted.POST /api/auth/registerwill return a409if the email is already registered. This leaks whether a given email exists in the system. For a private family app this is an acceptable tradeoff — but it should be a conscious decision, not an accidental one.SecurityConfigpermitAll(): confirm both/api/auth/invite/**and/api/auth/registerare explicitly listed. A missing entry means Spring Security will challenge with 401 before the controller is even reached.Recommendations
ConcurrentHashMap<String, Deque<Instant>>with a Caffeine cache:Caffeine.newBuilder().expireAfterWrite(1, MINUTES).maximumSize(10_000).build()— bounds memory and auto-evicts stale entries.@Transactionalalone does not prevent the race atREAD COMMITTEDisolation.🧪 Sara Holt — QA Engineer
Observations
AuthControllerTestexists in the current codebase — this PR creates it from scratch. Happy path and all error cases for both new endpoints must be covered.expires_at) → verify 410use_count == max_uses) → verify 409max_uses = NULL(unlimited) used 100 times → still validInviteControllerTestpermissions: test thatGET /api/inviteswith a user lackingADMIN_USERreturns 403 — not just that authenticated users with the permission can access it.use_countincremented in the admin UI."Recommendations
AuthControllerTestandInviteServiceTest.InviteControllerTestcase: non-admin user attemptsPOST /api/invites→ 403./admin/invitesand confirmuse_countshows1 / 1.InviteServiceTestwith two threads and verify that exactly one user is created.🎨 Leonie Voss — UX & Accessibility Designer
Observations
/registerform collects a password but the design doesn't mention a visibility toggle. On a registration form where the user is setting their password for the first time, this is important — typos in a hidden field lock people out.aria-liveon success banner: the/login?registered=1success banner must havearia-live="polite"so screen readers announce it — it appears after a redirect and won't be announced otherwise.Recommendations
<button type="button">with eye icon) to the password field on/register— same pattern used in/loginif one exists, otherwise add it there too.setTimeoutreset.aria-live="polite"to the registration success banner on/login.⚙️ Tobias Wendt — DevOps Engineer
Observations
APP_BASE_URLis a required new env variable:InviteServicemust construct the shareable URL as${appBaseUrl}/register?code={code}. If this env var is missing in production, invite links will be broken or point to localhost. The issue doesn't mention adding it todocker-compose.ymlor.env.example.RateLimitInterceptoruses in-memory state — no Redis or external dependency needed. This is correct for a single-node family app.com.github.ben-manes.caffeine:caffeinemust be added topom.xml. Spring Boot already pulls it in transitively viaspring-boot-starter-cacheif that starter is present — worth checking before adding a duplicate.Recommendations
APP_BASE_URLtodocker-compose.yml(e.g.APP_BASE_URL=http://localhost:8080) and to.env.examplewith a comment explaining it's used for invite link generation.@PostConstructor@Valuewith no default) so the backend fails fast ifAPP_BASE_URLis not configured — silent misconfiguration is worse than a loud startup failure.spring-boot-starter-cacheis already inpom.xmlbefore adding Caffeine as a new dependency.🗳️ Decision Queue — Action Required
4 decisions need your input before implementation starts.
Architecture
?token=conflicts with the existing password reset flow inhooks.server.ts. Use?code=(clean, no ambiguity) or?invite=(more explicit). Either works; pick one now so it's consistent everywhere. (Raised by: Felix)GET /api/invites— return all invites (full audit history) or active-only by default? All gives the admin visibility; active-only is a cleaner default UI. A?status=allquery param could offer both. (Raised by: Sara)Security
UX
Out of Scope (log for follow-up)
/login— deferred; current plan redirects to/loginwith a success message. Revisit if UX feedback suggests friction.Design spec committed to
maininf7747ba.docs/specs/register-page-spec.html— 10 sections:🗳️ Decision Queue — Resolved
All 4 open decisions from the decision queue have been settled.
Resolved
Query param naming — Use
?code=throughout. Avoids ambiguity with the existing?token=param used by the password reset flow. Update all references in the spec,hooks.server.ts, and frontend routes.Default filter for
GET /api/invites— Active-only by default. Add a "Alle anzeigen" toggle in the UI that appends?status=allto load the full audit history. The backend should support astatusquery param (active|all).Minimum password length — 8 characters, stored as a named constant. Apply consistently here and in the password reset flow.
Pre-filled fields editable — Registrants can edit name and email pre-filled by the admin. Pre-fill is a convenience, not a constraint. No locking needed.