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:
@@ -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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user