feat: invite-based self-service registration #269

Closed
opened 2026-04-18 15:22:35 +02:00 by marcel · 9 comments
Owner

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_USER permission). 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:

Column Type Notes
id UUID PK
code VARCHAR(16) UNIQUE 10-char uppercase alphanumeric, formatted XXXXX-XXXXX for display
label VARCHAR(255) Admin note, e.g. "Für Oma Helga"
max_uses INTEGER NULL 1 = personal, N = group, NULL = unlimited
use_count INTEGER default 0
prefill_first_name VARCHAR NULL
prefill_last_name VARCHAR NULL
prefill_email VARCHAR NULL
group_ids UUID[] Groups auto-assigned on registration
expires_at TIMESTAMP NULL NULL = no expiry
created_by UUID FK → users
created_at TIMESTAMP auto
revoked BOOLEAN default false

API 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, increments use_count

Admin (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 URL
  • DELETE /api/invites/{id} — revoke (sets revoked = true)

No update endpoint — revoke + recreate.

Frontend Routes

Public:

  • /register — added to PUBLIC_PATHS in hooks.server.ts
    • Loads token from ?token= param, calls GET /api/auth/invite/{code}, pre-fills name/email
    • Invalid/exhausted/expired token → clear error, no form
    • User sets username + password manually
    • On success → redirect to /login with success message

Admin:

  • /admin/invites — new page in existing admin nav
    • Table: code, label, uses (1 / 1, 3 / ∞), expiry, status badge (active / exhausted / revoked / expired), revoke button
    • "Neue Einladung" → inline creation form: label, max_uses, pre-fill fields, group picker, optional expiry
    • After creation: show shareable URL + code for copy

Security

  • Code generation: SecureRandom, 10 uppercase alphanumeric chars, stored without dash, displayed as XXXXX-XXXXX
  • Rate limiting: IP-based HandlerInterceptor with ConcurrentHashMap counter — 10 attempts/min on both public invite endpoints
  • Validation: username uniqueness caught via DataIntegrityViolationException (existing DB constraint); email uniqueness same; password minimum length enforced backend-side
  • Atomic re-validation: GET /invite/{code} is UX only — POST /register re-validates the token before creating the user
  • No info leakage: public endpoint returns only pre-fill fields, not group structure or internal IDs
  • No cleanup job: revoked/expired invites kept for admin audit history

Implementation Steps

Backend

  1. V43 migrationbackend/src/main/resources/db/migration/V43__add_invite_tokens.sql

    • Create invite_tokens table (schema above)
    • Unique index on code
  2. InviteToken entitybackend/src/main/java/org/raddatz/familienarchiv/model/InviteToken.java

    • Map all columns; groupIds as @ElementCollection in a join table (follows existing group_permissions pattern in UserGroup)
  3. InviteTokenRepositorybackend/src/main/java/org/raddatz/familienarchiv/repository/InviteTokenRepository.java

    • findByCode(String code) — for public lookup
    • Standard CRUD for admin endpoints
  4. InviteServicebackend/src/main/java/org/raddatz/familienarchiv/service/InviteService.java

    • generateCode()SecureRandom, 10 chars, uniqueness retry loop
    • validateCode(String code) — throws DomainException.notFound/conflict on invalid/exhausted/revoked/expired
    • createInvite(CreateInviteRequest dto, AppUser creator) → returns saved entity
    • redeemInvite(RegisterRequest dto) — validates code, creates user, assigns groups, increments use_count; @Transactional
    • revokeInvite(UUID id)
    • listInvites() — returns all, sorted by createdAt desc
  5. RateLimitInterceptorbackend/src/main/java/org/raddatz/familienarchiv/config/RateLimitInterceptor.java

    • In-memory ConcurrentHashMap<String, Deque<Instant>> keyed by IP
    • Applied to /api/auth/invite/** and /api/auth/register
    • 10 attempts per minute; returns 429 on breach
  6. AuthController — add to backend/src/main/java/org/raddatz/familienarchiv/controller/AuthController.java

    • GET /api/auth/invite/{code} — calls InviteService.validateCode, returns pre-fill DTO
    • POST /api/auth/register — calls InviteService.redeemInvite
  7. InviteControllerbackend/src/main/java/org/raddatz/familienarchiv/controller/InviteController.java

    • GET /api/invites@RequirePermission(ADMIN_USER)
    • POST /api/invites@RequirePermission(ADMIN_USER)
    • DELETE /api/invites/{id}@RequirePermission(ADMIN_USER)
  8. ErrorCode additions — INVITE_NOT_FOUND, INVITE_EXHAUSTED, INVITE_REVOKED, INVITE_EXPIRED; mirror in frontend errors.ts and translation files

  9. OpenAPI regen — rebuild backend, run npm run generate:api

Frontend

  1. /register routefrontend/src/routes/register/+page.svelte + +page.server.ts

    • +page.server.ts: read ?token param, call GET /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 calls POST /api/auth/register; on success redirect to /login?registered=1
  2. hooks.server.ts — add /register to PUBLIC_PATHS

  3. /login page — show success banner when ?registered=1 query param present

  4. /admin/invites routefrontend/src/routes/admin/invites/+page.svelte + +page.server.ts

    • Load: GET /api/invites
    • Actions: create invite, revoke invite
    • After creation: display the shareable URL prominently
  5. Admin nav — add "Einladungen" link to the admin navigation

  6. Translation keys — add de.json, en.json, es.json entries for new error codes and UI strings

Tests

  1. InviteServiceTest — unit tests: code generation uniqueness, valid/invalid/exhausted/revoked/expired token states, user creation and group assignment
  2. AuthControllerTest@WebMvcTest slice: public invite lookup endpoint, register endpoint (happy path + error cases)
  3. InviteControllerTest@WebMvcTest slice: list/create/revoke with and without ADMIN_USER permission

Verification

  1. Start stack: docker-compose up -d && cd backend && ./mvnw spring-boot:run
  2. Start frontend: cd frontend && npm run dev
  3. Log in as admin → navigate to /admin/invites → create a personal invite with pre-fill data
  4. Copy the link → open in incognito → verify form pre-fills → register → verify redirect to /login with success message → log in with new account
  5. Create a group invite (max_uses > 1) → use it twice → verify use_count increments → use it a third time beyond limit → verify rejection
  6. Revoke an active invite → try to use the link → verify rejection
  7. Run backend tests: cd backend && ./mvnw test
# 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_USER` permission). 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`: | Column | Type | Notes | |---|---|---| | `id` | UUID PK | | | `code` | VARCHAR(16) UNIQUE | 10-char uppercase alphanumeric, formatted `XXXXX-XXXXX` for display | | `label` | VARCHAR(255) | Admin note, e.g. "Für Oma Helga" | | `max_uses` | INTEGER NULL | 1 = personal, N = group, NULL = unlimited | | `use_count` | INTEGER | default 0 | | `prefill_first_name` | VARCHAR NULL | | | `prefill_last_name` | VARCHAR NULL | | | `prefill_email` | VARCHAR NULL | | | `group_ids` | UUID[] | Groups auto-assigned on registration | | `expires_at` | TIMESTAMP NULL | NULL = no expiry | | `created_by` | UUID FK → users | | | `created_at` | TIMESTAMP | auto | | `revoked` | BOOLEAN | default false | ### API 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, increments `use_count` **Admin (`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 URL - `DELETE /api/invites/{id}` — revoke (sets `revoked = true`) No update endpoint — revoke + recreate. ### Frontend Routes **Public:** - `/register` — added to `PUBLIC_PATHS` in `hooks.server.ts` - Loads token from `?token=` param, calls `GET /api/auth/invite/{code}`, pre-fills name/email - Invalid/exhausted/expired token → clear error, no form - User sets username + password manually - On success → redirect to `/login` with success message **Admin:** - `/admin/invites` — new page in existing admin nav - Table: code, label, uses (`1 / 1`, `3 / ∞`), expiry, status badge (active / exhausted / revoked / expired), revoke button - "Neue Einladung" → inline creation form: label, max_uses, pre-fill fields, group picker, optional expiry - After creation: show shareable URL + code for copy ### Security - **Code generation:** `SecureRandom`, 10 uppercase alphanumeric chars, stored without dash, displayed as `XXXXX-XXXXX` - **Rate limiting:** IP-based `HandlerInterceptor` with `ConcurrentHashMap` counter — 10 attempts/min on both public invite endpoints - **Validation:** username uniqueness caught via `DataIntegrityViolationException` (existing DB constraint); email uniqueness same; password minimum length enforced backend-side - **Atomic re-validation:** `GET /invite/{code}` is UX only — `POST /register` re-validates the token before creating the user - **No info leakage:** public endpoint returns only pre-fill fields, not group structure or internal IDs - **No cleanup job:** revoked/expired invites kept for admin audit history --- ## Implementation Steps ### Backend 1. **V43 migration** — `backend/src/main/resources/db/migration/V43__add_invite_tokens.sql` - Create `invite_tokens` table (schema above) - Unique index on `code` 2. **`InviteToken` entity** — `backend/src/main/java/org/raddatz/familienarchiv/model/InviteToken.java` - Map all columns; `groupIds` as `@ElementCollection` in a join table (follows existing `group_permissions` pattern in `UserGroup`) 3. **`InviteTokenRepository`** — `backend/src/main/java/org/raddatz/familienarchiv/repository/InviteTokenRepository.java` - `findByCode(String code)` — for public lookup - Standard CRUD for admin endpoints 4. **`InviteService`** — `backend/src/main/java/org/raddatz/familienarchiv/service/InviteService.java` - `generateCode()` — `SecureRandom`, 10 chars, uniqueness retry loop - `validateCode(String code)` — throws `DomainException.notFound/conflict` on invalid/exhausted/revoked/expired - `createInvite(CreateInviteRequest dto, AppUser creator)` → returns saved entity - `redeemInvite(RegisterRequest dto)` — validates code, creates user, assigns groups, increments `use_count`; `@Transactional` - `revokeInvite(UUID id)` - `listInvites()` — returns all, sorted by `createdAt` desc 5. **`RateLimitInterceptor`** — `backend/src/main/java/org/raddatz/familienarchiv/config/RateLimitInterceptor.java` - In-memory `ConcurrentHashMap<String, Deque<Instant>>` keyed by IP - Applied to `/api/auth/invite/**` and `/api/auth/register` - 10 attempts per minute; returns 429 on breach 6. **`AuthController`** — add to `backend/src/main/java/org/raddatz/familienarchiv/controller/AuthController.java` - `GET /api/auth/invite/{code}` — calls `InviteService.validateCode`, returns pre-fill DTO - `POST /api/auth/register` — calls `InviteService.redeemInvite` 7. **`InviteController`** — `backend/src/main/java/org/raddatz/familienarchiv/controller/InviteController.java` - `GET /api/invites` — `@RequirePermission(ADMIN_USER)` - `POST /api/invites` — `@RequirePermission(ADMIN_USER)` - `DELETE /api/invites/{id}` — `@RequirePermission(ADMIN_USER)` 8. **`ErrorCode`** additions — `INVITE_NOT_FOUND`, `INVITE_EXHAUSTED`, `INVITE_REVOKED`, `INVITE_EXPIRED`; mirror in frontend `errors.ts` and translation files 9. **OpenAPI regen** — rebuild backend, run `npm run generate:api` ### Frontend 10. **`/register` route** — `frontend/src/routes/register/+page.svelte` + `+page.server.ts` - `+page.server.ts`: read `?token` param, call `GET /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 calls `POST /api/auth/register`; on success redirect to `/login?registered=1` 11. **`hooks.server.ts`** — add `/register` to `PUBLIC_PATHS` 12. **`/login` page** — show success banner when `?registered=1` query param present 13. **`/admin/invites` route** — `frontend/src/routes/admin/invites/+page.svelte` + `+page.server.ts` - Load: `GET /api/invites` - Actions: create invite, revoke invite - After creation: display the shareable URL prominently 14. **Admin nav** — add "Einladungen" link to the admin navigation 15. **Translation keys** — add `de.json`, `en.json`, `es.json` entries for new error codes and UI strings ### Tests 16. **`InviteServiceTest`** — unit tests: code generation uniqueness, valid/invalid/exhausted/revoked/expired token states, user creation and group assignment 17. **`AuthControllerTest`** — `@WebMvcTest` slice: public invite lookup endpoint, register endpoint (happy path + error cases) 18. **`InviteControllerTest`** — `@WebMvcTest` slice: list/create/revoke with and without `ADMIN_USER` permission --- ## Verification 1. Start stack: `docker-compose up -d && cd backend && ./mvnw spring-boot:run` 2. Start frontend: `cd frontend && npm run dev` 3. Log in as admin → navigate to `/admin/invites` → create a personal invite with pre-fill data 4. Copy the link → open in incognito → verify form pre-fills → register → verify redirect to `/login` with success message → log in with new account 5. Create a group invite (max_uses > 1) → use it twice → verify use_count increments → use it a third time beyond limit → verify rejection 6. Revoke an active invite → try to use the link → verify rejection 7. Run backend tests: `cd backend && ./mvnw test`
marcel added the featureuser labels 2026-04-18 15:22:41 +02:00
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Observations

  • UUID[] JPA impedance mismatch: The group_ids column is described as UUID[] (PostgreSQL native array), but UserGroup already uses @ElementCollection + a join table (group_permissions). A native uuid[] column requires a custom AttributeConverter or Hibernate dialect config — the @ElementCollection join table is the proven pattern in this codebase and far simpler.
  • redeemInvite is 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.
  • Naming conflict: ?token= is already used by the password reset flow. Using it for invites creates confusion in hooks.server.ts routing and shared link-handling logic. Prefer ?code= or ?invite= to keep the two flows distinct.
  • PUBLIC_API_PATHS gap: hooks.server.ts has a PUBLIC_API_PATHS list for paths that don't need the auth header injected. /api/auth/invite/** and /api/auth/register must be added — otherwise the handleFetch hook will attempt to inject a null auth token on the registration page.
  • No AuthControllerTest exists in the current codebase — this PR must create one. The standard pattern is @WebMvcTest + @Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class}).

Recommendations

  • Use @ElementCollection in a join table (invite_token_group_ids) for group assignments — matches existing group_permissions pattern exactly.
  • Rename the query param to ?code= throughout to avoid ambiguity with password reset.
  • Split redeemInvite into validateAndLockCode(code) + createUserFromInvite(code, dto) — atomic lock via SELECT ... FOR UPDATE on the token row prevents TOCTOU on concurrent redemptions.
## 👨‍💻 Felix Brandt — Senior Fullstack Developer ### Observations - **`UUID[]` JPA impedance mismatch**: The `group_ids` column is described as `UUID[]` (PostgreSQL native array), but `UserGroup` already uses `@ElementCollection` + a join table (`group_permissions`). A native `uuid[]` column requires a custom `AttributeConverter` or Hibernate dialect config — the `@ElementCollection` join table is the proven pattern in this codebase and far simpler. - **`redeemInvite` is 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. - **Naming conflict**: `?token=` is already used by the password reset flow. Using it for invites creates confusion in `hooks.server.ts` routing and shared link-handling logic. Prefer `?code=` or `?invite=` to keep the two flows distinct. - **`PUBLIC_API_PATHS` gap**: `hooks.server.ts` has a `PUBLIC_API_PATHS` list for paths that don't need the auth header injected. `/api/auth/invite/**` and `/api/auth/register` must be added — otherwise the `handleFetch` hook will attempt to inject a null auth token on the registration page. - **No `AuthControllerTest` exists** in the current codebase — this PR must create one. The standard pattern is `@WebMvcTest` + `@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})`. ### Recommendations - Use `@ElementCollection` in a join table (`invite_token_group_ids`) for group assignments — matches existing `group_permissions` pattern exactly. - Rename the query param to `?code=` throughout to avoid ambiguity with password reset. - Split `redeemInvite` into `validateAndLockCode(code)` + `createUserFromInvite(code, dto)` — atomic lock via `SELECT ... FOR UPDATE` on the token row prevents TOCTOU on concurrent redemptions.
Author
Owner

🏗️ Markus Keller — Senior Application Architect

Observations

  • @ElementCollection is the right call for group_ids — native PostgreSQL uuid[] with JPA requires either a custom converter or a Hibernate-specific annotation. The @ElementCollection join table is already the project pattern (group_permissions in UserGroup) and can be extracted into a module boundary later if needed.
  • RateLimitInterceptor registration: the issue mentions a HandlerInterceptor, but it must be registered via a WebMvcConfigurer bean's addInterceptors() to scope it correctly to only the two public paths. Registering it as a @Component without addInterceptors() will not apply it.
  • Response shape for invalid code on GET /api/auth/invite/{code}: the issue says "invalid/exhausted/expired token → clear error." Returning a proper 4xx (404, 409, 410) is preferable to a 200 with 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 the permitAll() list in SecurityConfig. The issue implementation checklist doesn't call this out explicitly — it belongs in step 6 (AuthController), but should be an explicit sub-bullet.

Recommendations

  • Document the @ElementCollection join table choice in the entity — one-line comment is enough since the native array alternative would surprise any Spring developer.
  • Confirm the RateLimitInterceptor is registered via WebMvcConfigurer.addInterceptors() scoped only to the two public endpoints — not applied globally.
  • Use distinct 4xx status codes per failure mode: 404 (code not found), 409 (already exhausted/revoked), 410 (expired). These map cleanly to DomainException.notFound and DomainException.conflict.
## 🏗️ Markus Keller — Senior Application Architect ### Observations - **`@ElementCollection` is the right call** for `group_ids` — native PostgreSQL `uuid[]` with JPA requires either a custom converter or a Hibernate-specific annotation. The `@ElementCollection` join table is already the project pattern (`group_permissions` in `UserGroup`) and can be extracted into a module boundary later if needed. - **`RateLimitInterceptor` registration**: the issue mentions a `HandlerInterceptor`, but it must be registered via a `WebMvcConfigurer` bean's `addInterceptors()` to scope it correctly to only the two public paths. Registering it as a `@Component` without `addInterceptors()` will not apply it. - **Response shape for invalid code on `GET /api/auth/invite/{code}`**: the issue says "invalid/exhausted/expired token → clear error." Returning a proper 4xx (`404`, `409`, `410`) is preferable to a `200` with 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 the `permitAll()` list in `SecurityConfig`. The issue implementation checklist doesn't call this out explicitly — it belongs in step 6 (AuthController), but should be an explicit sub-bullet. ### Recommendations - Document the `@ElementCollection` join table choice in the entity — one-line comment is enough since the native array alternative would surprise any Spring developer. - Confirm the `RateLimitInterceptor` is registered via `WebMvcConfigurer.addInterceptors()` scoped only to the two public endpoints — not applied globally. - Use distinct 4xx status codes per failure mode: `404` (code not found), `409` (already exhausted/revoked), `410` (expired). These map cleanly to `DomainException.notFound` and `DomainException.conflict`.
Author
Owner

🔒 Nora Steiner — Security Engineer

Observations

  • Rate limiter memory leak: a 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 with expireAfterWrite).
  • TOCTOU on concurrent redemption: validateCode reads use_count, then redeemInvite increments it in a separate step. Two concurrent requests with the same code can both pass the use_count < max_uses check. Fix: use an atomic UPDATE 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.
  • Password minimum length: the issue mentions "password minimum length enforced backend-side" but does not specify a value. Leaving it unspecified means it will be omitted or set inconsistently.
  • Email enumeration on registration: POST /api/auth/register will return a 409 if 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.
  • SecurityConfig permitAll(): confirm both /api/auth/invite/** and /api/auth/register are explicitly listed. A missing entry means Spring Security will challenge with 401 before the controller is even reached.

Recommendations

  • Replace ConcurrentHashMap<String, Deque<Instant>> with a Caffeine cache: Caffeine.newBuilder().expireAfterWrite(1, MINUTES).maximumSize(10_000).build() — bounds memory and auto-evicts stale entries.
  • Implement the atomic UPDATE pattern for redemption — this is the only correct fix for the TOCTOU. @Transactional alone does not prevent the race at READ COMMITTED isolation.
  • Establish a minimum password length (recommend 12 characters) and add it as a named constant — apply it consistently here and in the password reset flow.
## 🔒 Nora Steiner — Security Engineer ### Observations - **Rate limiter memory leak**: a `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 with `expireAfterWrite`). - **TOCTOU on concurrent redemption**: `validateCode` reads `use_count`, then `redeemInvite` increments it in a separate step. Two concurrent requests with the same code can both pass the `use_count < max_uses` check. Fix: use an atomic `UPDATE 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. - **Password minimum length**: the issue mentions "password minimum length enforced backend-side" but does not specify a value. Leaving it unspecified means it will be omitted or set inconsistently. - **Email enumeration on registration**: `POST /api/auth/register` will return a `409` if 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. - **`SecurityConfig` `permitAll()`**: confirm both `/api/auth/invite/**` and `/api/auth/register` are explicitly listed. A missing entry means Spring Security will challenge with 401 before the controller is even reached. ### Recommendations - Replace `ConcurrentHashMap<String, Deque<Instant>>` with a Caffeine cache: `Caffeine.newBuilder().expireAfterWrite(1, MINUTES).maximumSize(10_000).build()` — bounds memory and auto-evicts stale entries. - Implement the atomic UPDATE pattern for redemption — this is the only correct fix for the TOCTOU. `@Transactional` alone does not prevent the race at `READ COMMITTED` isolation. - Establish a minimum password length (recommend 12 characters) and add it as a named constant — apply it consistently here and in the password reset flow.
Author
Owner

🧪 Sara Holt — QA Engineer

Observations

  • No AuthControllerTest exists in the current codebase — this PR creates it from scratch. Happy path and all error cases for both new endpoints must be covered.
  • Missing edge case scenarios not covered by the current verification checklist:
    • Expired invite (past expires_at) → verify 410
    • Revoked invite → verify 409
    • Exhausted invite (use_count == max_uses) → verify 409
    • Code with max_uses = NULL (unlimited) used 100 times → still valid
    • Personal invite (max_uses = 1) used concurrently by two requests → only one succeeds
    • Registration with a duplicate username → verify 409 with clear error
    • Registration with a duplicate email → verify 409 with clear error
  • InviteControllerTest permissions: test that GET /api/invites with a user lacking ADMIN_USER returns 403 — not just that authenticated users with the permission can access it.
  • E2E flow gap: the verification checklist jumps from "create invite" to "use link" but doesn't include "verify use_count incremented in the admin UI."

Recommendations

  • Add all 7 edge cases above as explicit test methods in AuthControllerTest and InviteServiceTest.
  • Add an InviteControllerTest case: non-admin user attempts POST /api/invites → 403.
  • Add to verification checklist: after using an invite, reload /admin/invites and confirm use_count shows 1 / 1.
  • The concurrent redemption test requires two parallel requests — write it as an InviteServiceTest with two threads and verify that exactly one user is created.
## 🧪 Sara Holt — QA Engineer ### Observations - **No `AuthControllerTest` exists** in the current codebase — this PR creates it from scratch. Happy path and all error cases for both new endpoints must be covered. - **Missing edge case scenarios** not covered by the current verification checklist: - Expired invite (past `expires_at`) → verify 410 - Revoked invite → verify 409 - Exhausted invite (`use_count == max_uses`) → verify 409 - Code with `max_uses = NULL` (unlimited) used 100 times → still valid - Personal invite (max_uses = 1) used concurrently by two requests → only one succeeds - Registration with a duplicate username → verify 409 with clear error - Registration with a duplicate email → verify 409 with clear error - **`InviteControllerTest` permissions**: test that `GET /api/invites` with a user lacking `ADMIN_USER` returns 403 — not just that authenticated users with the permission can access it. - **E2E flow gap**: the verification checklist jumps from "create invite" to "use link" but doesn't include "verify `use_count` incremented in the admin UI." ### Recommendations - Add all 7 edge cases above as explicit test methods in `AuthControllerTest` and `InviteServiceTest`. - Add an `InviteControllerTest` case: non-admin user attempts `POST /api/invites` → 403. - Add to verification checklist: after using an invite, reload `/admin/invites` and confirm `use_count` shows `1 / 1`. - The concurrent redemption test requires two parallel requests — write it as an `InviteServiceTest` with two threads and verify that exactly one user is created.
Author
Owner

🎨 Leonie Voss — UX & Accessibility Designer

Observations

  • Password show/hide toggle missing: the /register form 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.
  • Pre-filled field visual distinction: when name/email are pre-filled from the invite, users may not realize they can edit them. Fields should be visually distinct (e.g. a subtle highlight or label suffix "— vom Admin vorausgefüllt") so the user understands the source.
  • Copy-button UX for the shareable URL: the admin page shows the URL after creation but the design doesn't describe the copy button's confirmation state. A "Kopiert!" flash (replacing the button text for 2s) gives the admin feedback that the copy succeeded — especially important on mobile where clipboard access isn't always visible.
  • aria-live on success banner: the /login?registered=1 success banner must have aria-live="polite" so screen readers announce it — it appears after a redirect and won't be announced otherwise.
  • Status badge accessibility: the invite table uses status badges (active / exhausted / revoked / expired). Color alone is not sufficient — each badge needs a text label (which is already the plan) and sufficient contrast ratio per WCAG AA.

Recommendations

  • Add a password visibility toggle (<button type="button"> with eye icon) to the password field on /register — same pattern used in /login if one exists, otherwise add it there too.
  • Pre-filled fields: add a small helper text below each pre-filled input ("Von deiner Einladung übernommen — du kannst es ändern").
  • Copy button: implement a 2-second "Kopiert!" confirmation state using a setTimeout reset.
  • Add aria-live="polite" to the registration success banner on /login.
## 🎨 Leonie Voss — UX & Accessibility Designer ### Observations - **Password show/hide toggle missing**: the `/register` form 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. - **Pre-filled field visual distinction**: when name/email are pre-filled from the invite, users may not realize they can edit them. Fields should be visually distinct (e.g. a subtle highlight or label suffix "— vom Admin vorausgefüllt") so the user understands the source. - **Copy-button UX for the shareable URL**: the admin page shows the URL after creation but the design doesn't describe the copy button's confirmation state. A "Kopiert!" flash (replacing the button text for 2s) gives the admin feedback that the copy succeeded — especially important on mobile where clipboard access isn't always visible. - **`aria-live` on success banner**: the `/login?registered=1` success banner must have `aria-live="polite"` so screen readers announce it — it appears after a redirect and won't be announced otherwise. - **Status badge accessibility**: the invite table uses status badges (active / exhausted / revoked / expired). Color alone is not sufficient — each badge needs a text label (which is already the plan) and sufficient contrast ratio per WCAG AA. ### Recommendations - Add a password visibility toggle (`<button type="button">` with eye icon) to the password field on `/register` — same pattern used in `/login` if one exists, otherwise add it there too. - Pre-filled fields: add a small helper text below each pre-filled input ("Von deiner Einladung übernommen — du kannst es ändern"). - Copy button: implement a 2-second "Kopiert!" confirmation state using a `setTimeout` reset. - Add `aria-live="polite"` to the registration success banner on `/login`.
Author
Owner

⚙️ Tobias Wendt — DevOps Engineer

Observations

  • APP_BASE_URL is a required new env variable: InviteService must 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 to docker-compose.yml or .env.example.
  • No new infrastructure: the RateLimitInterceptor uses in-memory state — no Redis or external dependency needed. This is correct for a single-node family app.
  • Caffeine dependency: if Nora's recommendation to use Caffeine for rate limiting is adopted, com.github.ben-manes.caffeine:caffeine must be added to pom.xml. Spring Boot already pulls it in transitively via spring-boot-starter-cache if that starter is present — worth checking before adding a duplicate.

Recommendations

  • Add APP_BASE_URL to docker-compose.yml (e.g. APP_BASE_URL=http://localhost:8080) and to .env.example with a comment explaining it's used for invite link generation.
  • Add a startup check (@PostConstruct or @Value with no default) so the backend fails fast if APP_BASE_URL is not configured — silent misconfiguration is worse than a loud startup failure.
  • Check whether spring-boot-starter-cache is already in pom.xml before adding Caffeine as a new dependency.
## ⚙️ Tobias Wendt — DevOps Engineer ### Observations - **`APP_BASE_URL` is a required new env variable**: `InviteService` must 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 to `docker-compose.yml` or `.env.example`. - **No new infrastructure**: the `RateLimitInterceptor` uses in-memory state — no Redis or external dependency needed. This is correct for a single-node family app. - **Caffeine dependency**: if Nora's recommendation to use Caffeine for rate limiting is adopted, `com.github.ben-manes.caffeine:caffeine` must be added to `pom.xml`. Spring Boot already pulls it in transitively via `spring-boot-starter-cache` if that starter is present — worth checking before adding a duplicate. ### Recommendations - Add `APP_BASE_URL` to `docker-compose.yml` (e.g. `APP_BASE_URL=http://localhost:8080`) and to `.env.example` with a comment explaining it's used for invite link generation. - Add a startup check (`@PostConstruct` or `@Value` with no default) so the backend fails fast if `APP_BASE_URL` is not configured — silent misconfiguration is worse than a loud startup failure. - Check whether `spring-boot-starter-cache` is already in `pom.xml` before adding Caffeine as a new dependency.
Author
Owner

🗳️ Decision Queue — Action Required

4 decisions need your input before implementation starts.

Architecture

  • Query param naming?token= conflicts with the existing password reset flow in hooks.server.ts. Use ?code= (clean, no ambiguity) or ?invite= (more explicit). Either works; pick one now so it's consistent everywhere. (Raised by: Felix)
  • Default filter for 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=all query param could offer both. (Raised by: Sara)

Security

  • Minimum password length — the issue says "enforced backend-side" but gives no value. Recommend 12 characters as a named constant applied here and retroactively to the password reset flow. Confirm the number. (Raised by: Nora)

UX

  • Are pre-filled fields editable by the registrant? — if the admin pre-fills name and email, can the registrant change them before submitting? Locking them gives the admin control; allowing edits gives the registrant flexibility (e.g. nickname vs. full name). (Raised by: Leonie)

Out of Scope (log for follow-up)

  • Auto-login after registration vs. redirect to /login — deferred; current plan redirects to /login with a success message. Revisit if UX feedback suggests friction.
## 🗳️ Decision Queue — Action Required _4 decisions need your input before implementation starts._ ### Architecture - **Query param naming** — `?token=` conflicts with the existing password reset flow in `hooks.server.ts`. Use `?code=` (clean, no ambiguity) or `?invite=` (more explicit). Either works; pick one now so it's consistent everywhere. _(Raised by: Felix)_ - **Default filter for `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=all` query param could offer both. _(Raised by: Sara)_ ### Security - **Minimum password length** — the issue says "enforced backend-side" but gives no value. Recommend 12 characters as a named constant applied here and retroactively to the password reset flow. Confirm the number. _(Raised by: Nora)_ ### UX - **Are pre-filled fields editable by the registrant?** — if the admin pre-fills name and email, can the registrant change them before submitting? Locking them gives the admin control; allowing edits gives the registrant flexibility (e.g. nickname vs. full name). _(Raised by: Leonie)_ ### Out of Scope (log for follow-up) - Auto-login after registration vs. redirect to `/login` — deferred; current plan redirects to `/login` with a success message. Revisit if UX feedback suggests friction.
Author
Owner

Design spec committed to main in f7747ba.

docs/specs/register-page-spec.html — 10 sections:

  1. Full desktop overview (1280px, 55% scale mockup)
  2. Header anatomy — mint stripe, wordmark, DE/EN/ES language toggle states
  3. Above-card — eyebrow, headline, subtext in DE/EN/ES
  4. Form — Über dich (name fields, empty/focus/filled/error states)
  5. Form — Konto (email + password with show/hide, match/mismatch feedback)
  6. Notification preference card (unchecked vs. checked)
  7. Submit button (default/hover/loading) + footer links
  8. Success panel (post-submit, DE + EN)
  9. Mobile layout (320px, 3 states)
  10. Implementation notes — 29 i18n keys, WCAG 2.2 checklist, backend wiring + API endpoints
Design spec committed to `main` in f7747ba. **`docs/specs/register-page-spec.html`** — 10 sections: 1. Full desktop overview (1280px, 55% scale mockup) 2. Header anatomy — mint stripe, wordmark, DE/EN/ES language toggle states 3. Above-card — eyebrow, headline, subtext in DE/EN/ES 4. Form — Über dich (name fields, empty/focus/filled/error states) 5. Form — Konto (email + password with show/hide, match/mismatch feedback) 6. Notification preference card (unchecked vs. checked) 7. Submit button (default/hover/loading) + footer links 8. Success panel (post-submit, DE + EN) 9. Mobile layout (320px, 3 states) 10. Implementation notes — 29 i18n keys, WCAG 2.2 checklist, backend wiring + API endpoints
Author
Owner

🗳️ 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=all to load the full audit history. The backend should support a status query 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.

## 🗳️ 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=all` to load the full audit history. The backend should support a `status` query 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.
Sign in to join this conversation.
No Label feature user
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#269