feat(backend): Wahl-Phasen, Verantwortliche-Invites, Auth fixes

- New VerantwortlicheInvite model: LT-issued invites so a person can
  register as Gemeinde Verantwortliche(r) for a specific Gemeinde,
  skipping the self-registration approval step.
- Wahl/Workshop/Teilnehmer gain phase support (phasenAnzahl,
  beschreibung), mirroring the WP plugin's multi-phase elections.
  Teilnehmer unique constraint now scoped per phase.
- Auth: team login + guest auth adjustments, spec coverage.
- sync.service.ts: register VerantwortlicheInvite as a synced model.
- wahl.service.ts: submitTeilnehmer updated for the new phase-scoped
  unique key.
- client: login/home screen rework, new theme.dart, FCM web tweaks.
- .gitignore: ignore .DS_Store.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-11 18:00:18 +02:00
co-authored by Claude Sonnet 5
parent 72367637aa
commit 8af927c0f2
21 changed files with 844 additions and 175 deletions
+10 -4
View File
@@ -450,8 +450,14 @@ class Api {
return j['accessToken'] as String;
}
Future<String> teamLogin(String email, String password) async {
final j = await _post('/auth/team-login', {'email': email, 'password': password});
/// Logs a Gemeinde Teamer in by Gemeinde name (the normal path) or by
/// email (legacy/personal accounts) — pass exactly one of the two.
Future<String> teamLogin({String? gemeindeName, String? email, required String password}) async {
final j = await _post('/auth/team-login', {
if (gemeindeName != null && gemeindeName.isNotEmpty) 'gemeindeName': gemeindeName,
if (email != null && email.isNotEmpty) 'email': email,
'password': password,
});
return j['accessToken'] as String;
}
@@ -772,8 +778,8 @@ class AppState extends ChangeNotifier {
Future<void> guestLogin(String code, String first, String last) =>
_api.guestLogin(code, first, last).then(_establish);
Future<void> teamLogin(String email, String password) =>
_api.teamLogin(email, password).then(_establish);
Future<void> teamLogin({String? gemeindeName, String? email, required String password}) =>
_api.teamLogin(gemeindeName: gemeindeName, email: email, password: password).then(_establish);
Future<void> redeemInvite({
required String token,
+2 -4
View File
@@ -4,6 +4,7 @@ import 'package:http/http.dart' as http;
import 'api.dart';
import 'screens/home_screen.dart';
import 'screens/login_screen.dart';
import 'theme.dart';
void main() {
final state = AppState(Api(http.Client()))..bootstrap();
@@ -34,10 +35,7 @@ class KcApp extends StatelessWidget {
child: MaterialApp(
title: 'KC-App',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorSchemeSeed: const Color(0xFF3B5BA5),
useMaterial3: true,
),
theme: buildKcTheme(),
home: const _AuthGate(),
),
);
+61 -13
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import '../api.dart';
import '../main.dart';
import '../theme.dart';
import 'admin_screen.dart';
import 'chat_screen.dart';
import 'files_screen.dart';
@@ -109,8 +110,7 @@ class _IdentityCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final lines = <String>[
'Rolle: ${id.roleLabel}',
if (id.email != null) 'E-Mail: ${id.email}',
if (id.email != null) id.email!,
if (id.isLeitungsteam)
'Leitungsteam-Rechte gelten KC-übergreifend.'
else if (id.memberships.length > 1)
@@ -118,13 +118,35 @@ class _IdentityCard extends StatelessWidget {
];
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
padding: const EdgeInsets.all(20),
child: Row(
children: [
Text('Angemeldet', style: Theme.of(context).textTheme.labelMedium),
const SizedBox(height: 4),
for (final l in lines) Text(l),
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [KcColors.blue, KcColors.teal],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
shape: BoxShape.circle,
),
child: const Icon(Icons.person, color: Colors.white),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(id.roleLabel, style: Theme.of(context).textTheme.titleMedium),
for (final l in lines) ...[
const SizedBox(height: 2),
Text(l, style: Theme.of(context).textTheme.bodySmall),
],
],
),
),
],
),
),
@@ -147,12 +169,38 @@ class _NavTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Card(
child: ListTile(
leading: Icon(icon),
title: Text(title),
subtitle: Text(subtitle),
trailing: const Icon(Icons.chevron_right),
margin: const EdgeInsets.only(bottom: 12),
child: InkWell(
borderRadius: BorderRadius.circular(20),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: KcColors.blue.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Icon(icon, color: KcColors.blue),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 2),
Text(subtitle, style: Theme.of(context).textTheme.bodySmall),
],
),
),
Icon(Icons.chevron_right, color: KcColors.slate.withValues(alpha: 0.6)),
],
),
),
),
);
}
+238 -110
View File
@@ -2,38 +2,49 @@ import 'package:flutter/material.dart';
import '../api.dart';
import '../main.dart';
import '../theme.dart';
class LoginScreen extends StatelessWidget {
/// A single login screen — one card, no tabs, no role switcher. The KC-Code
/// field drives Konfi vs. Leitungsteam: a plain code reveals the Konfi name
/// fields; appending "LT" to the code (e.g. "ABC123LT") reveals the
/// Leitungsteam Authentik button instead. Gemeinde Teamer:in has its own
/// section below, logging in with the Gemeinde name instead of an email.
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key});
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
@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(
return Scaffold(
body: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Padding(
padding: const EdgeInsets.all(24),
child: TabBarView(
children: const [
_GuestForm(),
_TeamForm(),
_InviteForm(),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const _Brand(),
const SizedBox(height: 28),
Card(
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 28, 24, 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: const [
_KonfiOrLeitungsteamSection(),
Divider(height: 40),
_TeamerSection(),
],
),
),
),
],
),
),
),
@@ -43,12 +54,44 @@ class LoginScreen extends StatelessWidget {
}
}
/// Shared submit-button + error handling for the three little forms.
class _Brand extends StatelessWidget {
const _Brand();
@override
Widget build(BuildContext context) {
return Column(
children: [
Container(
width: 64,
height: 64,
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [KcColors.blue, KcColors.teal],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(18),
),
child: const Icon(Icons.castle_outlined, color: Colors.white, size: 32),
),
const SizedBox(height: 16),
const Text(
'KC-App',
style: TextStyle(fontSize: 26, fontWeight: FontWeight.w800, color: KcColors.navy),
),
const SizedBox(height: 4),
Text('Konfi-Castle Events', style: TextStyle(fontSize: 14, color: KcColors.slate)),
],
);
}
}
/// Shared submit-button + error handling.
class _FormShell extends StatefulWidget {
const _FormShell({required this.title, required this.fields, required this.onSubmit});
final String title;
const _FormShell({required this.fields, required this.onSubmit, this.submitLabel = 'Anmelden'});
final List<Widget> fields;
final Future<void> Function() onSubmit;
final String submitLabel;
@override
State<_FormShell> createState() => _FormShellState();
@@ -76,126 +119,203 @@ class _FormShellState extends State<_FormShell> {
@override
Widget build(BuildContext context) {
return ListView(
shrinkWrap: true,
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
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) ...[
const SizedBox(height: 8),
Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
const SizedBox(height: 12),
],
const SizedBox(height: 16),
FilledButton(
onPressed: _busy ? null : _run,
child: _busy
? const SizedBox(
height: 18, width: 18, child: CircularProgressIndicator(strokeWidth: 2))
: const Text('Weiter'),
height: 18, width: 18,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
: Text(widget.submitLabel),
),
],
);
}
}
TextField _field(TextEditingController c, String label, {bool obscure = false}) => TextField(
TextField _field(TextEditingController c, String label,
{bool obscure = false, IconData? icon, ValueChanged<String>? onChanged}) =>
TextField(
controller: c,
obscureText: obscure,
decoration: InputDecoration(labelText: label, border: const OutlineInputBorder()),
onChanged: onChanged,
decoration: InputDecoration(
labelText: label,
prefixIcon: icon != null ? Icon(icon, size: 20) : null,
),
);
class _GuestForm extends StatefulWidget {
const _GuestForm();
const _fieldGap = SizedBox(height: 12);
/// One code field drives two different logins: a plain KC-Code reveals the
/// Konfi name fields; a code ending in "LT" (e.g. "ABC123LT") reveals the
/// Leitungsteam Authentik button instead — no separate role picker needed.
class _KonfiOrLeitungsteamSection extends StatefulWidget {
const _KonfiOrLeitungsteamSection();
@override
State<_GuestForm> createState() => _GuestFormState();
State<_KonfiOrLeitungsteamSection> createState() => _KonfiOrLeitungsteamSectionState();
}
class _GuestFormState extends State<_GuestForm> {
class _KonfiOrLeitungsteamSectionState extends State<_KonfiOrLeitungsteamSection> {
final _code = TextEditingController();
final _first = TextEditingController();
final _last = TextEditingController();
bool get _isLeitungsteamCode {
final c = _code.text.trim();
return c.length > 2 && c.toUpperCase().endsWith('LT');
}
/// The KC-Code with a trailing "LT" trigger stripped back off, so
/// "ABC123LT" still resolves to the real invite code "ABC123".
String get _plainCode {
final c = _code.text.trim();
return _isLeitungsteamCode ? c.substring(0, c.length - 2) : c;
}
@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'),
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_field(
_code,
'KC-Code',
icon: Icons.confirmation_number_outlined,
onChanged: (_) => setState(() {}),
),
const SizedBox(height: 4),
Text(
'Konfi: gib deinen KC-Code ein. Leitungsteam: hänge "LT" an den '
'Code an (z. B. "ABC123LT").',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 16),
AnimatedSwitcher(
duration: const Duration(milliseconds: 180),
child: _code.text.trim().isEmpty
? const SizedBox.shrink(key: ValueKey('empty'))
: _isLeitungsteamCode
? _LeitungsteamLogin(key: const ValueKey('lt'), state: state)
: Column(
key: const ValueKey('konfi'),
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_field(_first, 'Vorname', icon: Icons.badge_outlined),
_fieldGap,
_field(_last, 'Nachname'),
const SizedBox(height: 4),
_FormShell(
submitLabel: 'Los geht\'s',
fields: const [],
onSubmit: () => state.guestLogin(
_plainCode,
_first.text.trim(),
_last.text.trim(),
),
),
const SizedBox(height: 4),
Text(
'Meldest du dich erneut mit demselben Code und Namen '
'an, kommst du in deinen bestehenden Account zurück.',
style: Theme.of(context).textTheme.bodySmall,
textAlign: TextAlign.center,
),
],
),
),
],
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();
class _LeitungsteamLogin extends StatelessWidget {
const _LeitungsteamLogin({super.key, required this.state});
final AppState state;
@override
Widget build(BuildContext context) {
final state = AppScope.of(context);
return ListView(
shrinkWrap: true,
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
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)),
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),
icon: const Icon(Icons.login, size: 20),
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),
Text(
'Öffnet die Konfi-Castle-ID (Authentik). Leitungsteam-Rechte und '
'Gemeinde-Zuordnungen kommen automatisch aus deinem Account.',
style: Theme.of(context).textTheme.bodySmall,
textAlign: TextAlign.center,
),
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;
/// Gemeinde Teamer:in login — Gemeinde name instead of email, since that's
/// what a Teamer actually thinks of as "their" login. Invite redemption for
/// a first-time account is folded in underneath.
class _TeamerSection extends StatefulWidget {
const _TeamerSection();
@override
State<_TeamerSection> createState() => _TeamerSectionState();
}
class _TeamerSectionState extends State<_TeamerSection> {
final _gemeinde = TextEditingController();
final _password = TextEditingController();
bool _showInvite = false;
@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),
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Gemeinde Teamer:in', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 16),
_FormShell(
fields: [
_field(_gemeinde, 'Gemeinde', icon: Icons.groups_outlined),
_fieldGap,
_field(_password, 'Passwort', obscure: true, icon: Icons.lock_outline),
],
onSubmit: () => state.teamLogin(
gemeindeName: _gemeinde.text.trim(),
password: _password.text,
),
),
const SizedBox(height: 4),
Center(
child: TextButton(
onPressed: () => setState(() => _showInvite = !_showInvite),
child: Text(_showInvite
? 'Einladung ausblenden'
: 'Noch kein Konto? Einladung einlösen'),
),
),
if (_showInvite) ...[
const Divider(height: 28),
const _InviteForm(),
],
],
onSubmit: () => state.teamLogin(email.text.trim(), password.text),
);
}
}
@@ -216,26 +336,34 @@ class _InviteFormState extends State<_InviteForm> {
@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),
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Teamer:in-Einladung einlösen',
style: Theme.of(context).textTheme.titleMedium, textAlign: TextAlign.center),
const SizedBox(height: 16),
_FormShell(
submitLabel: 'Konto anlegen',
fields: [
_field(_token, 'Einladungscode / Token'),
_fieldGap,
_field(_first, 'Vorname'),
_fieldGap,
_field(_last, 'Nachname'),
_fieldGap,
_field(_email, 'E-Mail (bei Gruppen-Link nötig)'),
_fieldGap,
_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(),
),
),
],
onSubmit: () => state.redeemInvite(
token: _token.text.trim(),
first: _first.text.trim(),
last: _last.text.trim(),
password: _password.text,
email: _email.text.trim(),
),
);
}
}
+99
View File
@@ -0,0 +1,99 @@
import 'package:flutter/material.dart';
/// Brand palette lifted from konfi-castle.com (Kubio theme CSS custom
/// properties: --kubio-color-1..6) so the app's look leans on the same
/// identity as the marketing site instead of Flutter's default Material
/// purple.
abstract final class KcColors {
static const blue = Color(0xFF2F7CFF); // --kubio-color-1
static const orange = Color(0xFFF17C20); // --kubio-color-2
static const teal = Color(0xFF4EBA9A); // --kubio-color-3
static const slate = Color(0xFF69768B); // --kubio-color-4
static const navy = Color(0xFF2B2D42); // --kubio-color-6 (headings/text)
static const surface = Color(0xFFF7F9FC);
}
ThemeData buildKcTheme() {
final colorScheme = ColorScheme.fromSeed(
seedColor: KcColors.blue,
brightness: Brightness.light,
).copyWith(
primary: KcColors.blue,
secondary: KcColors.orange,
tertiary: KcColors.teal,
surface: KcColors.surface,
onSurface: KcColors.navy,
);
return ThemeData(
useMaterial3: true,
colorScheme: colorScheme,
scaffoldBackgroundColor: KcColors.surface,
appBarTheme: AppBarTheme(
backgroundColor: KcColors.surface,
foregroundColor: KcColors.navy,
elevation: 0,
centerTitle: false,
titleTextStyle: const TextStyle(
color: KcColors.navy,
fontSize: 20,
fontWeight: FontWeight.w700,
),
),
cardTheme: CardThemeData(
elevation: 0,
color: Colors.white,
surfaceTintColor: Colors.transparent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
side: BorderSide(color: KcColors.navy.withValues(alpha: 0.06)),
),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: KcColors.surface,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: KcColors.blue, width: 1.5),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
backgroundColor: KcColors.blue,
foregroundColor: Colors.white,
minimumSize: const Size.fromHeight(50),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
textStyle: const TextStyle(fontWeight: FontWeight.w600, fontSize: 15),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: KcColors.blue,
side: const BorderSide(color: KcColors.blue),
minimumSize: const Size.fromHeight(46),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
),
segmentedButtonTheme: SegmentedButtonThemeData(
style: SegmentedButton.styleFrom(
selectedBackgroundColor: KcColors.blue,
selectedForegroundColor: Colors.white,
foregroundColor: KcColors.navy,
side: BorderSide(color: KcColors.navy.withValues(alpha: 0.15)),
),
),
textTheme: const TextTheme(
titleLarge: TextStyle(color: KcColors.navy, fontWeight: FontWeight.w700),
titleMedium: TextStyle(color: KcColors.navy, fontWeight: FontWeight.w600),
bodyMedium: TextStyle(color: KcColors.navy),
bodySmall: TextStyle(color: KcColors.slate),
labelMedium: TextStyle(color: KcColors.slate),
),
dividerTheme: DividerThemeData(color: KcColors.navy.withValues(alpha: 0.08)),
);
}