From 467f4b4ed2a898fbb816a3f4857cf7a1d24eebbe Mon Sep 17 00:00:00 2001 From: Dennis Nemec Date: Thu, 18 Jun 2026 13:08:18 +0200 Subject: [PATCH] feat(auth): Login-Timeout (10s) mit Hinweisbanner Haengt der interaktive Login (Browser-Tab/Token-Exchange) bei Verbindungsabbruch/Issuer-Hang, bricht er nach 10s ab; LoginPage zeigt 'Einloggen nicht moeglich. Spaeter erneut versuchen.' (Unauthenticated.loginTimedOut). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../authentication/bloc/auth_bloc.dart | 19 +++++++++++++- .../authentication/bloc/auth_state.dart | 9 ++++++- .../presentation/login_enforcer.dart | 7 ++++-- .../presentation/login_page.dart | 25 ++++++++++++++++++- 4 files changed, 55 insertions(+), 5 deletions(-) diff --git a/lib/feature/authentication/bloc/auth_bloc.dart b/lib/feature/authentication/bloc/auth_bloc.dart index ebdd1e6..2d23fca 100644 --- a/lib/feature/authentication/bloc/auth_bloc.dart +++ b/lib/feature/authentication/bloc/auth_bloc.dart @@ -48,15 +48,32 @@ class AuthBloc extends Bloc { late final StreamSubscription _subscription; + /// Timeout für den interaktiven Login. Schützt den Fahrer vor dem + /// „Spinner dreht ewig"-Fall, wenn der native `flutter_appauth`-Aufruf + /// (Browser-Tab + Token-Exchange) im Verbindungsabbruch / Issuer-Hang + /// stecken bleibt. Bewusst etwas knapper als der Restore-Timeout + /// (15 s im `_handleRestore`) — beim manuellen Login wartet der User + /// aktiv vor dem Bildschirm. + static const Duration _loginTimeout = Duration(seconds: 10); + Future _handleLogin( LoginRequested event, Emitter emit, ) async { try { emit(Authenticating()); - await tokenProvider.login(); + await tokenProvider.login().timeout(_loginTimeout); // Erfolg landet via Stream-Subscription als // ProviderSessionChanged.loggedIn → _handleProviderEvent. + } on TimeoutException { + // Native Login-Routine läuft im Hintergrund ggf. weiter. Sollte sie + // doch noch erfolgreich durchlaufen, feuert der Provider-Stream + // später ein `AuthLoggedIn` — der ProviderEvent-Handler hebt den + // State dann auf `Authenticated`. Kein Schaden, nur „nachträgliche + // Anmeldung". Bei späterer Exception passiert nichts; das Future + // ist hier nicht mehr awaited. + debugPrint('Login-Timeout nach ${_loginTimeout.inSeconds}s.'); + emit(Unauthenticated(loginTimedOut: true)); } catch (err, st) { debugPrint('Login fehlgeschlagen: $err\n$st'); emit(Unauthenticated()); diff --git a/lib/feature/authentication/bloc/auth_state.dart b/lib/feature/authentication/bloc/auth_state.dart index 073d7f3..4a91228 100644 --- a/lib/feature/authentication/bloc/auth_state.dart +++ b/lib/feature/authentication/bloc/auth_state.dart @@ -10,7 +10,14 @@ class AuthBootstrapping extends AuthState {} class Unauthenticated extends AuthState { final bool sessionExpired; - Unauthenticated({this.sessionExpired = false}); + + /// `true`, wenn der letzte Login-Versuch in das 10-s-Timeout im + /// [AuthBloc] gelaufen ist (z. B. Verbindungsabbruch während + /// `tokenProvider.login()` oder hängender Issuer). Die [LoginPage] + /// blendet daraufhin einen Hinweis ein. + final bool loginTimedOut; + + Unauthenticated({this.sessionExpired = false, this.loginTimedOut = false}); } /// Transient state während dem PKCE-Flow (Browser-Tab offen, diff --git a/lib/feature/authentication/presentation/login_enforcer.dart b/lib/feature/authentication/presentation/login_enforcer.dart index fd5797a..5fca2f5 100644 --- a/lib/feature/authentication/presentation/login_enforcer.dart +++ b/lib/feature/authentication/presentation/login_enforcer.dart @@ -28,8 +28,11 @@ class LoginEnforcer extends StatelessWidget { if (state is AuthBootstrapping) { return const _AuthBootstrapSplash(); } - final expired = state is Unauthenticated && state.sessionExpired; - return LoginPage(sessionExpired: expired); + final unauth = state is Unauthenticated ? state : null; + return LoginPage( + sessionExpired: unauth?.sessionExpired ?? false, + loginTimedOut: unauth?.loginTimedOut ?? false, + ); }, ); } diff --git a/lib/feature/authentication/presentation/login_page.dart b/lib/feature/authentication/presentation/login_page.dart index f9b4d91..3898d6c 100644 --- a/lib/feature/authentication/presentation/login_page.dart +++ b/lib/feature/authentication/presentation/login_page.dart @@ -12,10 +12,19 @@ import 'package:hl_lieferservice/feature/authentication/bloc/auth_state.dart'; /// Browser-Tab, der RedirectURI `holzleitner://oauth2redirect` kommt /// zurück, der Code wird gegen Tokens getauscht. class LoginPage extends StatelessWidget { - const LoginPage({super.key, this.sessionExpired = false}); + const LoginPage({ + super.key, + this.sessionExpired = false, + this.loginTimedOut = false, + }); final bool sessionExpired; + /// Hinweis-Banner, dass der vorherige Login-Versuch ins 10-s-Timeout + /// gelaufen ist (typisch: Verbindungsabbruch oder hängender Issuer). + /// Wird vom `LoginEnforcer` aus `Unauthenticated.loginTimedOut` gefüttert. + final bool loginTimedOut; + @override Widget build(BuildContext context) { return Scaffold( @@ -36,6 +45,20 @@ class LoginPage extends StatelessWidget { ), actions: const [SizedBox.shrink()], ), + if (loginTimedOut) + MaterialBanner( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + content: const Text( + 'Einloggen nicht möglich. Später erneut versuchen.', + style: TextStyle(color: Colors.white), + ), + backgroundColor: Colors.red.shade700, + leading: const Icon( + Icons.cloud_off, + color: Colors.white, + ), + actions: const [SizedBox.shrink()], + ), Expanded( child: Center( child: Column(