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 d5ecdcd3c4
commit df11d8492d
20 changed files with 693 additions and 49 deletions
+46 -11
View File
@@ -79,8 +79,10 @@ class _FormShellState extends State<_FormShell> {
return ListView(
shrinkWrap: true,
children: [
Text(widget.title, style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 16),
if (widget.title.isNotEmpty) ...[
Text(widget.title, style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 16),
],
...widget.fields,
const SizedBox(height: 20),
if (_error != null) ...[
@@ -146,21 +148,54 @@ class _TeamFormState extends State<_TeamForm> {
@override
Widget build(BuildContext context) {
final state = AppScope.of(context);
return _FormShell(
title: 'Teamer:in-Login',
fields: [
_field(_email, 'E-Mail'),
return ListView(
shrinkWrap: true,
children: [
Text('Leitungsteam / Verantwortliche',
style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 12),
_field(_password, 'Passwort', obscure: true),
if (state.authError != null) ...[
Text(state.authError!,
style: TextStyle(color: Theme.of(context).colorScheme.error)),
const SizedBox(height: 12),
],
FilledButton.icon(
onPressed: () => state.beginOidcLogin(),
icon: const Icon(Icons.login),
label: const Text('Mit Konfi-Castle-ID anmelden'),
),
const SizedBox(height: 8),
const Text(
'Leitungsteam & Gemeinde-Verantwortliche melden sich über die '
'Konfi-Castle-ID (Authentik) an — dieser Client deckt bisher den '
'lokalen Teamer-Login ab.',
'Öffnet die Konfi-Castle-ID (Authentik). Leitungsteam-Rechte kommen '
'aus deiner Authentik-Gruppe.',
style: TextStyle(fontSize: 12),
),
const Divider(height: 40),
Text('Lokaler Teamer:in-Login',
style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 12),
_TeamPasswordForm(email: _email, password: _password),
],
onSubmit: () => state.teamLogin(_email.text.trim(), _password.text),
);
}
}
class _TeamPasswordForm extends StatelessWidget {
const _TeamPasswordForm({required this.email, required this.password});
final TextEditingController email;
final TextEditingController password;
@override
Widget build(BuildContext context) {
final state = AppScope.of(context);
return _FormShell(
title: '',
fields: [
_field(email, 'E-Mail'),
const SizedBox(height: 12),
_field(password, 'Passwort', obscure: true),
],
onSubmit: () => state.teamLogin(email.text.trim(), password.text),
);
}
}