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
|
/// 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',
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user