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:
@ -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',
|
||||
);
|
||||
|
||||
@ -23,13 +23,19 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||
on<LogoutRequested>(_handleLogout);
|
||||
on<RestoreSessionRequested>(_handleRestore);
|
||||
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 {
|
||||
await tokenProvider.logout();
|
||||
if (locator.isRegistered<Authenticated>()) {
|
||||
locator.unregister<Authenticated>();
|
||||
}
|
||||
emit(Unauthenticated(sessionExpired: true));
|
||||
debugPrint(
|
||||
'[AuthBloc] SessionExpiredEvent aus Legacy-Repo ignoriert — '
|
||||
'echter SessionExpired kommt vom KeycloakOidcTokenProvider.',
|
||||
);
|
||||
});
|
||||
|
||||
_subscription = tokenProvider.events.listen(
|
||||
|
||||
@ -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<CarSelectionPage> {
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
appBar: _isChanging
|
||||
? AppBar(
|
||||
leading: IconButton(
|
||||
appBar: AppBar(
|
||||
leading: _isChanging
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => context.read<CarSelectBloc>().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<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)),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user