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:
+122
-7
@@ -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<String, dynamic> 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<String, dynamic> 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<String, dynamic> j) {
|
||||
final u = j['user'] as Map<String, dynamic>? ?? const {};
|
||||
final g = j['gemeinde'] as Map<String, dynamic>? ?? const {};
|
||||
return OnboardingRequest(
|
||||
id: j['id'] as String,
|
||||
userName: [u['firstName'], u['lastName']].whereType<String>().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<List<Kc>> kcs() async {
|
||||
final list = await _get('/kc') as List<dynamic>;
|
||||
return list.map((e) => Kc.fromJson(e as Map<String, dynamic>)).toList();
|
||||
}
|
||||
|
||||
Future<Kc> createKc(String name) async =>
|
||||
Kc.fromJson(await _post('/kc', {'name': name}) as Map<String, dynamic>);
|
||||
|
||||
Future<List<Gemeinde>> gemeinden(String kcId) async {
|
||||
final list = await _get('/gemeinde?kcId=$kcId') as List<dynamic>;
|
||||
return list.map((e) => Gemeinde.fromJson(e as Map<String, dynamic>)).toList();
|
||||
}
|
||||
|
||||
Future<Gemeinde> createGemeinde(String kcId, String name) async => Gemeinde.fromJson(
|
||||
await _post('/gemeinde', {'kcId': kcId, 'name': name}) as Map<String, dynamic>);
|
||||
|
||||
Future<List<OnboardingRequest>> onboardingRequests(String kcId) async {
|
||||
final list = await _get('/onboarding/requests?kcId=$kcId') as List<dynamic>;
|
||||
return list.map((e) => OnboardingRequest.fromJson(e as Map<String, dynamic>)).toList();
|
||||
}
|
||||
|
||||
Future<void> approveOnboarding(String id) => _post('/onboarding/requests/$id/approve', null);
|
||||
Future<void> rejectOnboarding(String id) => _post('/onboarding/requests/$id/reject', null);
|
||||
|
||||
// --- chat: REST for channels/history; live send/receive is the /chat WS ---
|
||||
Future<List<ChatChannel>> channels(String kcId) async {
|
||||
final list = await _get('/chat/$kcId/channels') as List<dynamic>;
|
||||
@@ -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<void> 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<void> _establish(String token) async {
|
||||
Future<void> beginOidcLogin() => _oidc.beginLogin();
|
||||
|
||||
Future<void> _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<void> _clear(SharedPreferences prefs) async {
|
||||
_api.token = null;
|
||||
await prefs.remove(_tokenKey);
|
||||
await prefs.remove(_refreshKey);
|
||||
}
|
||||
|
||||
Future<void> 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<void> logout() async {
|
||||
_api.token = null;
|
||||
_identity = null;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_tokenKey);
|
||||
_authError = null;
|
||||
await _clear(await SharedPreferences.getInstance());
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user