Phase B+1 Nachzügler: LAN-IP-Config, SessionExpired-Legacy, Logout in CarSelection

- BackendConfig.localDev nutzt jetzt die LAN-IP des Dev-Macs
  (192.168.0.138) statt localhost. Notwendig zum Testen auf einem
  realen Android-Gerät über WLAN. Auf dem iOS-Simulator
  zurückwechseln oder per Build-Flag injizieren.
- AuthBloc.on<SessionExpiredEvent> wird zum No-Op (mit Log).
  Begründung: die alten ERPframe-Repos rufen das nach jedem 401 auf,
  weil ihr Cookie-Login serverseitig weg ist. Solange Phase D diese
  Repos nicht ersetzt hat, wäre ein echter Logout daraus fatal —
  der erste TourBloc-Load nach Keycloak-Login würde die Session
  sofort wieder wegwerfen. Die legitime SessionExpired-Quelle bleibt
  der Provider-Stream (Refresh-Failure).
- CarSelectionPage hat jetzt durchgehend eine AppBar (vorher nur
  im 'wechseln'-Modus) plus ein Account-Popup oben rechts mit
  Personalnummer + roter Abmelden-Aktion. Der Drawer ist sonst
  nur an Home, und solange Cars-Loading per 401 blockt, kommt der
  User ohne Pre-Home-Logout nicht raus.
This commit is contained in:
Dennis Nemec
2026-05-15 11:33:34 +02:00
parent f074d53f3d
commit e369d1ceb2
3 changed files with 106 additions and 17 deletions

View File

