diff --git a/lib/data/network/backend_config.dart b/lib/data/network/backend_config.dart index f2932d5..2d0fcdd 100644 --- a/lib/data/network/backend_config.dart +++ b/lib/data/network/backend_config.dart @@ -48,8 +48,8 @@ class BackendConfig { /// Default-Konfiguration für lokale Entwicklung gegen das /// Docker-Compose-Setup (Postgres + Keycloak + Backend). static const BackendConfig localDev = BackendConfig( - apiBaseUrl: 'http://localhost:3000', - keycloakIssuerUrl: 'http://localhost:8080/realms/holzleitner', + apiBaseUrl: 'http://192.168.0.138:3000', + keycloakIssuerUrl: 'http://192.168.0.138:8080/realms/holzleitner', keycloakClientId: 'holzleitner-app', keycloakRedirectUrl: 'holzleitner://oauth2redirect', ); diff --git a/lib/feature/authentication/bloc/auth_bloc.dart b/lib/feature/authentication/bloc/auth_bloc.dart index 1f378ed..0d8d4a1 100644 --- a/lib/feature/authentication/bloc/auth_bloc.dart +++ b/lib/feature/authentication/bloc/auth_bloc.dart @@ -23,13 +23,19 @@ class AuthBloc extends Bloc { on(_handleLogout); on(_handleRestore); on(_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((event, emit) async { - await tokenProvider.logout(); - if (locator.isRegistered()) { - locator.unregister(); - } - emit(Unauthenticated(sessionExpired: true)); + debugPrint( + '[AuthBloc] SessionExpiredEvent aus Legacy-Repo ignoriert — ' + 'echter SessionExpired kommt vom KeycloakOidcTokenProvider.', + ); }); _subscription = tokenProvider.events.listen( diff --git a/lib/feature/car_selection/presentation/car_selection_page.dart b/lib/feature/car_selection/presentation/car_selection_page.dart index 2f454d8..2934d43 100644 --- a/lib/feature/car_selection/presentation/car_selection_page.dart +++ b/lib/feature/car_selection/presentation/car_selection_page.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.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_event.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/events.dart'; @@ -132,19 +133,27 @@ class _CarSelectionPageState extends State { } }, child: Scaffold( - appBar: _isChanging - ? AppBar( - leading: IconButton( + appBar: AppBar( + leading: _isChanging + ? IconButton( icon: const Icon(Icons.close), onPressed: () => context.read().add( CarSelectCancel(car: widget.previousCar!), ), - ), - title: const Text("Fahrzeug wechseln"), - backgroundColor: Theme.of(context).primaryColor, - foregroundColor: Theme.of(context).colorScheme.onSecondary, - ) - : null, + ) + : null, + title: Text( + _isChanging ? "Fahrzeug wechseln" : "Fahrzeug auswählen", + ), + 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( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -235,4 +244,78 @@ class _CarSelectionPageState extends State { ), ); } +} + +/// 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 _confirm(BuildContext context) async { + final authBloc = context.read(); + final confirmed = await showDialog( + 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().state; + final authenticated = auth is Authenticated ? auth : null; + return PopupMenuButton( + tooltip: "Account", + icon: const Icon(Icons.account_circle_outlined), + onSelected: (value) { + if (value == 'logout') _confirm(context); + }, + itemBuilder: (context) => [ + if (authenticated != null) + PopupMenuItem( + 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( + value: 'logout', + child: ListTile( + leading: Icon(Icons.logout, color: Colors.red), + title: Text("Abmelden", style: TextStyle(color: Colors.red)), + ), + ), + ], + ); + } } \ No newline at end of file