From dbaafabcf4c0dd1f11d767c2b21218c244650383 Mon Sep 17 00:00:00 2001 From: linus Date: Thu, 10 Sep 2026 08:01:19 +0200 Subject: [PATCH] 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 --- backend/README.md | 16 +- backend/prisma/schema.prisma | 13 +- backend/src/app.module.ts | 2 + backend/src/auth/authentik.strategy.ts | 2 +- backend/src/auth/team-auth.service.ts | 2 +- .../src/auth/token-verification.service.ts | 34 ++- .../dto/register-verantwortliche.dto.ts | 11 + .../src/onboarding/onboarding.controller.ts | 67 ++++++ backend/src/onboarding/onboarding.module.ts | 11 + .../src/onboarding/onboarding.service.spec.ts | 224 ++++++++++++++++++ backend/src/onboarding/onboarding.service.ts | 155 ++++++++++++ plan-kcAppMultiTenantPlatform.prompt.md | 14 +- 12 files changed, 533 insertions(+), 18 deletions(-) create mode 100644 backend/src/onboarding/dto/register-verantwortliche.dto.ts create mode 100644 backend/src/onboarding/onboarding.controller.ts create mode 100644 backend/src/onboarding/onboarding.module.ts create mode 100644 backend/src/onboarding/onboarding.service.spec.ts create mode 100644 backend/src/onboarding/onboarding.service.ts diff --git a/backend/README.md b/backend/README.md index fd8764e..d005f65 100644 --- a/backend/README.md +++ b/backend/README.md @@ -59,6 +59,15 @@ client's host - no separate web server is needed. or a Verantwortliche/r for their own Gemeinde (enforced in `TeamerService`, since `RolesGuard` only scopes by `kcId`). Files/chat read endpoints accept `'team'` tokens too, so Teamer see non-Konfi files and chat. +- `onboarding/` — self-registration for Gemeinde Verantwortliche. + `GET /onboarding/kc/:inviteCode` (public) returns the KC name + its + Gemeinden to pick from. `POST /onboarding/verantwortliche` takes the + caller's raw Authentik bearer token (no local `Membership` needed yet), + JIT-provisions the local `User` from the token claims, and creates a + `Membership` with `status = PENDING`. Leitungsteam reviews via + `GET /onboarding/requests?kcId=` and `POST /onboarding/requests/:id/approve` + or `.../reject`. Auth strategies only load `ACTIVE` memberships, so a + pending request grants nothing until approved. - `wahl/` — Wahl/Workshop administration (Leitungsteam-only), guest Teilnehmer submission, Force-Zuteilung overrides, and `ZuteilungService`: a faithful port of the WP plugin's `kc_run_zuteilung` (force-assignments → @@ -96,7 +105,8 @@ client's host - no separate web server is needed. Leitungsteam roles are global across all KCs). All planned backend phases are implemented. `npm test` runs Jest unit tests -(`ZuteilungService`, `TeamAuthService`, `TeamerService`; Prisma mocked). -Remaining work: the Flutter clients (see repo root README), Authentik -provisioning for LT/Verantwortliche, and the first real Prisma migration +(`ZuteilungService`, `TeamAuthService`, `TeamerService`, `OnboardingService`; +Prisma mocked). Remaining work: the Flutter clients (see repo root README), +Authentik JIT provisioning for LT (Verantwortliche already self-provision via +`onboarding/`), invite email delivery, and the first real Prisma migration (only `schema.prisma` exists so far). diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 5812b27..460f05f 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -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) diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 806bcf0..ffffde1 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -7,6 +7,7 @@ import { AuthModule } from './auth/auth.module'; import { KcModule } from './kc/kc.module'; import { GemeindeModule } from './gemeinde/gemeinde.module'; import { TeamerModule } from './teamer/teamer.module'; +import { OnboardingModule } from './onboarding/onboarding.module'; import { WahlModule } from './wahl/wahl.module'; import { FilesModule } from './files/files.module'; import { ChatModule } from './chat/chat.module'; @@ -27,6 +28,7 @@ import { SyncModule } from './sync/sync.module'; KcModule, GemeindeModule, TeamerModule, + OnboardingModule, WahlModule, FilesModule, ChatModule, diff --git a/backend/src/auth/authentik.strategy.ts b/backend/src/auth/authentik.strategy.ts index 3cf9465..f5b88b8 100644 --- a/backend/src/auth/authentik.strategy.ts +++ b/backend/src/auth/authentik.strategy.ts @@ -43,7 +43,7 @@ export class AuthentikStrategy extends PassportStrategy(Strategy, 'authentik') { async validate(payload: AuthentikJwtPayload): Promise { const user = await this.prisma.user.findUnique({ where: { authentikSub: payload.sub }, - include: { memberships: true }, + include: { memberships: { where: { status: 'ACTIVE' } } }, }); if (!user) { throw new UnauthorizedException('User not provisioned locally yet'); diff --git a/backend/src/auth/team-auth.service.ts b/backend/src/auth/team-auth.service.ts index 5ab4833..048333e 100644 --- a/backend/src/auth/team-auth.service.ts +++ b/backend/src/auth/team-auth.service.ts @@ -141,7 +141,7 @@ export class TeamAuthService { async resolve(userId: string): Promise { const user = await this.prisma.user.findFirst({ where: { id: userId, passwordHash: { not: null } }, - include: { memberships: true }, + include: { memberships: { where: { status: 'ACTIVE' } } }, }); if (!user) { throw new UnauthorizedException('Team account no longer exists'); diff --git a/backend/src/auth/token-verification.service.ts b/backend/src/auth/token-verification.service.ts index 6823c52..73a09b5 100644 --- a/backend/src/auth/token-verification.service.ts +++ b/backend/src/auth/token-verification.service.ts @@ -25,7 +25,15 @@ export class TokenVerificationService { this.jwks = jwksRsa({ jwksUri: `${this.issuerUrl}/jwks/`, cache: true, rateLimit: true }); } - async verifyAuthentik(token: string): Promise { + /// Verifies an Authentik token's signature and returns its identity claims, + /// without requiring a local User to exist yet (used by the onboarding + /// self-registration path, which provisions that User). + async verifyAuthentikClaims(token: string): Promise<{ + sub: string; + email: string; + firstName: string; + lastName: string; + }> { const decoded = jwt.decode(token, { complete: true }); const kid = decoded?.header.kid; if (!kid) { @@ -35,14 +43,28 @@ export class TokenVerificationService { const payload = jwt.verify(token, key.getPublicKey(), { issuer: this.issuerUrl, algorithms: ['RS256'], - }) as jwt.JwtPayload; - if (!payload.sub) { - throw new UnauthorizedException('Authentik token missing subject'); + }) as jwt.JwtPayload & { + email?: string; + given_name?: string; + family_name?: string; + }; + if (!payload.sub || !payload.email) { + throw new UnauthorizedException('Authentik token missing subject or email'); } + return { + sub: payload.sub, + email: payload.email, + firstName: payload.given_name ?? '', + lastName: payload.family_name ?? '', + }; + } + + async verifyAuthentik(token: string): Promise { + const { sub } = await this.verifyAuthentikClaims(token); const user = await this.prisma.user.findUnique({ - where: { authentikSub: payload.sub }, - include: { memberships: true }, + where: { authentikSub: sub }, + include: { memberships: { where: { status: 'ACTIVE' } } }, }); if (!user) { throw new UnauthorizedException('User not provisioned locally yet'); diff --git a/backend/src/onboarding/dto/register-verantwortliche.dto.ts b/backend/src/onboarding/dto/register-verantwortliche.dto.ts new file mode 100644 index 0000000..13048aa --- /dev/null +++ b/backend/src/onboarding/dto/register-verantwortliche.dto.ts @@ -0,0 +1,11 @@ +import { IsNotEmpty, IsString } from 'class-validator'; + +export class RegisterVerantwortlicheDto { + @IsString() + @IsNotEmpty() + inviteCode!: string; + + @IsString() + @IsNotEmpty() + gemeindeId!: string; +} diff --git a/backend/src/onboarding/onboarding.controller.ts b/backend/src/onboarding/onboarding.controller.ts new file mode 100644 index 0000000..4fb1861 --- /dev/null +++ b/backend/src/onboarding/onboarding.controller.ts @@ -0,0 +1,67 @@ +import { + Body, + Controller, + Get, + Headers, + Param, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { OnboardingService } from './onboarding.service'; +import { RegisterVerantwortlicheDto } from './dto/register-verantwortliche.dto'; +import { Roles } from '../common/roles.decorator'; +import { RolesGuard } from '../common/roles.guard'; +import { Role } from '../common/role.enum'; + +function bearer(header?: string): string | undefined { + return header?.startsWith('Bearer ') ? header.slice('Bearer '.length) : undefined; +} + +@Controller('onboarding') +export class OnboardingController { + constructor(private readonly onboarding: OnboardingService) {} + + /// Public lookup: invite code -> KC name + selectable Gemeinden. + @Get('kc/:inviteCode') + resolveInvite(@Param('inviteCode') inviteCode: string) { + return this.onboarding.resolveInvite(inviteCode); + } + + /// Self-registration as Gemeinde Verantwortliche/r. Authenticated by the + /// caller's raw Authentik bearer token (no local Membership required yet). + @Post('verantwortliche') + registerVerantwortliche( + @Body() dto: RegisterVerantwortlicheDto, + @Headers('authorization') authorization?: string, + ) { + return this.onboarding.registerVerantwortliche( + bearer(authorization), + dto.inviteCode, + dto.gemeindeId, + ); + } + + /// Leitungsteam: review and act on pending self-registrations. + @Get('requests') + @UseGuards(AuthGuard('authentik'), RolesGuard) + @Roles(Role.LEITUNGSTEAM) + listRequests(@Query('kcId') kcId: string) { + return this.onboarding.listRequests(kcId); + } + + @Post('requests/:membershipId/approve') + @UseGuards(AuthGuard('authentik'), RolesGuard) + @Roles(Role.LEITUNGSTEAM) + approve(@Param('membershipId') membershipId: string) { + return this.onboarding.approve(membershipId); + } + + @Post('requests/:membershipId/reject') + @UseGuards(AuthGuard('authentik'), RolesGuard) + @Roles(Role.LEITUNGSTEAM) + reject(@Param('membershipId') membershipId: string) { + return this.onboarding.reject(membershipId); + } +} diff --git a/backend/src/onboarding/onboarding.module.ts b/backend/src/onboarding/onboarding.module.ts new file mode 100644 index 0000000..307c076 --- /dev/null +++ b/backend/src/onboarding/onboarding.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { OnboardingService } from './onboarding.service'; +import { OnboardingController } from './onboarding.controller'; + +@Module({ + imports: [AuthModule], + providers: [OnboardingService], + controllers: [OnboardingController], +}) +export class OnboardingModule {} diff --git a/backend/src/onboarding/onboarding.service.spec.ts b/backend/src/onboarding/onboarding.service.spec.ts new file mode 100644 index 0000000..7e1ee35 --- /dev/null +++ b/backend/src/onboarding/onboarding.service.spec.ts @@ -0,0 +1,224 @@ +import { + BadRequestException, + NotFoundException, + UnauthorizedException, +} from '@nestjs/common'; +import { MembershipStatus, Role } from '@prisma/client'; +import { OnboardingService } from './onboarding.service'; + +/// Prisma / Sync / TokenVerification faked in memory. + +const CLAIMS = { + sub: 'authentik-sub-1', + email: 'Vera@example.org', + firstName: 'Vera', + lastName: 'Wong', +}; + +function makeService(seed: { + kc?: { id: string; name: string; inviteCode: string; isActive: boolean } | null; + gemeinde?: { id: string; name: string; kcId: string } | null; + user?: { id: string; authentikSub: string } | null; + membership?: { + id: string; + status: MembershipStatus; + userId: string; + kcId: string; + gemeindeId: string; + } | null; + tokenThrows?: boolean; +}) { + const state = { + membership: seed.membership ?? null, + }; + + const prisma = { + kc: { + findUnique: jest.fn().mockResolvedValue( + seed.kc === undefined + ? { id: 'kc-1', name: 'KC 2026', inviteCode: 'code-1', isActive: true, gemeinden: [] } + : seed.kc, + ), + }, + gemeinde: { + findUnique: jest.fn().mockResolvedValue( + seed.gemeinde === undefined ? { id: 'gem-1', name: 'Nord', kcId: 'kc-1' } : seed.gemeinde, + ), + }, + user: { + findUnique: jest.fn().mockResolvedValue(seed.user ?? null), + create: jest.fn(({ data }: { data: Record }) => + Promise.resolve({ id: 'u-new', ...data }), + ), + }, + membership: { + findUnique: jest.fn(() => Promise.resolve(state.membership)), + findMany: jest.fn().mockResolvedValue([]), + create: jest.fn(({ data }: { data: Record }) => { + state.membership = { id: 'mem-new', ...data } as never; + return Promise.resolve(state.membership); + }), + update: jest.fn(({ data }: { data: { status: MembershipStatus } }) => { + state.membership = { ...state.membership!, ...data }; + return Promise.resolve(state.membership); + }), + delete: jest.fn(() => Promise.resolve(state.membership!)), + }, + }; + const sync = { capture: jest.fn().mockResolvedValue(undefined) }; + const tokens = { + verifyAuthentikClaims: seed.tokenThrows + ? jest.fn().mockRejectedValue(new UnauthorizedException('bad token')) + : jest.fn().mockResolvedValue(CLAIMS), + }; + + const service = new OnboardingService(prisma as never, sync as never, tokens as never); + return { service, prisma, sync, tokens }; +} + +describe('OnboardingService.resolveInvite', () => { + it('404s an unknown code', async () => { + const { service } = makeService({ kc: null }); + await expect(service.resolveInvite('nope')).rejects.toBeInstanceOf(NotFoundException); + }); + + it('404s an inactive KC', async () => { + const { service } = makeService({ + kc: { id: 'kc-1', name: 'KC', inviteCode: 'c', isActive: false }, + }); + await expect(service.resolveInvite('c')).rejects.toBeInstanceOf(NotFoundException); + }); + + it('returns the KC name and its Gemeinden', async () => { + const { service, prisma } = makeService({}); + prisma.kc.findUnique = jest.fn().mockResolvedValue({ + id: 'kc-1', + name: 'KC 2026', + isActive: true, + gemeinden: [{ id: 'gem-1', name: 'Nord' }], + }); + await expect(service.resolveInvite('code-1')).resolves.toEqual({ + kcId: 'kc-1', + kcName: 'KC 2026', + gemeinden: [{ id: 'gem-1', name: 'Nord' }], + }); + }); +}); + +describe('OnboardingService.registerVerantwortliche', () => { + it('rejects a missing token', async () => { + const { service } = makeService({}); + await expect( + service.registerVerantwortliche(undefined, 'code-1', 'gem-1'), + ).rejects.toBeInstanceOf(UnauthorizedException); + }); + + it('propagates an invalid token', async () => { + const { service } = makeService({ tokenThrows: true }); + await expect( + service.registerVerantwortliche('t', 'code-1', 'gem-1'), + ).rejects.toBeInstanceOf(UnauthorizedException); + }); + + it('404s an unknown invite code', async () => { + const { service } = makeService({ kc: null }); + await expect( + service.registerVerantwortliche('t', 'bad', 'gem-1'), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('400s when the Gemeinde is not part of the KC', async () => { + const { service } = makeService({ gemeinde: { id: 'gem-9', name: 'X', kcId: 'other-kc' } }); + await expect( + service.registerVerantwortliche('t', 'code-1', 'gem-9'), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('provisions the user and creates a PENDING membership', async () => { + const { service, prisma, sync } = makeService({ user: null, membership: null }); + const res = await service.registerVerantwortliche('t', 'code-1', 'gem-1'); + + expect(prisma.user.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + authentikSub: 'authentik-sub-1', + email: 'vera@example.org', + }), + }); + expect(prisma.membership.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + role: Role.GEMEINDE_VERANTWORTLICHER, + status: MembershipStatus.PENDING, + gemeindeId: 'gem-1', + }), + }); + expect(res).toMatchObject({ status: MembershipStatus.PENDING, kcName: 'KC 2026' }); + expect(sync.capture).toHaveBeenCalledWith('User', 'CREATE', expect.any(String), expect.anything()); + expect(sync.capture).toHaveBeenCalledWith('Membership', 'CREATE', expect.any(String), expect.anything()); + }); + + it('does not re-create the user when one already exists', async () => { + const { service, prisma } = makeService({ + user: { id: 'u-1', authentikSub: 'authentik-sub-1' }, + membership: null, + }); + await service.registerVerantwortliche('t', 'code-1', 'gem-1'); + expect(prisma.user.create).not.toHaveBeenCalled(); + expect(prisma.membership.create).toHaveBeenCalled(); + }); + + it('returns the existing membership status without creating a second one', async () => { + const { service, prisma } = makeService({ + user: { id: 'u-1', authentikSub: 'authentik-sub-1' }, + membership: { + id: 'mem-1', + status: MembershipStatus.ACTIVE, + userId: 'u-1', + kcId: 'kc-1', + gemeindeId: 'gem-1', + }, + }); + const res = await service.registerVerantwortliche('t', 'code-1', 'gem-1'); + expect(res).toMatchObject({ membershipId: 'mem-1', status: MembershipStatus.ACTIVE }); + expect(prisma.membership.create).not.toHaveBeenCalled(); + }); +}); + +describe('OnboardingService.approve / reject', () => { + const pending = { + id: 'mem-1', + status: MembershipStatus.PENDING, + userId: 'u-1', + kcId: 'kc-1', + gemeindeId: 'gem-1', + }; + + it('404s approving an unknown request', async () => { + const { service } = makeService({ membership: null }); + await expect(service.approve('mem-x')).rejects.toBeInstanceOf(NotFoundException); + }); + + it('400s approving a non-pending request', async () => { + const { service } = makeService({ + membership: { ...pending, status: MembershipStatus.ACTIVE }, + }); + await expect(service.approve('mem-1')).rejects.toBeInstanceOf(BadRequestException); + }); + + it('flips the status to ACTIVE and captures the update', async () => { + const { service, prisma, sync } = makeService({ membership: { ...pending } }); + await service.approve('mem-1'); + expect(prisma.membership.update).toHaveBeenCalledWith({ + where: { id: 'mem-1' }, + data: { status: MembershipStatus.ACTIVE }, + }); + expect(sync.capture).toHaveBeenCalledWith('Membership', 'UPDATE', 'mem-1', expect.anything()); + }); + + it('deletes on reject and captures the delete', async () => { + const { service, prisma, sync } = makeService({ membership: { ...pending } }); + const res = await service.reject('mem-1'); + expect(res).toEqual({ id: 'mem-1' }); + expect(prisma.membership.delete).toHaveBeenCalledWith({ where: { id: 'mem-1' } }); + expect(sync.capture).toHaveBeenCalledWith('Membership', 'DELETE', 'mem-1', { id: 'mem-1' }); + }); +}); diff --git a/backend/src/onboarding/onboarding.service.ts b/backend/src/onboarding/onboarding.service.ts new file mode 100644 index 0000000..71b606f --- /dev/null +++ b/backend/src/onboarding/onboarding.service.ts @@ -0,0 +1,155 @@ +import { + BadRequestException, + Injectable, + NotFoundException, + UnauthorizedException, +} from '@nestjs/common'; +import { MembershipStatus, Role, SyncOperation } from '@prisma/client'; +import { PrismaClient } from '../prisma/prisma.module'; +import { SyncService } from '../sync/sync.service'; +import { TokenVerificationService } from '../auth/token-verification.service'; + +/// Self-service onboarding for Gemeinde Verantwortliche. The person signs in +/// with their Konfi-Castle-ID (Authentik) and submits a KC invite code plus +/// the Gemeinde they belong to; this provisions their local User (JIT) and a +/// PENDING membership that a Leitungsteam member must approve before it grants +/// any rights. +@Injectable() +export class OnboardingService { + constructor( + private readonly prisma: PrismaClient, + private readonly sync: SyncService, + private readonly tokens: TokenVerificationService, + ) {} + + /// Public: resolves an invite code to the KC name and its Gemeinden so the + /// registrant can pick theirs. The code itself is the shared secret. + async resolveInvite(inviteCode: string) { + const kc = await this.prisma.kc.findUnique({ + where: { inviteCode }, + include: { + gemeinden: { select: { id: true, name: true }, orderBy: { name: 'asc' } }, + }, + }); + if (!kc || !kc.isActive) { + throw new NotFoundException('Unknown or inactive KC invite code'); + } + return { kcId: kc.id, kcName: kc.name, gemeinden: kc.gemeinden }; + } + + async registerVerantwortliche(token: string | undefined, inviteCode: string, gemeindeId: string) { + if (!token) { + throw new UnauthorizedException('Missing Authentik bearer token'); + } + const claims = await this.tokens.verifyAuthentikClaims(token); + + const kc = await this.prisma.kc.findUnique({ where: { inviteCode } }); + if (!kc || !kc.isActive) { + throw new NotFoundException('Unknown or inactive KC invite code'); + } + const gemeinde = await this.prisma.gemeinde.findUnique({ where: { id: gemeindeId } }); + if (!gemeinde || gemeinde.kcId !== kc.id) { + throw new BadRequestException('Gemeinde does not belong to this KC'); + } + + const user = await this.upsertUser(claims); + + const existing = await this.prisma.membership.findUnique({ + where: { + userId_kcId_gemeindeId: { userId: user.id, kcId: kc.id, gemeindeId }, + }, + }); + if (existing) { + return this.summary(existing.id, existing.status, kc.name, gemeinde.name); + } + + const membership = await this.prisma.membership.create({ + data: { + userId: user.id, + kcId: kc.id, + gemeindeId, + role: Role.GEMEINDE_VERANTWORTLICHER, + status: MembershipStatus.PENDING, + }, + }); + await this.sync.capture('Membership', SyncOperation.CREATE, membership.id, membership); + return this.summary(membership.id, membership.status, kc.name, gemeinde.name); + } + + async listRequests(kcId: string) { + return this.prisma.membership.findMany({ + where: { + kcId, + status: MembershipStatus.PENDING, + role: Role.GEMEINDE_VERANTWORTLICHER, + }, + include: { + user: { select: { id: true, email: true, firstName: true, lastName: true } }, + gemeinde: { select: { id: true, name: true } }, + }, + orderBy: { createdAt: 'asc' }, + }); + } + + async approve(membershipId: string) { + await this.getPendingOrThrow(membershipId); + const membership = await this.prisma.membership.update({ + where: { id: membershipId }, + data: { status: MembershipStatus.ACTIVE }, + }); + await this.sync.capture('Membership', SyncOperation.UPDATE, membership.id, membership); + return membership; + } + + async reject(membershipId: string) { + await this.getPendingOrThrow(membershipId); + const membership = await this.prisma.membership.delete({ where: { id: membershipId } }); + await this.sync.capture('Membership', SyncOperation.DELETE, membership.id, { id: membership.id }); + return { id: membership.id }; + } + + private async getPendingOrThrow(membershipId: string) { + const membership = await this.prisma.membership.findUnique({ where: { id: membershipId } }); + if (!membership) { + throw new NotFoundException('Request not found'); + } + if (membership.status !== MembershipStatus.PENDING) { + throw new BadRequestException('Request is not pending'); + } + return membership; + } + + private async upsertUser(claims: { + sub: string; + email: string; + firstName: string; + lastName: string; + }) { + const email = claims.email.toLowerCase(); + const existing = await this.prisma.user.findUnique({ + where: { authentikSub: claims.sub }, + }); + if (existing) { + return existing; + } + const user = await this.prisma.user.create({ + data: { + authentikSub: claims.sub, + email, + firstName: claims.firstName, + lastName: claims.lastName, + }, + }); + await this.sync.capture('User', SyncOperation.CREATE, user.id, user); + return user; + } + + private summary( + membershipId: string, + status: MembershipStatus, + kcName: string, + gemeindeName: string, + ) { + return { membershipId, status, kcName, gemeindeName }; + } +} diff --git a/plan-kcAppMultiTenantPlatform.prompt.md b/plan-kcAppMultiTenantPlatform.prompt.md index 1dee59d..6f2bbb2 100644 --- a/plan-kcAppMultiTenantPlatform.prompt.md +++ b/plan-kcAppMultiTenantPlatform.prompt.md @@ -15,8 +15,10 @@ Neuentwicklung, die das WordPress-Plugin **Workshop-Wahlen** (Wahlen/Workshops/T - **Gemeinde Verantwortliche** – pro Gemeinde/KC; verwalten **nur Nutzer ihrer eigenen Gemeinde** (legen Teamer an); Wahlen/Workshops macht ausschließlich LT; zudem Authentik Gruppe und Benutzer. - **Gemeinde Teamer** – von Verantwortlichen angelegt, ebenfalls Lokaler Account. - **Guest/Konfi** – optionaler, rein lokaler Account auf dem jeweiligen Server (kein Authentik), Vor-/Nachname Pflicht, **temporär pro KC/Event** (nicht wiederverwendbar über mehrere Events). -- **Membership**: verknüpft `User` (Authentik) ⇄ `Kc` (+ optional `Gemeinde`) ⇄ `Role`. LT-Memberships lassen `gemeindeId` leer und gelten global. -- **Einstieg/Onboarding**: LT vergibt pro KC einen Code/QR (enthält den KC-Key). Damit erhält man sofortigen Guest-Zugang **oder** kann sich vorab als Gemeinde Verantwortlicher/Teamer einer Gemeinde registrieren. +- **Membership**: verknüpft `User` ⇄ `Kc` (+ optional `Gemeinde`) ⇄ `Role`, mit `status` (`ACTIVE`/`PENDING`). LT-Memberships lassen `gemeindeId` leer und gelten global. `PENDING` (aus der Selbstregistrierung) gewährt keine Rechte, bis ein LT sie genehmigt — die Auth-Strategien laden nur `ACTIVE`-Memberships. +- **Einstieg/Onboarding**: LT vergibt pro KC einen Code/QR (enthält den KC-Key). Damit erhält man sofortigen Guest-Zugang **oder** kann sich vorab registrieren: + - **Gemeinde Verantwortliche/r**: `onboarding/`-Modul — mit Konfi-Castle-ID (Authentik) einloggen, KC-Code + bestehende Gemeinde wählen → `User` wird JIT angelegt, `Membership` als `PENDING`; LT genehmigt. + - **Gemeinde Teamer**: `teamer/`-Modul — von einer Verantwortliche/r direkt angelegt oder per Invite-Link/E-Mail-Invite selbst registriert (lokaler Account, sofort `ACTIVE`). - **Wahl** (Workshop-Wahl): von LT pro KC angelegt; Name trägt `datumsSchluessel` + `teil` (Bewusste Vereinfachung ggü. Original-Plugin: dort gibt es mehrere "Phasen" *innerhalb* einer Wahl via `Teilnehmer.phase`; hier ist stattdessen **eine Wahl = ein Teil/Phase**, gemäß expliziter Nutzer-Klarstellung). - **Workshop**: `kapazitaet`, `minTeilnehmer` (für Konsolidierung unterbesetzter Workshops). - **Teilnehmer**: Guest übermittelt `prioritaeten` (geordnete Workshop-ID-Liste, max. 3 – entspricht wunsch1..wunsch3 im Original). @@ -50,7 +52,7 @@ Vollständiges Schema: [backend/prisma/schema.prisma](backend/prisma/schema.pris - **Datei-Bytes werden nicht repliziert** – nur Metadaten (inkl. `storageKey`) laufen durchs Sync-Log. Lokaler und Cloud-Server müssen denselben Nextcloud/S3-Backend-Zugriff haben, damit ein `storageKey` auf beiden Seiten auflösbar ist. - **Push-Benachrichtigungen (FCM/APNs)** sind im Plan vorgesehen, aber noch nicht implementiert (Teil der noch ausstehenden Flutter-Client-Arbeit). - **Gemeinde-Verwaltung (CRUD)**: über `GemeindeController` (LT-only) verfügbar — Anlegen/Auflisten/Lesen/Umbenennen/Löschen von Gemeinden pro KC. Gemeinde Verantwortliche/Teamer erfahren ihre eigene Gemeinde weiterhin aus der `Membership`, nicht über diesen Endpunkt. -- **Authentik-Provisionierung (LT + Gemeinde Verantwortliche)**: Diese beiden Rollen laufen über die Konfi-Castle-ID (Authentik). Das Backend erwartet, dass der `User` mit passendem `authentikSub` bereits lokal existiert, bevor er sich einloggen kann — ein automatischer Provisionierungs-/Sync-Pfad aus Authentik heraus fehlt noch. (Gemeinde Teamer brauchen das nicht mehr: seit dieser Session sind sie lokale Accounts, siehe `teamer/` + `auth/team-login`.) +- **Authentik-Provisionierung**: Gemeinde Verantwortliche legen ihren lokalen `User` jetzt selbst über `onboarding/` an (JIT aus den Token-Claims, dann `PENDING` bis LT-Freigabe). **LT** dagegen wird von `AuthentikStrategy` noch nicht JIT angelegt — ein LT-`User` muss vor dem ersten Login manuell existieren. (Gemeinde Teamer sind seit dieser Session lokale Accounts, siehe `teamer/` + `auth/team-login`.) - **Teamer-Identität ist E-Mail-basiert und global eindeutig**: `User.email` ist instanzweit unique, d. h. dieselbe E-Mail kann nicht gleichzeitig Teamer in zwei KCs sein. Für den „pro KC wie Guests"-Fall in der Praxis unkritisch, aber dokumentiert. - **Kein E-Mail-Versand**: `teamer-invites` erzeugt Token/Link; das tatsächliche Verschicken der E-Mail-Invites ist noch nicht angebunden. @@ -64,6 +66,7 @@ Vollständiges Schema: [backend/prisma/schema.prisma](backend/prisma/schema.pris | `auth/` | Authentik-Resource-Server-Strategie (`AuthGuard('authentik')`), Guest-Login (`AuthGuard('guest')`), **lokaler Teamer-Login** (`AuthGuard('team')`, `TEAM_JWT_SECRET`, bcrypt) inkl. Invite-Redemption, `TokenVerificationService` für manuelle Verifikation außerhalb des HTTP/Passport-Pfads (WS-Handshake, akzeptiert jetzt Authentik/Team/Guest) | `POST /api/auth/guest`, `POST /api/auth/team-login`, `POST /api/auth/teamer/register` | | `kc/` | KC-Verwaltung, Leitungsteam-only | `POST /api/kc`, `GET /api/kc` | | `gemeinde/` | Gemeinde-CRUD pro KC, Leitungsteam-only | `POST /api/gemeinde`, `GET /api/gemeinde?kcId=`, `GET/PATCH/DELETE /api/gemeinde/:id` | +| `onboarding/` | Selbstregistrierung Gemeinde Verantwortliche/r: öffentlicher Invite-Lookup, JIT-`User`-Anlage aus Authentik-Claims, `Membership` im Status `PENDING`; LT sieht/genehmigt/lehnt ab | `GET /api/onboarding/kc/:inviteCode`, `POST /api/onboarding/verantwortliche` (Authentik-Bearer), `GET /api/onboarding/requests?kcId=` (LT), `POST /api/onboarding/requests/:id/approve\|reject` (LT) | | `teamer/` | Lokale Gemeinde-Teamer-Accounts + Invites; Direkt-Anlage, Gruppen-Link und E-Mail-Invite; nutzbar von LT (jede Gemeinde) oder Verantwortliche/r (nur eigene Gemeinde, in `TeamerService` geprüft) | `POST/GET /api/gemeinde/:gemeindeId/teamer`, `DELETE /api/gemeinde/:gemeindeId/teamer/:userId`, `POST/GET /api/gemeinde/:gemeindeId/teamer-invites`, `DELETE .../teamer-invites/:id` | | `wahl/` | Wahl-/Workshop-Verwaltung, Force-Zuteilung, Teilnehmer-Einreichung, `ZuteilungService` (Portierung von `kc_run_zuteilung`: Force-Zuteilungen → 3 Wunschrunden → Zufallsfüllung → Konsolidierung unterbesetzter Workshops), CSV-Export | `POST/GET /api/wahl`, `POST/GET /api/wahl/:id/workshops`, `POST /api/wahl/:id/force-zuteilung`, `POST /api/wahl/:id/teilnehmer` (Guest), `POST /api/wahl/:id/zuteilung/run`, `GET /api/wahl/:id/zuteilung(.csv)` | | `files/` | Upload (LT-only, multipart) mit Sichtbarkeitsstufe; Liste/Download für Team oder Guest, gefiltert nach erlaubten Sichtbarkeitsstufen; `StorageProvider`-Abstraktion (WebDAV/Nextcloud Default, S3 optional) | `POST /api/files/:kcId`, `GET /api/files/:kcId`, `GET /api/files/download/:fileId` | @@ -107,10 +110,11 @@ Details, Setup-Anleitung und `.env`-Variablen: [backend/README.md](backend/READM 1. Nach jeder Phase: `npx tsc -p tsconfig.build.json --noEmit`, `npx nest build`, sowie ein kurzer Boot-Test (`node dist/main.js`) zur Prüfung, dass der DI-Graph auflöst und alle Routen korrekt gemappt werden (ohne Live-DB/Authentik/Nextcloud). 2. Sync-Modul: manuell verifiziert, dass `SyncSecretGuard` Requests ohne `x-sync-secret` mit 403 ablehnt und mit korrektem Secret durchlässt (DB-Fehler in der Sandbox ist erwartet, da kein Postgres läuft). 3. Web-Client: `GET /` liefert die statische Seite (200), `GET /api/kc` trifft die echte, geschützte API (401 ohne Token). -4. Jest-Unit-Tests (Prisma/Sync gemockt, `npm test` grün, 32 Tests): +4. Jest-Unit-Tests (Prisma/Sync gemockt, `npm test` grün, 46 Tests): - `src/wahl/zuteilung.service.spec.ts`: Force-Vorrang, Wunschrunden-Fallback bei voller Kapazität, Unzugeteilt-Fall, Konsolidierung unterbesetzter Workshops, Sync-Capture-Anzahl. - `src/auth/team-auth.service.spec.ts`: Invite-Redemption (unbekannt/widerrufen/abgelaufen/aufgebraucht, Gruppen-Link ohne E-Mail, E-Mail-Mismatch, Dublette) + Passwort-Login. - `src/teamer/teamer.service.spec.ts`: Gemeinde-Scope-Check (LT global, Verantwortliche/r nur eigene Gemeinde), Direkt-Anlage, Invite-Defaults, Löschung. + - `src/onboarding/onboarding.service.spec.ts`: Invite-Lookup, Verantwortlichen-Selbstregistrierung (Token fehlt/ungültig, unbekannter Code, Gemeinde nicht im KC, JIT-User + `PENDING`, Idempotenz), Approve/Reject. 5. Noch ausstehend (sobald echte Infrastruktur verfügbar ist): Zuteilungslogik gegen bekannte Testdaten aus dem alten Plugin validieren; Ende-zu-Ende-Rollenmatrix (LT/Verantwortlicher/Teamer/Guest) über alle Kernfeatures; echter Sync-Test zwischen zwei laufenden Instanzen (lokal + Cloud). --- @@ -118,7 +122,7 @@ Details, Setup-Anleitung und `.env`-Variablen: [backend/README.md](backend/READM ## 8. Nächste Schritte 1. Sobald Flutter verfügbar ist: Client-Grundgerüst aufsetzen (Mobile + Web + Desktop, eine Codebase), beginnend mit Invite/Login-Flow gegen die bestehende API. -2. Authentik-Provisionierung für LT + Gemeinde Verantwortliche automatisieren (JIT-Anlage des lokalen `User` beim ersten Login aus den Token-Claims, oder Sync aus der Authentik-Admin-API). +2. Authentik-JIT für **LT** vervollständigen: `AuthentikStrategy` legt den lokalen `User` beim ersten Login noch nicht selbst an (nur der `onboarding/`-Pfad für Verantwortliche tut das). LT bleibt bis dahin auf manuelle `User`-Anlage angewiesen. 3. E-Mail-Versand für `teamer-invites` anbinden (Mailer + Templates); aktuell wird nur Token/Link erzeugt. 4. Push-Benachrichtigungen (FCM/APNs) für Chat/Ankündigungen. 5. Echte Infrastruktur (Postgres, Authentik, Nextcloud/S3) aufsetzen, erste Prisma-Migration erzeugen (`prisma migrate dev`, bisher nur `schema.prisma`) und die in Abschnitt 7 offenen Verifikationsschritte durchführen.