Files
KC-APP/client/app/lib/screens/home_screen.dart
T
linusandClaude Sonnet 5 df11d8492d 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>
2026-09-10 09:24:38 +02:00

148 lines
4.0 KiB
Dart

import 'package:flutter/material.dart';
import '../api.dart';
import '../main.dart';
import 'admin_screen.dart';
import 'chat_screen.dart';
import 'files_screen.dart';
import 'wahl_screen.dart';
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context) {
final state = AppScope.of(context);
final id = state.identity!;
final kcId = id.kcId;
final tiles = <Widget>[
if (id.isLeitungsteam)
_NavTile(
icon: Icons.admin_panel_settings,
title: 'Verwaltung',
subtitle: 'KCs, Gemeinden, Onboarding-Freigaben',
onTap: () => _open(context, const AdminScreen()),
),
if (id.kind == SessionKind.guest)
_NavTile(
icon: Icons.how_to_vote,
title: 'Workshop-Wahl',
subtitle: 'Deine Wünsche abgeben',
onTap: () => _open(context, const WahlScreen()),
),
if (kcId != null)
_NavTile(
icon: Icons.folder_shared,
title: 'Dateien',
subtitle: 'Freigegebene Dateien ansehen',
onTap: () => _open(context, FilesScreen(kcId: kcId)),
),
if (kcId != null)
_NavTile(
icon: Icons.forum,
title: 'Chat',
subtitle: 'Kanäle, Verlauf & Live-Nachrichten',
onTap: () => _open(context, ChatScreen(kcId: kcId)),
),
];
return Scaffold(
appBar: AppBar(
title: const Text('KC-App'),
actions: [
IconButton(
tooltip: 'Abmelden',
onPressed: state.logout,
icon: const Icon(Icons.logout),
),
],
),
body: SafeArea(
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560),
child: ListView(
padding: const EdgeInsets.all(20),
children: [
_IdentityCard(id: id),
const SizedBox(height: 16),
...tiles,
if (tiles.isEmpty)
const Padding(
padding: EdgeInsets.only(top: 24),
child: Text(
'Für diesen Account gibt es hier noch keine Ansichten. '
'Sobald dir eine Gemeinde/ein KC zugeordnet ist, erscheinen '
'Dateien und Chat.',
),
),
],
),
),
),
),
);
}
void _open(BuildContext context, Widget screen) {
Navigator.of(context).push(MaterialPageRoute(builder: (_) => screen));
}
}
class _IdentityCard extends StatelessWidget {
const _IdentityCard({required this.id});
final Identity id;
@override
Widget build(BuildContext context) {
final lines = <String>[
'Rolle: ${id.roleLabel}',
if (id.email != null) 'E-Mail: ${id.email}',
if (id.isLeitungsteam)
'Leitungsteam-Rechte gelten KC-übergreifend.'
else if (id.memberships.length > 1)
'${id.memberships.length} Zuordnungen',
];
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Angemeldet', style: Theme.of(context).textTheme.labelMedium),
const SizedBox(height: 4),
for (final l in lines) Text(l),
],
),
),
);
}
}
class _NavTile extends StatelessWidget {
const _NavTile({
required this.icon,
required this.title,
required this.subtitle,
required this.onTap,
});
final IconData icon;
final String title;
final String subtitle;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Card(
child: ListTile(
leading: Icon(icon),
title: Text(title),
subtitle: Text(subtitle),
trailing: const Icon(Icons.chevron_right),
onTap: onTap,
),
);
}
}