Introduce a CodeResolverService to classify user login codes, complete with detailed resolution logic and usability checks. Extend the sync system to handle conflicts via last-write-wins arbitration, with detailed conflict tracking for review. Update file permissions and runtime isolation in Docker to enhance security.
375 lines
12 KiB
Dart
375 lines
12 KiB
Dart
import 'package:flutter/material.dart';
|
|
|
|
import '../api.dart';
|
|
import '../main.dart';
|
|
import '../theme.dart';
|
|
|
|
/// 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 Scaffold(
|
|
body: SafeArea(
|
|
child: Center(
|
|
child: SingleChildScrollView(
|
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32),
|
|
child: ConstrainedBox(
|
|
constraints: const BoxConstraints(maxWidth: 420),
|
|
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(),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
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.fields, required this.onSubmit, this.submitLabel = 'Anmelden'});
|
|
final List<Widget> fields;
|
|
final Future<void> Function() onSubmit;
|
|
final String submitLabel;
|
|
|
|
@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 Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
...widget.fields,
|
|
if (_error != null) ...[
|
|
const SizedBox(height: 8),
|
|
Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
|
|
],
|
|
const SizedBox(height: 16),
|
|
FilledButton(
|
|
onPressed: _busy ? null : _run,
|
|
child: _busy
|
|
? const SizedBox(
|
|
height: 18, width: 18,
|
|
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
|
|
: Text(widget.submitLabel),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
TextField _field(TextEditingController c, String label,
|
|
{bool obscure = false, IconData? icon, ValueChanged<String>? onChanged}) =>
|
|
TextField(
|
|
controller: c,
|
|
obscureText: obscure,
|
|
onChanged: onChanged,
|
|
decoration: InputDecoration(
|
|
labelText: label,
|
|
prefixIcon: icon != null ? Icon(icon, size: 20) : null,
|
|
),
|
|
);
|
|
|
|
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<_KonfiOrLeitungsteamSection> createState() => _KonfiOrLeitungsteamSectionState();
|
|
}
|
|
|
|
class _KonfiOrLeitungsteamSectionState extends State<_KonfiOrLeitungsteamSection> {
|
|
final _code = TextEditingController();
|
|
final _first = TextEditingController();
|
|
final _last = TextEditingController();
|
|
|
|
bool get _isLeitungsteamCode {
|
|
final c = _code.text.trim();
|
|
final upper = c.toUpperCase();
|
|
final lower = c.toLowerCase();
|
|
return upper == 'LT' || (c.length > 2 && upper.endsWith('LT')) || lower == 'login' || lower == 'sso';
|
|
}
|
|
|
|
/// 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();
|
|
if (c.toUpperCase() == 'LT' || c.toLowerCase() == 'login' || c.toLowerCase() == 'sso') return '';
|
|
return (c.length > 2 && c.toUpperCase().endsWith('LT'))
|
|
? c.substring(0, c.length - 2)
|
|
: c;
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final state = AppScope.of(context);
|
|
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: gib "LT" ein oder hänge "LT" an den '
|
|
'Code an (z. B. "LT" oder "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,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _LeitungsteamLogin extends StatelessWidget {
|
|
const _LeitungsteamLogin({super.key, required this.state});
|
|
final AppState state;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
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, size: 20),
|
|
label: const Text('Mit Konfi-Castle-ID anmelden'),
|
|
),
|
|
const SizedBox(height: 8),
|
|
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,
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 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 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(),
|
|
],
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
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 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(),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|