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>
242 lines
6.8 KiB
Dart
242 lines
6.8 KiB
Dart
import 'package:flutter/material.dart';
|
|
|
|
import '../api.dart';
|
|
import '../main.dart';
|
|
|
|
class LoginScreen extends StatelessWidget {
|
|
const LoginScreen({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return DefaultTabController(
|
|
length: 3,
|
|
child: Scaffold(
|
|
appBar: AppBar(
|
|
title: const Text('KC-App'),
|
|
bottom: const TabBar(
|
|
tabs: [
|
|
Tab(text: 'Konfi / Gast'),
|
|
Tab(text: 'Team-Login'),
|
|
Tab(text: 'Einladung'),
|
|
],
|
|
),
|
|
),
|
|
body: SafeArea(
|
|
child: Center(
|
|
child: ConstrainedBox(
|
|
constraints: const BoxConstraints(maxWidth: 420),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(24),
|
|
child: TabBarView(
|
|
children: const [
|
|
_GuestForm(),
|
|
_TeamForm(),
|
|
_InviteForm(),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Shared submit-button + error handling for the three little forms.
|
|
class _FormShell extends StatefulWidget {
|
|
const _FormShell({required this.title, required this.fields, required this.onSubmit});
|
|
final String title;
|
|
final List<Widget> fields;
|
|
final Future<void> Function() onSubmit;
|
|
|
|
@override
|
|
State<_FormShell> createState() => _FormShellState();
|
|
}
|
|
|
|
class _FormShellState extends State<_FormShell> {
|
|
bool _busy = false;
|
|
String? _error;
|
|
|
|
Future<void> _run() async {
|
|
setState(() {
|
|
_busy = true;
|
|
_error = null;
|
|
});
|
|
try {
|
|
await widget.onSubmit();
|
|
} on ApiException catch (e) {
|
|
setState(() => _error = e.message);
|
|
} catch (e) {
|
|
setState(() => _error = e.toString());
|
|
} finally {
|
|
if (mounted) setState(() => _busy = false);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return ListView(
|
|
shrinkWrap: true,
|
|
children: [
|
|
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) ...[
|
|
Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
|
|
const SizedBox(height: 12),
|
|
],
|
|
FilledButton(
|
|
onPressed: _busy ? null : _run,
|
|
child: _busy
|
|
? const SizedBox(
|
|
height: 18, width: 18, child: CircularProgressIndicator(strokeWidth: 2))
|
|
: const Text('Weiter'),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
TextField _field(TextEditingController c, String label, {bool obscure = false}) => TextField(
|
|
controller: c,
|
|
obscureText: obscure,
|
|
decoration: InputDecoration(labelText: label, border: const OutlineInputBorder()),
|
|
);
|
|
|
|
class _GuestForm extends StatefulWidget {
|
|
const _GuestForm();
|
|
@override
|
|
State<_GuestForm> createState() => _GuestFormState();
|
|
}
|
|
|
|
class _GuestFormState extends State<_GuestForm> {
|
|
final _code = TextEditingController();
|
|
final _first = TextEditingController();
|
|
final _last = TextEditingController();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final state = AppScope.of(context);
|
|
return _FormShell(
|
|
title: 'Mit Einladungscode beitreten',
|
|
fields: [
|
|
_field(_code, 'Einladungscode'),
|
|
const SizedBox(height: 12),
|
|
_field(_first, 'Vorname'),
|
|
const SizedBox(height: 12),
|
|
_field(_last, 'Nachname'),
|
|
],
|
|
onSubmit: () => state.guestLogin(_code.text.trim(), _first.text.trim(), _last.text.trim()),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _TeamForm extends StatefulWidget {
|
|
const _TeamForm();
|
|
@override
|
|
State<_TeamForm> createState() => _TeamFormState();
|
|
}
|
|
|
|
class _TeamFormState extends State<_TeamForm> {
|
|
final _email = TextEditingController();
|
|
final _password = TextEditingController();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final state = AppScope.of(context);
|
|
return ListView(
|
|
shrinkWrap: true,
|
|
children: [
|
|
Text('Leitungsteam / Verantwortliche',
|
|
style: Theme.of(context).textTheme.titleLarge),
|
|
const SizedBox(height: 12),
|
|
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(
|
|
'Ö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),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
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),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _InviteForm extends StatefulWidget {
|
|
const _InviteForm();
|
|
@override
|
|
State<_InviteForm> createState() => _InviteFormState();
|
|
}
|
|
|
|
class _InviteFormState extends State<_InviteForm> {
|
|
final _token = TextEditingController();
|
|
final _first = TextEditingController();
|
|
final _last = TextEditingController();
|
|
final _email = TextEditingController();
|
|
final _password = TextEditingController();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final state = AppScope.of(context);
|
|
return _FormShell(
|
|
title: 'Teamer:in-Einladung einlösen',
|
|
fields: [
|
|
_field(_token, 'Einladungscode / Token'),
|
|
const SizedBox(height: 12),
|
|
_field(_first, 'Vorname'),
|
|
const SizedBox(height: 12),
|
|
_field(_last, 'Nachname'),
|
|
const SizedBox(height: 12),
|
|
_field(_email, 'E-Mail (bei Gruppen-Link nötig)'),
|
|
const SizedBox(height: 12),
|
|
_field(_password, 'Passwort wählen (min. 8 Zeichen)', obscure: true),
|
|
],
|
|
onSubmit: () => state.redeemInvite(
|
|
token: _token.text.trim(),
|
|
first: _first.text.trim(),
|
|
last: _last.text.trim(),
|
|
password: _password.text,
|
|
email: _email.text.trim(),
|
|
),
|
|
);
|
|
}
|
|
}
|