feat(backend): self-registration for Gemeinde Verantwortliche

New onboarding/ module. A prospective Verantwortliche/r signs in with their
Konfi-Castle-ID (Authentik), looks up a KC by invite code, picks an existing
Gemeinde, and registers:

- GET  /api/onboarding/kc/:inviteCode  -> KC name + its Gemeinden (public;
  the invite code is the shared secret)
- POST /api/onboarding/verantwortliche -> verifies the raw Authentik bearer
  token's claims (no local Membership required yet via new
  TokenVerificationService.verifyAuthentikClaims), JIT-provisions the local
  User, and creates a Membership with status PENDING. Idempotent per
  (user, kc, gemeinde).
- GET  /api/onboarding/requests?kcId=            (LT) list pending
- POST /api/onboarding/requests/:id/approve|reject (LT) approve flips to
  ACTIVE, reject deletes.

Schema: Membership gains status (enum MembershipStatus { ACTIVE, PENDING },
default ACTIVE). AuthentikStrategy / TokenVerificationService / TeamAuthService
now load only ACTIVE memberships, so a pending request grants nothing until
approved. Membership create/update/delete flow through the sync log.

Tests: onboarding.service.spec.ts (14 cases); npm test green at 46.
Docs (plan + backend README) updated.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-10 08:01:19 +02:00
co-authored by Claude Sonnet 5
parent d48c07b0e4
commit 6ed5aa2c76
11 changed files with 524 additions and 13 deletions
+11 -2
View File
@@ -47,6 +47,14 @@ enum Role {
GEMEINDE_TEAMER
}
/// PENDING memberships come from self-registration and grant no rights until
/// a Leitungsteam member approves them. Everything created by LT/Verantwortliche
/// directly is ACTIVE from the start.
enum MembershipStatus {
ACTIVE
PENDING
}
/// A team member account. Leitungsteam and Gemeinde Verantwortliche are
/// Authentik-backed (`authentikSub` set, `passwordHash` null). Gemeinde
/// Teamer are local accounts created by a Verantwortliche/r (`passwordHash`
@@ -70,12 +78,13 @@ model User {
/// Scopes a User's role to a specific Kc (and Gemeinde, if applicable).
/// LEITUNGSTEAM memberships apply to all Kcs implicitly and omit gemeindeId.
model Membership {
id String @id @default(cuid())
id String @id @default(cuid())
userId String
kcId String
gemeindeId String?
role Role
createdAt DateTime @default(now())
status MembershipStatus @default(ACTIVE)
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade)