Files
KC-APP-Server/src/onboarding/onboarding.controller.ts
T
linusandClaude Sonnet 5 847fed8dad feat: Authentik OIDC login (PKCE) + Leitungsteam admin screens
Backend:
- AuthentikStrategy / TokenVerificationService: normalise the issuer's
  trailing slash and accept both `iss` spellings (Authentik's discovery
  issuer and token `iss` carry a trailing slash; the JWKS URL must not
  double it). Wire the real konfi-castle issuer into .env.example.
- team token path now goes through toAuthenticatedUser too, so a local
  account flagged isLeitungsteam gets the synthetic global LT membership
  regardless of token kind.
- LT-admin controllers (kc, gemeinde, onboarding, sync, teamer) accept
  ['authentik','team'] so such an account can use them. RolesGuard still
  enforces the actual LT/role check.
- app.module serves the Flutter web build from client/app/build/web (SPA
  fallback covers the OIDC redirect path /v1/auth/callback), falling back
  to the interim client/web/ if it isn't built.

Client (client/app/):
- oidc.dart: Authorization-Code + PKCE against Authentik (discovery, S256
  challenge, state, token exchange, refresh). Browser bits (sessionStorage,
  redirect, URL) behind a conditional import so `flutter test` still
  compiles on the VM.
- AppState handles the ?code= callback on bootstrap, stores access +
  refresh, refreshes an expired token on restart.
- Login screen: "Mit Konfi-Castle-ID anmelden" button (Leitungsteam /
  Verantwortliche) alongside the local Teamer password form.
- admin_screen.dart: LT-only "Verwaltung" — list/create KCs, per KC the
  Gemeinden (list/create) and pending Verantwortlichen requests
  (approve/reject). Verified end to end against local Postgres with an
  isLeitungsteam account (create KC/Gemeinde, list + approve a request).

flutter analyze/test/build web green; backend npm test 56.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 09:24:38 +02:00

68 lines
2.1 KiB
TypeScript

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', 'team']), RolesGuard)
@Roles(Role.LEITUNGSTEAM)
listRequests(@Query('kcId') kcId: string) {
return this.onboarding.listRequests(kcId);
}
@Post('requests/:membershipId/approve')
@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
@Roles(Role.LEITUNGSTEAM)
approve(@Param('membershipId') membershipId: string) {
return this.onboarding.approve(membershipId);
}
@Post('requests/:membershipId/reject')
@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
@Roles(Role.LEITUNGSTEAM)
reject(@Param('membershipId') membershipId: string) {
return this.onboarding.reject(membershipId);
}
}