@ -48,8 +48,8 @@ class BackendConfig {
/// Default-Konfiguration für lokale Entwicklung gegen das /// Default-Konfiguration für lokale Entwicklung gegen das
/// Docker-Compose-Setup (Postgres + Keycloak + Backend). /// Docker-Compose-Setup (Postgres + Keycloak + Backend).
static const BackendConfig localDev = BackendConfig( static const BackendConfig localDev = BackendConfig(
apiBaseUrl: 'http://localhost:3000', apiBaseUrl: 'http://192.168.0.138:3000',
keycloakIssuerUrl: 'http://localhost:8080/realms/holzleitner', keycloakIssuerUrl: 'http://192.168.0.138:8080/realms/holzleitner',
keycloakClientId: 'holzleitner-app', keycloakClientId: 'holzleitner-app',
keycloakRedirectUrl: 'holzleitner://oauth2redirect', keycloakRedirectUrl: 'holzleitner://oauth2redirect',
); );

View File

@ -23,13 +23,19 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
on<LogoutRequested>(_handleLogout); on<LogoutRequested>(_handleLogout);
on<RestoreSessionRequested>(_handleRestore); on<RestoreSessionRequested>(_handleRestore);
on<ProviderSessionChanged>(_handleProviderEvent); on<ProviderSessionChanged>(_handleProviderEvent);
// Legacy: ERPframe-Repos feuern bei 401. // Legacy: alte ERPframe-Repos rufen authBloc.add(SessionExpiredEvent())
// bei jedem 401, weil ihr Cookie-Login serverseitig nicht mehr
// existiert. Solange Phase D diese Repos nicht ersetzt hat, wäre
// ein echter Logout daraus fatal: der erste TourBloc-Load nach
// erfolgreicher Keycloak-Anmeldung würde die Session sofort wieder
// wegwerfen. Daher hier nur loggen, **nicht** ausloggen — die
// legitime SessionExpired-Quelle ist der Provider-Stream
// (AuthSessionExpired bei Refresh-Failure).
on<SessionExpiredEvent>((event, emit) async { on<SessionExpiredEvent>((event, emit) async {
await tokenProvider.logout(); debugPrint(
if (locator.isRegistered<Authenticated>()) { '[AuthBloc] SessionExpiredEvent aus Legacy-Repo ignoriert — '
locator.unregister<Authenticated>(); 'echter SessionExpired kommt vom KeycloakOidcTokenProvider.',
} );
emit(Unauthenticated(sessionExpired: true));
}); });
_subscription = tokenProvider.events.listen( _subscription = tokenProvider.events.listen(

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hl_lieferservice/feature/authentication/bloc/auth_bloc.dart'; import 'package:hl_lieferservice/feature/authentication/bloc/auth_bloc.dart';
import 'package:hl_lieferservice/feature/authentication/bloc/auth_event.dart';
import 'package:hl_lieferservice/feature/authentication/bloc/auth_state.dart'; import 'package:hl_lieferservice/feature/authentication/bloc/auth_state.dart';
import 'package:hl_lieferservice/feature/car_selection/bloc/bloc.dart'; import 'package:hl_lieferservice/feature/car_selection/bloc/bloc.dart';
import 'package:hl_lieferservice/feature/car_selection/bloc/events.dart'; import 'package:hl_lieferservice/feature/car_selection/bloc/events.dart';
@ -132,19 +133,27 @@ class _CarSelectionPageState extends State<CarSelectionPage> {
} }
}, },
child: Scaffold( child: Scaffold(
appBar: _isChanging appBar: AppBar(
? AppBar( leading: _isChanging
leading: IconButton( ? IconButton(
icon: const Icon(Icons.close), icon: const Icon(Icons.close),
onPressed: () => context.read<CarSelectBloc>().add( onPressed: () => context.read<CarSelectBloc>().add(
CarSelectCancel(car: widget.previousCar!), CarSelectCancel(car: widget.previousCar!),
), ),
), )
title: const Text("Fahrzeug wechseln"), : null,
backgroundColor: Theme.of(context).primaryColor, title: Text(
foregroundColor: Theme.of(context).colorScheme.onSecondary, _isChanging ? "Fahrzeug wechseln" : "Fahrzeug auswählen",
) ),
: null, backgroundColor: Theme.of(context).primaryColor,
foregroundColor: Theme.of(context).colorScheme.onSecondary,
actions: [
// Logout-Zugang auch hier vorhalten, weil der Drawer nur an
// der Home-Page hängt und der User bis zur Fahrzeugwahl
// sonst keine Möglichkeit hat, sich abzumelden.
_LogoutMenu(),
],
),
body: SafeArea( body: SafeArea(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -235,4 +244,78 @@ class _CarSelectionPageState extends State<CarSelectionPage> {
), ),
); );
} }
}
/// Kleines PopupMenu mit Abmelden-Eintrag — bewusst kein eigener
/// IconButton, weil das Confirm-Dialog-Pattern Konsistenz mit dem
/// Home-Drawer hat. Auch hier: Refresh-Token wird im Provider gelöscht,
/// LoginEnforcer routet automatisch auf LoginPage zurück.
class _LogoutMenu extends StatelessWidget {
Future<void> _confirm(BuildContext context) async {
final authBloc = context.read<AuthBloc>();
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text("Abmelden"),
content: const Text(
"Möchten Sie sich wirklich abmelden? "
"Beim nächsten Start ist eine erneute Anmeldung erforderlich.",
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: const Text("Abbrechen"),
),
FilledButton(
style: FilledButton.styleFrom(backgroundColor: Colors.red),
onPressed: () => Navigator.of(dialogContext).pop(true),
child: const Text("Abmelden"),
),
],
),
);
if (confirmed == true) {
authBloc.add(const LogoutRequested());
}
}
@override
Widget build(BuildContext context) {
final auth = context.watch<AuthBloc>().state;
final authenticated = auth is Authenticated ? auth : null;
return PopupMenuButton<String>(
tooltip: "Account",
icon: const Icon(Icons.account_circle_outlined),
onSelected: (value) {
if (value == 'logout') _confirm(context);
},
itemBuilder: (context) => [
if (authenticated != null)
PopupMenuItem<String>(
enabled: false,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
authenticated.displayName,
style: const TextStyle(fontWeight: FontWeight.w600),
),
Text(
"Personalnummer ${authenticated.personalnummer}",
style: const TextStyle(fontSize: 12, color: Colors.grey),
),
],
),
),
if (authenticated != null) const PopupMenuDivider(),
const PopupMenuItem<String>(
value: 'logout',
child: ListTile(
leading: Icon(Icons.logout, color: Colors.red),
title: Text("Abmelden", style: TextStyle(color: Colors.red)),
),
),
],
);
}
} }