From df11d8492df1b7b5c5edee707830720b24bf2349 Mon Sep 17 00:00:00 2001 From: linus Date: Thu, 10 Sep 2026 09:24:38 +0200 Subject: [PATCH] feat: Authentik OIDC login (PKCE) + Leitungsteam admin screens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/.env.example | 4 +- backend/src/app.module.ts | 16 +- backend/src/auth/authentik.strategy.ts | 8 +- backend/src/auth/team-auth.service.ts | 14 +- .../src/auth/token-verification.service.ts | 5 +- backend/src/gemeinde/gemeinde.controller.ts | 2 +- backend/src/kc/kc.controller.ts | 2 +- .../src/onboarding/onboarding.controller.ts | 6 +- backend/src/sync/sync.controller.ts | 2 +- backend/src/teamer/teamer.controller.ts | 7 +- client/app/lib/api.dart | 129 +++++++- client/app/lib/browser.dart | 3 + client/app/lib/browser_stub.dart | 9 + client/app/lib/browser_web.dart | 22 ++ client/app/lib/oidc.dart | 151 +++++++++ client/app/lib/screens/admin_screen.dart | 291 ++++++++++++++++++ client/app/lib/screens/home_screen.dart | 8 + client/app/lib/screens/login_screen.dart | 57 +++- client/app/pubspec.lock | 4 +- client/app/pubspec.yaml | 2 + 20 files changed, 693 insertions(+), 49 deletions(-) create mode 100644 client/app/lib/browser.dart create mode 100644 client/app/lib/browser_stub.dart create mode 100644 client/app/lib/browser_web.dart create mode 100644 client/app/lib/oidc.dart create mode 100644 client/app/lib/screens/admin_screen.dart diff --git a/backend/.env.example b/backend/.env.example index de0a625..808ad73 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,8 +1,8 @@ # Postgres connection used by Prisma DATABASE_URL="postgresql://postgres:postgres@localhost:5432/kcapp?schema=public" -# Authentik OIDC issuer, e.g. https://auth.example.org/application/o/kc-app/ -AUTHENTIK_ISSUER_URL="https://authentik.example.org/application/o/kc-app" +# Authentik OIDC issuer (trailing slash optional — both forms are accepted). +AUTHENTIK_ISSUER_URL="https://sso.konfi-castle.com/application/o/konfi-castle-app" # Name of the Authentik group whose members are Leitungsteam. Mirrored to # User.isLeitungsteam on every login (the access token must carry a `groups` diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 2cf76fb..ffaeec6 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -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, diff --git a/backend/src/auth/authentik.strategy.ts b/backend/src/auth/authentik.strategy.ts index 9fca443..474e6e4 100644 --- a/backend/src/auth/authentik.strategy.ts +++ b/backend/src/auth/authentik.strategy.ts @@ -32,18 +32,20 @@ export class AuthentikStrategy extends PassportStrategy(Strategy, 'authentik') { private readonly prisma: PrismaClient, private readonly sync: SyncService, ) { - const issuerUrl = config.getOrThrow('AUTHENTIK_ISSUER_URL'); + // Authentik's discovery `issuer` carries a trailing slash and so does the + // `iss` claim in its tokens; accept both spellings and never emit `//`. + const base = config.getOrThrow('AUTHENTIK_ISSUER_URL').replace(/\/+$/, ''); super({ jwtFromRequest: (req: Request) => req.headers.authorization?.startsWith('Bearer ') ? req.headers.authorization.slice('Bearer '.length) : null, secretOrKeyProvider: jwksRsa.passportJwtSecret({ - jwksUri: `${issuerUrl}/jwks/`, + jwksUri: `${base}/jwks/`, cache: true, rateLimit: true, }), - issuer: issuerUrl, + issuer: [base, `${base}/`], algorithms: ['RS256'], }); this.leitungsteamGroup = config.get('AUTHENTIK_LEITUNGSTEAM_GROUP', 'Leitungsteam'); diff --git a/backend/src/auth/team-auth.service.ts b/backend/src/auth/team-auth.service.ts index 048333e..935f781 100644 --- a/backend/src/auth/team-auth.service.ts +++ b/backend/src/auth/team-auth.service.ts @@ -12,6 +12,7 @@ import * as jwt from 'jsonwebtoken'; import { PrismaClient } from '../prisma/prisma.module'; import { SyncService } from '../sync/sync.service'; import { AuthenticatedUser } from './authenticated-request'; +import { toAuthenticatedUser } from './provision-user'; export interface TeamJwtPayload { sub: string; @@ -146,15 +147,8 @@ export class TeamAuthService { if (!user) { throw new UnauthorizedException('Team account no longer exists'); } - return { - userId: user.id, - authentikSub: user.authentikSub, - email: user.email, - memberships: user.memberships.map((m) => ({ - kcId: m.kcId, - gemeindeId: m.gemeindeId, - role: m.role, - })), - }; + // Same shape as the Authentik path, incl. the synthetic global + // LEITUNGSTEAM membership when `isLeitungsteam` is set on the row. + return toAuthenticatedUser(user); } } diff --git a/backend/src/auth/token-verification.service.ts b/backend/src/auth/token-verification.service.ts index f3caa60..3d48559 100644 --- a/backend/src/auth/token-verification.service.ts +++ b/backend/src/auth/token-verification.service.ts @@ -29,7 +29,8 @@ export class TokenVerificationService { private readonly teamAuth: TeamAuthService, private readonly sync: SyncService, ) { - this.issuerUrl = config.getOrThrow('AUTHENTIK_ISSUER_URL'); + // See AuthentikStrategy: normalise the trailing slash, accept both forms. + this.issuerUrl = config.getOrThrow('AUTHENTIK_ISSUER_URL').replace(/\/+$/, ''); this.jwks = jwksRsa({ jwksUri: `${this.issuerUrl}/jwks/`, cache: true, rateLimit: true }); this.leitungsteamGroup = config.get('AUTHENTIK_LEITUNGSTEAM_GROUP', 'Leitungsteam'); } @@ -48,7 +49,7 @@ export class TokenVerificationService { } const key = await this.jwks.getSigningKey(kid); const payload = jwt.verify(token, key.getPublicKey(), { - issuer: this.issuerUrl, + issuer: [this.issuerUrl, `${this.issuerUrl}/`], algorithms: ['RS256'], }) as jwt.JwtPayload & { email?: string; diff --git a/backend/src/gemeinde/gemeinde.controller.ts b/backend/src/gemeinde/gemeinde.controller.ts index 5f404ef..9b6c06a 100644 --- a/backend/src/gemeinde/gemeinde.controller.ts +++ b/backend/src/gemeinde/gemeinde.controller.ts @@ -21,7 +21,7 @@ import { Role } from '../common/role.enum'; /// global across all KCs (see RolesGuard); Gemeinde Verantwortliche/Teamer /// learn their own Gemeinde from their Membership, not from this endpoint. @Controller('gemeinde') -@UseGuards(AuthGuard('authentik'), RolesGuard) +@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard) @Roles(Role.LEITUNGSTEAM) export class GemeindeController { constructor(private readonly gemeinde: GemeindeService) {} diff --git a/backend/src/kc/kc.controller.ts b/backend/src/kc/kc.controller.ts index b520137..896cb02 100644 --- a/backend/src/kc/kc.controller.ts +++ b/backend/src/kc/kc.controller.ts @@ -7,7 +7,7 @@ import { RolesGuard } from '../common/roles.guard'; import { Role } from '../common/role.enum'; @Controller('kc') -@UseGuards(AuthGuard('authentik'), RolesGuard) +@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard) export class KcController { constructor(private readonly kc: KcService) {} diff --git a/backend/src/onboarding/onboarding.controller.ts b/backend/src/onboarding/onboarding.controller.ts index 4fb1861..dabbffe 100644 --- a/backend/src/onboarding/onboarding.controller.ts +++ b/backend/src/onboarding/onboarding.controller.ts @@ -45,21 +45,21 @@ export class OnboardingController { /// Leitungsteam: review and act on pending self-registrations. @Get('requests') - @UseGuards(AuthGuard('authentik'), RolesGuard) + @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'), RolesGuard) + @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'), RolesGuard) + @UseGuards(AuthGuard(['authentik', 'team']), RolesGuard) @Roles(Role.LEITUNGSTEAM) reject(@Param('membershipId') membershipId: string) { return this.onboarding.reject(membershipId); diff --git a/backend/src/sync/sync.controller.ts b/backend/src/sync/sync.controller.ts index 4a06dfc..c84107f 100644 --- a/backend/src/sync/sync.controller.ts +++ b/backend/src/sync/sync.controller.ts @@ -33,7 +33,7 @@ export class SyncController { /// Manual on-demand push+pull against the configured peer (Leitungsteam-only). @Post('trigger') - @UseGuards(AuthGuard('authentik'), RolesGuard) + @UseGuards(AuthGuard(['authentik', 'team']), RolesGuard) @Roles(Role.LEITUNGSTEAM) async trigger() { const peerUrl = this.config.getOrThrow('SYNC_PEER_URL'); diff --git a/backend/src/teamer/teamer.controller.ts b/backend/src/teamer/teamer.controller.ts index ba11723..ddc3cde 100644 --- a/backend/src/teamer/teamer.controller.ts +++ b/backend/src/teamer/teamer.controller.ts @@ -18,10 +18,11 @@ import { Role } from '../common/role.enum'; import { AuthenticatedRequest } from '../auth/authenticated-request'; /// Gemeinde Teamer administration. The coarse guard admits Leitungsteam and -/// Gemeinde Verantwortliche (both Authentik-backed); TeamerService then -/// checks the caller is actually responsible for `:gemeindeId`. +/// Gemeinde Verantwortliche (Authentik tokens, or a local `isLeitungsteam` +/// account via a team token); TeamerService then checks the caller is +/// actually responsible for `:gemeindeId`. @Controller('gemeinde/:gemeindeId') -@UseGuards(AuthGuard('authentik'), RolesGuard) +@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard) @Roles(Role.LEITUNGSTEAM, Role.GEMEINDE_VERANTWORTLICHER) export class TeamerController { constructor(private readonly teamer: TeamerService) {} diff --git a/client/app/lib/api.dart b/client/app/lib/api.dart index 0d04bcf..c9902f8 100644 --- a/client/app/lib/api.dart +++ b/client/app/lib/api.dart @@ -4,6 +4,8 @@ import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'package:shared_preferences/shared_preferences.dart'; +import 'oidc.dart'; + /// Backend base URL. Override at build/run time with /// `--dart-define=API_BASE=https://...`. const String kApiBase = String.fromEnvironment( @@ -91,6 +93,55 @@ class Membership { ); } +class Kc { + Kc({required this.id, required this.name, required this.inviteCode, required this.isActive}); + final String id; + final String name; + final String inviteCode; + final bool isActive; + factory Kc.fromJson(Map j) => Kc( + id: j['id'] as String, + name: j['name'] as String, + inviteCode: j['inviteCode'] as String? ?? '', + isActive: j['isActive'] as bool? ?? true, + ); +} + +class Gemeinde { + Gemeinde({required this.id, required this.name, required this.kcId}); + final String id; + final String name; + final String kcId; + factory Gemeinde.fromJson(Map j) => Gemeinde( + id: j['id'] as String, + name: j['name'] as String, + kcId: j['kcId'] as String? ?? '', + ); +} + +class OnboardingRequest { + OnboardingRequest({ + required this.id, + required this.userName, + required this.userEmail, + required this.gemeindeName, + }); + final String id; + final String userName; + final String userEmail; + final String gemeindeName; + factory OnboardingRequest.fromJson(Map j) { + final u = j['user'] as Map? ?? const {}; + final g = j['gemeinde'] as Map? ?? const {}; + return OnboardingRequest( + id: j['id'] as String, + userName: [u['firstName'], u['lastName']].whereType().join(' ').trim(), + userEmail: u['email'] as String? ?? '', + gemeindeName: g['name'] as String? ?? '', + ); + } +} + class Workshop { Workshop({required this.id, required this.name, required this.kapazitaet}); final String id; @@ -326,6 +377,31 @@ class Api { ); } + // --- LT admin --- + Future> kcs() async { + final list = await _get('/kc') as List; + return list.map((e) => Kc.fromJson(e as Map)).toList(); + } + + Future createKc(String name) async => + Kc.fromJson(await _post('/kc', {'name': name}) as Map); + + Future> gemeinden(String kcId) async { + final list = await _get('/gemeinde?kcId=$kcId') as List; + return list.map((e) => Gemeinde.fromJson(e as Map)).toList(); + } + + Future createGemeinde(String kcId, String name) async => Gemeinde.fromJson( + await _post('/gemeinde', {'kcId': kcId, 'name': name}) as Map); + + Future> onboardingRequests(String kcId) async { + final list = await _get('/onboarding/requests?kcId=$kcId') as List; + return list.map((e) => OnboardingRequest.fromJson(e as Map)).toList(); + } + + Future approveOnboarding(String id) => _post('/onboarding/requests/$id/approve', null); + Future rejectOnboarding(String id) => _post('/onboarding/requests/$id/reject', null); + // --- chat: REST for channels/history; live send/receive is the /chat WS --- Future> channels(String kcId) async { final list = await _get('/chat/$kcId/channels') as List; @@ -341,43 +417,83 @@ class Api { /// App-wide session + auth actions. Persists the token in shared_preferences /// (localStorage on web). class AppState extends ChangeNotifier { - AppState(this._api); + AppState(this._api, {OidcClient? oidc}) : _oidc = oidc ?? OidcClient(http.Client()); final Api _api; + final OidcClient _oidc; static const _tokenKey = 'kc_token'; + static const _refreshKey = 'kc_refresh'; Identity? _identity; Identity? get identity => _identity; bool _loading = true; bool get loading => _loading; bool get isLoggedIn => _identity != null; + String? _authError; + String? get authError => _authError; Api get api => _api; Future bootstrap() async { final prefs = await SharedPreferences.getInstance(); + + // 1. Are we landing on the OIDC redirect (?code=…)? + try { + final tokens = await _oidc.completeIfCallback(); + if (tokens != null) { + await _establish(tokens.accessToken, refreshToken: tokens.refreshToken); + _loading = false; + notifyListeners(); + return; + } + } catch (e) { + _authError = '$e'; + } + + // 2. Restore a stored session, refreshing an expired Authentik token. final saved = prefs.getString(_tokenKey); if (saved != null) { _api.token = saved; try { _identity = await _api.me(); } catch (_) { - _api.token = null; - await prefs.remove(_tokenKey); + final refresh = prefs.getString(_refreshKey); + if (refresh != null) { + try { + final t = await _oidc.refresh(refresh); + await _establish(t.accessToken, refreshToken: t.refreshToken ?? refresh); + } catch (_) { + await _clear(prefs); + } + } else { + await _clear(prefs); + } } } _loading = false; notifyListeners(); } - Future _establish(String token) async { + Future beginOidcLogin() => _oidc.beginLogin(); + + Future _establish(String token, {String? refreshToken}) async { _api.token = token; _identity = await _api.me(); final prefs = await SharedPreferences.getInstance(); await prefs.setString(_tokenKey, token); + if (refreshToken != null) { + await prefs.setString(_refreshKey, refreshToken); + } + _authError = null; notifyListeners(); } + Future _clear(SharedPreferences prefs) async { + _api.token = null; + await prefs.remove(_tokenKey); + await prefs.remove(_refreshKey); + } + Future guestLogin(String code, String first, String last) => _api.guestLogin(code, first, last).then(_establish); @@ -402,10 +518,9 @@ class AppState extends ChangeNotifier { .then(_establish); Future logout() async { - _api.token = null; _identity = null; - final prefs = await SharedPreferences.getInstance(); - await prefs.remove(_tokenKey); + _authError = null; + await _clear(await SharedPreferences.getInstance()); notifyListeners(); } } diff --git a/client/app/lib/browser.dart b/client/app/lib/browser.dart new file mode 100644 index 0000000..d243a95 --- /dev/null +++ b/client/app/lib/browser.dart @@ -0,0 +1,3 @@ +// Picks the real browser implementation on web, a throwing stub elsewhere +// (so `flutter test` on the Dart VM still compiles). +export 'browser_stub.dart' if (dart.library.js_interop) 'browser_web.dart'; diff --git a/client/app/lib/browser_stub.dart b/client/app/lib/browser_stub.dart new file mode 100644 index 0000000..3e02c0f --- /dev/null +++ b/client/app/lib/browser_stub.dart @@ -0,0 +1,9 @@ +// Non-web fallback: the OIDC redirect flow only runs in a browser. +const _msg = 'Browser-only: OIDC login is not available on this platform.'; + +void setSession(String key, String value) => throw UnsupportedError(_msg); +String? getSession(String key) => throw UnsupportedError(_msg); +void removeSession(String key) => throw UnsupportedError(_msg); +Never redirect(String url) => throw UnsupportedError(_msg); +Map currentQueryParameters() => const {}; +void clearQuery() {} diff --git a/client/app/lib/browser_web.dart b/client/app/lib/browser_web.dart new file mode 100644 index 0000000..d68ac12 --- /dev/null +++ b/client/app/lib/browser_web.dart @@ -0,0 +1,22 @@ +import 'package:web/web.dart' as web; + +/// Per-tab storage for the PKCE verifier + CSRF state (cleared when the tab +/// closes), and the little bit of `window` access the OIDC redirect needs. + +void setSession(String key, String value) => + web.window.sessionStorage.setItem(key, value); + +String? getSession(String key) => web.window.sessionStorage.getItem(key); + +void removeSession(String key) => web.window.sessionStorage.removeItem(key); + +void redirect(String url) => web.window.location.assign(url); + +Map currentQueryParameters() => + Uri.parse(web.window.location.href).queryParameters; + +/// Drop the OIDC callback path + `?code=…&state=…` from the address bar +/// without reloading (back to the app root). +void clearQuery() { + web.window.history.replaceState(null, '', '/'); +} diff --git a/client/app/lib/oidc.dart b/client/app/lib/oidc.dart new file mode 100644 index 0000000..d183821 --- /dev/null +++ b/client/app/lib/oidc.dart @@ -0,0 +1,151 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:crypto/crypto.dart'; +import 'package:http/http.dart' as http; + +import 'browser.dart' as browser; + +/// Authentik OIDC config. Overridable with --dart-define; defaults are the +/// konfi-castle production values (a *public* client — PKCE, no secret). +const kOidcIssuer = String.fromEnvironment( + 'OIDC_ISSUER', + defaultValue: 'https://sso.konfi-castle.com/application/o/konfi-castle-app/', +); +const kOidcClientId = String.fromEnvironment( + 'OIDC_CLIENT_ID', + defaultValue: 'K7f9mn6bP6jSjZDMYuiZCXMeVmVcqFcYNj0blJk9', +); +const kOidcRedirectUri = String.fromEnvironment( + 'OIDC_REDIRECT_URI', + defaultValue: 'http://localhost:3000/v1/auth/callback', +); + +const _scope = 'openid profile email groups offline_access'; +const _verifierKey = 'oidc_verifier'; +const _stateKey = 'oidc_state'; + +class OidcTokens { + OidcTokens({required this.accessToken, this.refreshToken, this.expiresIn}); + final String accessToken; + final String? refreshToken; + final int? expiresIn; + + factory OidcTokens.fromJson(Map j) => OidcTokens( + accessToken: j['access_token'] as String, + refreshToken: j['refresh_token'] as String?, + expiresIn: (j['expires_in'] as num?)?.toInt(), + ); +} + +class OidcException implements Exception { + OidcException(this.message); + final String message; + @override + String toString() => 'OidcException: $message'; +} + +/// Authorization-Code + PKCE against Authentik, for the browser. The backend +/// only validates the resulting access token (resource-server pattern). +class OidcClient { + OidcClient(this._http); + final http.Client _http; + Map? _discovery; + + Future> _disc() async { + if (_discovery != null) return _discovery!; + final base = kOidcIssuer.endsWith('/') ? kOidcIssuer : '$kOidcIssuer/'; + final res = await _http.get(Uri.parse('$base.well-known/openid-configuration')); + if (res.statusCode != 200) { + throw OidcException('Discovery failed (${res.statusCode})'); + } + return _discovery = jsonDecode(res.body) as Map; + } + + /// Kicks off the redirect to Authentik. Does not return (page navigates). + Future beginLogin() async { + final d = await _disc(); + final verifier = _randomUrlToken(64); + final state = _randomUrlToken(24); + final challenge = base64UrlEncode(sha256.convert(ascii.encode(verifier)).bytes) + .replaceAll('=', ''); + browser.setSession(_verifierKey, verifier); + browser.setSession(_stateKey, state); + + final authUri = Uri.parse(d['authorization_endpoint'] as String).replace( + queryParameters: { + 'response_type': 'code', + 'client_id': kOidcClientId, + 'redirect_uri': kOidcRedirectUri, + 'scope': _scope, + 'state': state, + 'code_challenge': challenge, + 'code_challenge_method': 'S256', + }, + ); + browser.redirect(authUri.toString()); + } + + /// If the current URL carries `?code=…`, exchanges it for tokens and scrubs + /// the query. Returns null when this isn't a callback load. + Future completeIfCallback() async { + final params = browser.currentQueryParameters(); + final code = params['code']; + if (code == null || code.isEmpty) { + if (params['error'] != null) { + browser.clearQuery(); + throw OidcException('Authentik: ${params['error_description'] ?? params['error']}'); + } + return null; + } + final expectedState = browser.getSession(_stateKey); + final verifier = browser.getSession(_verifierKey); + browser.removeSession(_stateKey); + browser.removeSession(_verifierKey); + browser.clearQuery(); + + if (verifier == null || params['state'] != expectedState) { + throw OidcException('State mismatch — please retry the login.'); + } + final d = await _disc(); + final res = await _http.post( + Uri.parse(d['token_endpoint'] as String), + headers: {'Content-Type': 'application/x-www-form-urlencoded'}, + body: { + 'grant_type': 'authorization_code', + 'code': code, + 'redirect_uri': kOidcRedirectUri, + 'client_id': kOidcClientId, + 'code_verifier': verifier, + }, + ); + if (res.statusCode != 200) { + throw OidcException('Token exchange failed (${res.statusCode}): ${res.body}'); + } + return OidcTokens.fromJson(jsonDecode(res.body) as Map); + } + + Future refresh(String refreshToken) async { + final d = await _disc(); + final res = await _http.post( + Uri.parse(d['token_endpoint'] as String), + headers: {'Content-Type': 'application/x-www-form-urlencoded'}, + body: { + 'grant_type': 'refresh_token', + 'refresh_token': refreshToken, + 'client_id': kOidcClientId, + }, + ); + if (res.statusCode != 200) { + throw OidcException('Refresh failed (${res.statusCode})'); + } + return OidcTokens.fromJson(jsonDecode(res.body) as Map); + } + + static String _randomUrlToken(int length) { + const chars = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~'; + final rnd = Random.secure(); + return List.generate(length, (_) => chars[rnd.nextInt(chars.length)]).join(); + } +} diff --git a/client/app/lib/screens/admin_screen.dart b/client/app/lib/screens/admin_screen.dart new file mode 100644 index 0000000..82f5e16 --- /dev/null +++ b/client/app/lib/screens/admin_screen.dart @@ -0,0 +1,291 @@ +import 'package:flutter/material.dart'; + +import '../api.dart'; +import '../main.dart'; + +/// Leitungsteam admin: KCs, their Gemeinden, and pending self-registrations. +class AdminScreen extends StatefulWidget { + const AdminScreen({super.key}); + + @override + State createState() => _AdminScreenState(); +} + +class _AdminScreenState extends State { + Future>? _future; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _future ??= AppScope.of(context).api.kcs(); + } + + void _reload() => setState(() => _future = AppScope.of(context).api.kcs()); + + Future _createKc() async { + final api = AppScope.of(context).api; + final name = await _promptText(context, 'Neues KC', 'Name'); + if (name == null || name.isEmpty || !mounted) return; + try { + await api.createKc(name); + if (mounted) _reload(); + } catch (e) { + if (mounted) _toast(context, '$e'); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Verwaltung')), + floatingActionButton: FloatingActionButton.extended( + onPressed: _createKc, + icon: const Icon(Icons.add), + label: const Text('KC'), + ), + body: FutureBuilder>( + future: _future, + builder: (context, snap) { + if (snap.connectionState != ConnectionState.done) { + return const Center(child: CircularProgressIndicator()); + } + if (snap.hasError) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text('${snap.error}', textAlign: TextAlign.center), + ), + ); + } + final kcs = snap.data!; + if (kcs.isEmpty) { + return const Center(child: Text('Noch keine KCs. Unten anlegen.')); + } + return ListView( + children: [ + for (final kc in kcs) + ListTile( + leading: const Icon(Icons.festival), + title: Text(kc.name), + subtitle: Text('Code ${kc.inviteCode}'), + trailing: const Icon(Icons.chevron_right), + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => KcDetailScreen(kc: kc)), + ), + ), + ], + ); + }, + ), + ); + } +} + +class KcDetailScreen extends StatefulWidget { + const KcDetailScreen({super.key, required this.kc}); + final Kc kc; + + @override + State createState() => _KcDetailScreenState(); +} + +class _KcDetailScreenState extends State { + Future>? _gemeinden; + Future>? _requests; + + Api get _api => AppScope.of(context).api; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _gemeinden ??= _api.gemeinden(widget.kc.id); + _requests ??= _api.onboardingRequests(widget.kc.id); + } + + void _reloadGemeinden() => + setState(() => _gemeinden = _api.gemeinden(widget.kc.id)); + void _reloadRequests() => + setState(() => _requests = _api.onboardingRequests(widget.kc.id)); + + Future _addGemeinde() async { + final api = _api; + final name = await _promptText(context, 'Neue Gemeinde', 'Name'); + if (name == null || name.isEmpty || !mounted) return; + try { + await api.createGemeinde(widget.kc.id, name); + if (mounted) _reloadGemeinden(); + } catch (e) { + if (mounted) _toast(context, '$e'); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text(widget.kc.name)), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + Card( + child: ListTile( + title: const Text('Einladungscode'), + subtitle: Text(widget.kc.inviteCode), + trailing: const Icon(Icons.qr_code_2), + ), + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: Text('Gemeinden', + style: Theme.of(context).textTheme.titleMedium), + ), + TextButton.icon( + onPressed: _addGemeinde, + icon: const Icon(Icons.add), + label: const Text('Hinzufügen'), + ), + ], + ), + _GemeindeList(future: _gemeinden!, onRetry: _reloadGemeinden), + const Divider(height: 40), + Text('Offene Verantwortlichen-Anfragen', + style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + _RequestList( + future: _requests!, + onAction: (id, approve) async { + try { + approve + ? await _api.approveOnboarding(id) + : await _api.rejectOnboarding(id); + _reloadRequests(); + } catch (e) { + if (context.mounted) _toast(context, '$e'); + } + }, + ), + ], + ), + ); + } +} + +class _GemeindeList extends StatelessWidget { + const _GemeindeList({required this.future, required this.onRetry}); + final Future> future; + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + return FutureBuilder>( + future: future, + builder: (context, snap) { + if (snap.connectionState != ConnectionState.done) { + return const Padding( + padding: EdgeInsets.all(12), + child: Center(child: CircularProgressIndicator()), + ); + } + if (snap.hasError) { + return TextButton(onPressed: onRetry, child: Text('Fehler: ${snap.error}')); + } + final gemeinden = snap.data!; + if (gemeinden.isEmpty) return const Text('Noch keine Gemeinden.'); + return Column( + children: [ + for (final g in gemeinden) + ListTile( + dense: true, + leading: const Icon(Icons.church), + title: Text(g.name), + ), + ], + ); + }, + ); + } +} + +class _RequestList extends StatelessWidget { + const _RequestList({required this.future, required this.onAction}); + final Future> future; + final Future Function(String id, bool approve) onAction; + + @override + Widget build(BuildContext context) { + return FutureBuilder>( + future: future, + builder: (context, snap) { + if (snap.connectionState != ConnectionState.done) { + return const Padding( + padding: EdgeInsets.all(12), + child: Center(child: CircularProgressIndicator()), + ); + } + if (snap.hasError) { + return Text('Fehler: ${snap.error}'); + } + final requests = snap.data!; + if (requests.isEmpty) return const Text('Keine offenen Anfragen.'); + return Column( + children: [ + for (final r in requests) + Card( + child: ListTile( + title: Text(r.userName.isEmpty ? r.userEmail : r.userName), + subtitle: Text('${r.userEmail}\nGemeinde: ${r.gemeindeName}'), + isThreeLine: true, + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + tooltip: 'Genehmigen', + icon: const Icon(Icons.check, color: Colors.green), + onPressed: () => onAction(r.id, true), + ), + IconButton( + tooltip: 'Ablehnen', + icon: const Icon(Icons.close, color: Colors.red), + onPressed: () => onAction(r.id, false), + ), + ], + ), + ), + ), + ], + ); + }, + ); + } +} + +Future _promptText(BuildContext context, String title, String label) { + final controller = TextEditingController(); + return showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(title), + content: TextField( + controller: controller, + autofocus: true, + decoration: InputDecoration(labelText: label), + onSubmitted: (v) => Navigator.of(context).pop(v.trim()), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Abbrechen'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(controller.text.trim()), + child: const Text('OK'), + ), + ], + ), + ); +} + +void _toast(BuildContext context, String message) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message))); +} diff --git a/client/app/lib/screens/home_screen.dart b/client/app/lib/screens/home_screen.dart index acd9a8c..5244f72 100644 --- a/client/app/lib/screens/home_screen.dart +++ b/client/app/lib/screens/home_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import '../api.dart'; import '../main.dart'; +import 'admin_screen.dart'; import 'chat_screen.dart'; import 'files_screen.dart'; import 'wahl_screen.dart'; @@ -16,6 +17,13 @@ class HomeScreen extends StatelessWidget { final kcId = id.kcId; final tiles = [ + if (id.isLeitungsteam) + _NavTile( + icon: Icons.admin_panel_settings, + title: 'Verwaltung', + subtitle: 'KCs, Gemeinden, Onboarding-Freigaben', + onTap: () => _open(context, const AdminScreen()), + ), if (id.kind == SessionKind.guest) _NavTile( icon: Icons.how_to_vote, diff --git a/client/app/lib/screens/login_screen.dart b/client/app/lib/screens/login_screen.dart index ab94dd3..803c652 100644 --- a/client/app/lib/screens/login_screen.dart +++ b/client/app/lib/screens/login_screen.dart @@ -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), ); } } diff --git a/client/app/pubspec.lock b/client/app/pubspec.lock index 3fe5047..bcddee5 100644 --- a/client/app/pubspec.lock +++ b/client/app/pubspec.lock @@ -42,7 +42,7 @@ packages: source: hosted version: "1.19.1" crypto: - dependency: transitive + dependency: "direct main" description: name: crypto sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf @@ -350,7 +350,7 @@ packages: source: hosted version: "15.3.0" web: - dependency: transitive + dependency: "direct main" description: name: web sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" diff --git a/client/app/pubspec.yaml b/client/app/pubspec.yaml index 01e5ac6..fc9abf1 100644 --- a/client/app/pubspec.yaml +++ b/client/app/pubspec.yaml @@ -12,6 +12,8 @@ dependencies: http: ^1.2.2 shared_preferences: ^2.3.2 web_socket_channel: ^3.0.1 + crypto: ^3.0.6 + web: ^1.1.0 dev_dependencies: flutter_test: