From c9fd0afdf756870dbb574229e680210e3ee9e292 Mon Sep 17 00:00:00 2001 From: cwiesen Date: Wed, 25 Feb 2026 15:45:00 -0600 Subject: [PATCH] feat: redirect to new signedout screen on sign out --- lib/core/auth/auth_flow_native.dart | 6 +- lib/core/auth/auth_flow_web.dart | 2 +- lib/core/auth/auth_provider.dart | 7 +++ lib/core/auth/web_auth_callback.dart | 6 ++ lib/core/auth/web_auth_callback_native.dart | 5 ++ lib/core/auth/web_auth_callback_web.dart | 13 +++++ lib/core/router/app_router.dart | 26 +++++++-- lib/features/auth/signed_out_screen.dart | 34 +++++++++++ lib/run_soliplex_app.dart | 9 ++- test/core/router/app_router_test.dart | 37 +++++++++--- .../features/auth/signed_out_screen_test.dart | 57 +++++++++++++++++++ 11 files changed, 183 insertions(+), 19 deletions(-) create mode 100644 lib/features/auth/signed_out_screen.dart create mode 100644 test/features/auth/signed_out_screen_test.dart diff --git a/lib/core/auth/auth_flow_native.dart b/lib/core/auth/auth_flow_native.dart index 87f74ccd..afc2e174 100644 --- a/lib/core/auth/auth_flow_native.dart +++ b/lib/core/auth/auth_flow_native.dart @@ -32,10 +32,12 @@ class NativeAuthFlow implements AuthFlow { required FlutterAppAuth appAuth, required String redirectScheme, }) : _appAuth = appAuth, - _redirectUri = '$redirectScheme://callback'; + _redirectUri = '$redirectScheme://callback', + _logoutRedirectUri = '$redirectScheme://signedout'; final FlutterAppAuth _appAuth; final String _redirectUri; + final String _logoutRedirectUri; @override bool get isWeb => false; @@ -85,7 +87,7 @@ class NativeAuthFlow implements AuthFlow { EndSessionRequest( idTokenHint: idToken, discoveryUrl: discoveryUrl, - postLogoutRedirectUrl: _redirectUri, + postLogoutRedirectUrl: _logoutRedirectUri, ), ); } on Exception catch (e, s) { diff --git a/lib/core/auth/auth_flow_web.dart b/lib/core/auth/auth_flow_web.dart index d9d8b5e2..4890303f 100644 --- a/lib/core/auth/auth_flow_web.dart +++ b/lib/core/auth/auth_flow_web.dart @@ -95,7 +95,7 @@ class WebAuthFlow implements AuthFlow { final frontendOrigin = _navigator.origin; final logoutUri = Uri.parse(endSessionEndpoint).replace( queryParameters: { - 'post_logout_redirect_uri': frontendOrigin, + 'post_logout_redirect_uri': '$frontendOrigin/#/signedout', 'client_id': clientId, if (idToken.isNotEmpty) 'id_token_hint': idToken, }, diff --git a/lib/core/auth/auth_provider.dart b/lib/core/auth/auth_provider.dart index 1fb7ac22..ef51d840 100644 --- a/lib/core/auth/auth_provider.dart +++ b/lib/core/auth/auth_provider.dart @@ -37,6 +37,13 @@ final authFlowProvider = Provider((ref) { ); }); +/// Provider for initial URL hash path captured at startup. +/// +/// On web, captures the hash path before GoRouter overwrites it. +/// Used to detect post-logout redirect from IdP (e.g., '/signedout'). +/// On native, always null. +final capturedInitialPathProvider = Provider((ref) => null); + /// Provider for callback params captured at startup. /// /// Override this in [ProviderScope.overrides] with the value from diff --git a/lib/core/auth/web_auth_callback.dart b/lib/core/auth/web_auth_callback.dart index 516dc209..7b82b4f0 100644 --- a/lib/core/auth/web_auth_callback.dart +++ b/lib/core/auth/web_auth_callback.dart @@ -26,6 +26,12 @@ abstract final class CallbackParamsCapture { /// On web, extracts tokens from URL query params. /// On native, returns [NoCallbackParams] (native uses flutter_appauth). static CallbackParams captureNow() => impl.captureCallbackParamsNow(); + + /// Capture the initial URL hash path before GoRouter overwrites it. + /// + /// On web, returns the path from the hash (e.g., '/signedout'). + /// On native, returns null (not applicable). + static String? captureInitialHashPath() => impl.captureInitialHashPath(); } /// Service for handling OAuth callback URL operations. diff --git a/lib/core/auth/web_auth_callback_native.dart b/lib/core/auth/web_auth_callback_native.dart index 21ebfc87..0bc215e2 100644 --- a/lib/core/auth/web_auth_callback_native.dart +++ b/lib/core/auth/web_auth_callback_native.dart @@ -1,5 +1,10 @@ import 'package:soliplex_frontend/core/auth/web_auth_callback.dart'; +/// Capture initial hash path - no-op on native. +/// +/// Only relevant on web where the IdP redirects back with a hash path. +String? captureInitialHashPath() => null; + /// Capture callback params - no-op on native. /// /// Native platforms use flutter_appauth which handles OAuth callbacks diff --git a/lib/core/auth/web_auth_callback_web.dart b/lib/core/auth/web_auth_callback_web.dart index bc24d9ed..de0c3f00 100644 --- a/lib/core/auth/web_auth_callback_web.dart +++ b/lib/core/auth/web_auth_callback_web.dart @@ -4,6 +4,19 @@ import 'package:soliplex_frontend/core/auth/web_auth_callback.dart'; import 'package:soliplex_frontend/core/logging/loggers.dart'; import 'package:web/web.dart' as web; +/// Capture the initial URL hash path on web (e.g., '/signedout' from '/#/signedout'). +/// +/// Returns the path portion of the hash, or null if no hash or empty. +/// Used to detect post-logout redirect from IdP before GoRouter overrides it. +String? captureInitialHashPath() { + final hash = web.window.location.hash; + if (hash.isEmpty || hash == '#/') return null; + // Strip leading '#' to get path (e.g., '#/signedout' → '/signedout') + final path = hash.startsWith('#') ? hash.substring(1) : hash; + Loggers.auth.debug('Web auth: Captured initial hash path: $path'); + return path; +} + /// Capture callback params from current URL. /// /// Used by [CallbackParamsCapture.captureNow] in main() before ProviderScope. diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index 1244a032..6a672ab4 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -9,6 +9,7 @@ import 'package:soliplex_frontend/core/models/features.dart'; import 'package:soliplex_frontend/core/models/route_config.dart'; import 'package:soliplex_frontend/core/providers/shell_config_provider.dart'; import 'package:soliplex_frontend/features/auth/auth_callback_screen.dart'; +import 'package:soliplex_frontend/features/auth/signed_out_screen.dart'; import 'package:soliplex_frontend/features/home/home_screen.dart'; import 'package:soliplex_frontend/features/inspector/network_inspector_screen.dart'; import 'package:soliplex_frontend/features/log_viewer/log_viewer_screen.dart'; @@ -144,7 +145,7 @@ String _normalizePath(String path) { /// Home ('/') is public so users can configure the backend URL before auth. /// When [RouteConfig.showHomeRoute] is false, the '/' route doesn't exist /// (no fallback) - requests to '/' will hit the error page. -const _publicRoutes = {'/', '/login', '/auth/callback'}; +const _publicRoutes = {'/', '/login', '/auth/callback', '/signedout'}; /// Application router provider. /// @@ -182,14 +183,23 @@ final routerProvider = Provider((ref) { final isOAuthCallback = capturedParams is WebCallbackParams; Loggers.router.debug('isOAuthCallback = $isOAuthCallback'); + // Check if browser URL has a post-logout redirect path (e.g., '/signedout'). + // GoRouter's initialLocation overrides the browser hash, so we must + // capture the path before GoRouter takes over. + final capturedPath = ref.read(capturedInitialPathProvider); + // Use configured initial route or default to / final configuredInitial = routeConfig.initialRoute; final validatedInitial = isRouteVisible(configuredInitial, features, routeConfig) ? configuredInitial : getDefaultAuthenticatedRoute(features, routeConfig); - // Route to callback screen if we have OAuth tokens to process - final initialPath = isOAuthCallback ? '/auth/callback' : validatedInitial; + // Priority: OAuth callback > captured hash path > configured initial route + final initialPath = isOAuthCallback + ? '/auth/callback' + : capturedPath == '/signedout' + ? '/signedout' + : validatedInitial; Loggers.router.debug('Initial location: $initialPath'); return GoRouter( @@ -219,8 +229,7 @@ final routerProvider = Provider((ref) { if (isExplicitSignOut) { Loggers.router.info('Explicit sign-out detected'); } - final target = - isExplicitSignOut && routeConfig.showHomeRoute ? '/' : '/login'; + final target = isExplicitSignOut ? '/signedout' : '/login'; Loggers.router.debug('redirecting to $target'); return target; } @@ -253,6 +262,13 @@ final routerProvider = Provider((ref) { pageBuilder: (context, state) => const NoTransitionPage(child: AuthCallbackScreen()), ), + // Post-logout landing page - chrome-less like other auth screens + GoRoute( + path: '/signedout', + name: 'signed-out', + pageBuilder: (context, state) => + const NoTransitionPage(child: SignedOutScreen()), + ), if (routeConfig.showHomeRoute) GoRoute( path: '/', diff --git a/lib/features/auth/signed_out_screen.dart b/lib/features/auth/signed_out_screen.dart new file mode 100644 index 00000000..522cad86 --- /dev/null +++ b/lib/features/auth/signed_out_screen.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +/// Screen shown after the user has been signed out. +/// +/// Chrome-less (no AppShell) like other auth screens. +/// Provides a button to navigate back to the login screen. +class SignedOutScreen extends StatelessWidget { + const SignedOutScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const ExcludeSemantics(child: Icon(Icons.logout, size: 48)), + const SizedBox(height: 16), + Text( + 'You have been signed out', + style: Theme.of(context).textTheme.headlineSmall, + ), + const SizedBox(height: 24), + ElevatedButton( + onPressed: () => context.go('/login'), + child: const Text('Sign in again'), + ), + ], + ), + ), + ); + } +} diff --git a/lib/run_soliplex_app.dart b/lib/run_soliplex_app.dart index cdf0f05e..b50c0127 100644 --- a/lib/run_soliplex_app.dart +++ b/lib/run_soliplex_app.dart @@ -70,9 +70,12 @@ Future runSoliplexApp({ return true; // Handled — prevent default error reporting. }; - // Capture OAuth callback params BEFORE GoRouter initializes. - // GoRouter may modify the URL, losing the callback tokens. + // Capture URL state BEFORE GoRouter initializes (it overwrites the URL). + // GoRouter's initialLocation overrides the browser hash, so we must + // capture both OAuth callback params and the hash path (for post-logout + // redirect from IdP) before GoRouter takes over. final callbackParams = CallbackParamsCapture.captureNow(); + final initialHashPath = CallbackParamsCapture.captureInitialHashPath(); // Clear URL params immediately after capture (security: remove tokens). // Must happen before GoRouter initializes to avoid URL state conflicts. @@ -108,6 +111,8 @@ Future runSoliplexApp({ // Inject shell configuration via ProviderScope (no global state) shellConfigProvider.overrideWithValue(config), capturedCallbackParamsProvider.overrideWithValue(callbackParams), + if (initialHashPath != null) + capturedInitialPathProvider.overrideWithValue(initialHashPath), // Fulfills the contract of preloadedPrefsProvider, enabling // synchronous log config initialization (no race condition). preloadedPrefsProvider.overrideWithValue(prefs), diff --git a/test/core/router/app_router_test.dart b/test/core/router/app_router_test.dart index d1fd6ea9..ab193619 100644 --- a/test/core/router/app_router_test.dart +++ b/test/core/router/app_router_test.dart @@ -19,6 +19,7 @@ import 'package:soliplex_frontend/core/providers/shell_config_provider.dart'; import 'package:soliplex_frontend/core/providers/threads_provider.dart'; import 'package:soliplex_frontend/core/router/app_router.dart'; import 'package:soliplex_frontend/features/auth/auth_callback_screen.dart'; +import 'package:soliplex_frontend/features/auth/signed_out_screen.dart'; import 'package:soliplex_frontend/features/home/home_screen.dart'; import 'package:soliplex_frontend/features/login/login_screen.dart'; import 'package:soliplex_frontend/features/quiz/quiz_screen.dart'; @@ -116,14 +117,19 @@ Widget createRouterAppAt( return GoRouter( initialLocation: initialLocation, redirect: (context, state) { - const publicRoutes = {'/', '/login', '/auth/callback'}; + const publicRoutes = { + '/', + '/login', + '/auth/callback', + '/signedout', + }; final isPublicRoute = publicRoutes.contains(state.matchedLocation); if (!hasAccess && !isPublicRoute) { final target = switch (currentAuthState) { Unauthenticated( reason: UnauthenticatedReason.explicitSignOut, ) => - '/', + '/signedout', _ => '/login', }; return target; @@ -145,6 +151,11 @@ Widget createRouterAppAt( name: 'home', builder: (_, __) => const Scaffold(body: HomeScreen()), ), + GoRoute( + path: '/signedout', + name: 'signed-out', + builder: (_, __) => const SignedOutScreen(), + ), GoRoute( path: '/rooms', name: 'rooms', @@ -405,7 +416,7 @@ void main() { expect(find.byType(LoginScreen), findsOneWidget); }); - testWidgets('explicit sign-out redirects to home', (tester) async { + testWidgets('explicit sign-out redirects to /signedout', (tester) async { final container = ProviderContainer( overrides: [ shellConfigProvider.overrideWithValue(testSoliplexConfig), @@ -437,12 +448,12 @@ void main() { expect(find.byType(RoomsScreen), findsOneWidget); - // Explicit sign-out → home (to choose different backend) + // Explicit sign-out → /signedout await (container.read(authProvider.notifier) as _ControllableAuthNotifier) .signOut(); await tester.pumpAndSettle(); - expect(find.byType(HomeScreen), findsOneWidget); + expect(find.byType(SignedOutScreen), findsOneWidget); }); testWidgets('token refresh preserves navigation location', (tester) async { @@ -536,6 +547,14 @@ void main() { expect(find.byType(LoginScreen), findsOneWidget); }); + testWidgets('authenticated user at /signedout redirects to /rooms', ( + tester, + ) async { + await tester.pumpWidget(createRouterAppAt('/signedout')); + await tester.pumpAndSettle(); + expect(find.byType(RoomsScreen), findsOneWidget); + }); + testWidgets('NoAuthRequired user at /login redirects to /rooms', ( tester, ) async { @@ -946,10 +965,10 @@ void main() { expect(find.byType(RoomsScreen), findsOneWidget); }); - testWidgets('sign-out redirects to /login regardless of config', ( + testWidgets('sign-out redirects to /signedout regardless of config', ( tester, ) async { - // Setup: showHomeRoute: false - verify sign-out goes to /login not / + // Setup: showHomeRoute: false - verify sign-out goes to /signedout final container = ProviderContainer( overrides: [ shellConfigProvider.overrideWithValue( @@ -991,8 +1010,8 @@ void main() { .signOut(); await tester.pumpAndSettle(); - // Expect: Redirects to /login (not / which would crash) - expect(find.byType(LoginScreen), findsOneWidget); + // Expect: Redirects to /signedout (dedicated post-logout page) + expect(find.byType(SignedOutScreen), findsOneWidget); }); testWidgets('no redirect loop when already on target route', ( diff --git a/test/features/auth/signed_out_screen_test.dart b/test/features/auth/signed_out_screen_test.dart new file mode 100644 index 00000000..9f58fc6f --- /dev/null +++ b/test/features/auth/signed_out_screen_test.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:soliplex_frontend/features/auth/signed_out_screen.dart'; + +import '../../helpers/test_helpers.dart'; + +Widget _createApp() { + final router = GoRouter( + initialLocation: '/signedout', + routes: [ + GoRoute( + path: '/signedout', + builder: (_, __) => const SignedOutScreen(), + ), + GoRoute(path: '/login', builder: (_, __) => const Text('Login')), + ], + ); + + return MaterialApp.router(theme: testThemeData, routerConfig: router); +} + +void main() { + group('SignedOutScreen', () { + testWidgets('renders heading text', (tester) async { + await tester.pumpWidget(_createApp()); + await tester.pumpAndSettle(); + + expect(find.text('You have been signed out'), findsOneWidget); + }); + + testWidgets('renders sign in button', (tester) async { + await tester.pumpWidget(_createApp()); + await tester.pumpAndSettle(); + + expect(find.text('Sign in again'), findsOneWidget); + }); + + testWidgets('renders logout icon', (tester) async { + await tester.pumpWidget(_createApp()); + await tester.pumpAndSettle(); + + expect(find.byIcon(Icons.logout), findsOneWidget); + }); + + testWidgets('tapping button navigates to /login', (tester) async { + await tester.pumpWidget(_createApp()); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Sign in again')); + await tester.pumpAndSettle(); + + expect(find.text('Login'), findsOneWidget); + expect(find.text('You have been signed out'), findsNothing); + }); + }); +}