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>
This commit is contained in:
2026-09-10 09:24:38 +02:00
co-authored by Claude Sonnet 5
parent a21a9c1cc4
commit 847fed8dad
10 changed files with 37 additions and 29 deletions
+13 -3
View File
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { ServeStaticModule } from '@nestjs/serve-static';
import { existsSync } from 'fs';
import { join } from 'path';
import { PrismaModule } from './prisma/prisma.module';
import { MailModule } from './mail/mail.module';
@@ -14,13 +15,22 @@ import { FilesModule } from './files/files.module';
import { ChatModule } from './chat/chat.module';
import { SyncModule } from './sync/sync.module';
// Prefer the Flutter web build (single entry point at :3000, incl. the OIDC
// redirect path /v1/auth/callback via SPA fallback). Falls back to the plain
// interim client if the Flutter build hasn't been produced yet.
const flutterWeb = join(__dirname, '..', '..', 'client', 'app', 'build', 'web');
const interimWeb = join(__dirname, '..', '..', 'client', 'web');
const webRoot =
process.env.WEB_CLIENT_DIR ?? (existsSync(flutterWeb) ? flutterWeb : interimWeb);
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
// Serves the plain static web client from ../client/web; the REST API
// lives under /api (see main.ts) so it never collides with these routes.
// Static web client; the REST API lives under /api (see main.ts) so it
// never collides. Unmatched non-file paths fall back to index.html so the
// client-side router owns routes like /v1/auth/callback.
ServeStaticModule.forRoot({
rootPath: join(__dirname, '..', '..', 'client', 'web'),
rootPath: webRoot,
exclude: ['/api*'],
}),
PrismaModule,