diff --git a/app/lib/main.dart b/app/lib/main.dart index 467efab5..0dc9d49c 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -65,6 +65,7 @@ class CortexApp extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final design = Design.of(context); final languageAsync = ref.watch(appLanguageSettingsNotifierProvider); + final sessionExpiredMessage = ref.watch(sessionExpiredProvider); final locale = languageAsync.maybeWhen( data: (settings) => settings.languageCode == 'system' @@ -112,7 +113,7 @@ class CortexApp extends ConsumerWidget { builder: (context, child) { final originalData = MediaQuery.of(context); final systemScale = originalData.textScaler.scale(1.0); - return MediaQuery( + final scaled = MediaQuery( data: originalData.copyWith( textScaler: TextScaler.linear(systemScale * scaleMultiplier), ), @@ -124,6 +125,28 @@ class CortexApp extends ConsumerWidget { child: child ?? const SizedBox.shrink(), ), ); + + // Session expired overlay is placed here — above the Navigator — + // so it is never part of any route transition and cannot overlap + // the incoming Login screen. + if (sessionExpiredMessage != null) { + return Stack( + children: [ + scaled, + Positioned.fill( + child: SessionExpiredDialog( + message: sessionExpiredMessage, + onSignIn: () { + ref.read(sessionExpiredProvider.notifier).state = null; + ref.read(authProvider.notifier).logout(); + }, + ), + ), + ], + ); + } + + return scaled; }, ); } diff --git a/packages/core/lib/data/auth/auth_provider.dart b/packages/core/lib/data/auth/auth_provider.dart index b6d5b060..b9e09860 100644 --- a/packages/core/lib/data/auth/auth_provider.dart +++ b/packages/core/lib/data/auth/auth_provider.dart @@ -62,6 +62,10 @@ final cachedAuthFlagProvider = Provider((ref) => false); class Auth extends _$Auth { AuthRepository get _repository => ref.read(authRepositoryProvider); + /// Tracks the in-flight logout cleanup so login methods can wait for it + /// before writing new session data to the DB. + Future _cleanupFuture = Future.value(); + @override FutureOr build() async { return await _repository.isUserLoggedIn(); @@ -71,12 +75,16 @@ class Auth extends _$Auth { required String username, required String password, }) async { + // Wait for any in-flight logout cleanup to finish before writing + // new session data — prevents stale cleanup from wiping a fresh login. + await _cleanupFuture; await _repository.loginWithPassword(username: username, password: password); state = const AsyncData(true); } Future loginWithGoogle() async { + await _cleanupFuture; await _repository.loginWithGoogle(); state = const AsyncData(true); @@ -89,6 +97,7 @@ class Auth extends _$Auth { String? phone, String? countryCode, }) async { + await _cleanupFuture; await _repository.register( username: username, email: email, @@ -117,6 +126,7 @@ class Auth extends _$Auth { required String phoneNumber, String? email, }) async { + await _cleanupFuture; await _repository.verifyOtp( otp: otp, phoneNumber: phoneNumber, @@ -127,6 +137,13 @@ class Auth extends _$Auth { } Future logout() async { + // Flip auth state immediately — router redirects to Login on this frame. + // Store the cleanup work in _cleanupFuture so login methods can gate on it. + state = const AsyncData(false); + _cleanupFuture = _runCleanup(); + } + + Future _runCleanup() async { try { // Safety net: explicitly clear the user row to guarantee no stale data leaks if the full purge fails final userRepo = await ref.read(userRepositoryProvider.future); @@ -136,8 +153,6 @@ class Auth extends _$Auth { await resetUseCase.execute(); await _repository.logout(); - - state = const AsyncData(false); } catch (e, stackTrace) { ref .read(sentryServiceProvider) @@ -146,8 +161,6 @@ class Auth extends _$Auth { stackTrace: stackTrace, level: AppErrorLevel.error, ); - state = const AsyncData(false); - rethrow; } } diff --git a/packages/core/lib/data/auth/auth_repository.dart b/packages/core/lib/data/auth/auth_repository.dart index 6f40656c..071f92ce 100644 --- a/packages/core/lib/data/auth/auth_repository.dart +++ b/packages/core/lib/data/auth/auth_repository.dart @@ -117,12 +117,13 @@ class AuthRepository { Future logout() async { final token = await _localDataSource.getToken(); + await _clearToken(); try { - await _apiService.logout(authToken: token); + if (token != null && token.isNotEmpty) { + await _apiService.logout(authToken: token); + } } catch (_) { // Still logout locally if API fails - } finally { - await _clearToken(); } } diff --git a/packages/core/test/data/auth/auth_provider_test.dart b/packages/core/test/data/auth/auth_provider_test.dart index 9089a695..f0fe742c 100644 --- a/packages/core/test/data/auth/auth_provider_test.dart +++ b/packages/core/test/data/auth/auth_provider_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; @@ -99,12 +101,106 @@ void main() { expect(container.read(authProvider).value, isTrue); // Act + // logout() fires cleanup in the background — drain the event queue so + // all async hops in _runCleanup() complete before verifying mock calls. await container.read(authProvider.notifier).logout(); + await pumpEventQueue(); // Assert expect(container.read(authProvider).value, isFalse); verify(mockResetUseCase.execute()).called(1); verify(mockRepository.logout()).called(1); }); + + // Regression: auth state must flip to false synchronously on logout(), + // before any cleanup awaits, so the router redirects on the same frame. + test( + 'logout flips state to false synchronously before cleanup completes', + () async { + // Arrange + when(mockRepository.isUserLoggedIn()).thenAnswer((_) async => true); + await container.read(authProvider.future); + + final cleanupStarted = Completer(); + final cleanupGate = Completer(); + + when(mockUserRepo.clearCurrentUser()).thenAnswer((_) async { + cleanupStarted.complete(); + await cleanupGate.future; // block cleanup mid-flight + }); + + // Act — fire logout but don't await it; it returns immediately after + // flipping state, while cleanup is still blocked above. + final logoutFuture = container.read(authProvider.notifier).logout(); + + // Wait until cleanup has actually started (proving it's in-flight) + await cleanupStarted.future; + + // Assert — state is already false even though cleanup hasn't finished + expect( + container.read(authProvider).value, + isFalse, + reason: + 'auth state must flip synchronously so the router can redirect ' + 'on the same frame, before cleanup completes', + ); + + // Unblock cleanup and finish the logout + cleanupGate.complete(); + await logoutFuture; + }, + ); + + // Regression: a login attempted while logout cleanup is still running + // must not proceed until cleanup finishes — prevents stale cleanup from + // wiping the new session's freshly-written user row or cached data. + test( + 'loginWithPassword waits for in-flight logout cleanup before writing', + () async { + // Arrange + when(mockRepository.isUserLoggedIn()).thenAnswer((_) async => true); + await container.read(authProvider.future); + + final cleanupGate = Completer(); + final loginCalled = Completer(); + + // Block cleanup mid-flight + when(mockUserRepo.clearCurrentUser()).thenAnswer((_) async { + await cleanupGate.future; + }); + + // loginWithPassword should not be called until cleanup gate opens + when( + mockRepository.loginWithPassword(username: 'user', password: 'pass'), + ).thenAnswer((_) async { + loginCalled.complete(); + }); + + // Act — logout (cleanup blocked), then immediately attempt login + final logoutFuture = container.read(authProvider.notifier).logout(); + final loginFuture = container + .read(authProvider.notifier) + .loginWithPassword(username: 'user', password: 'pass'); + + // Give a short window — login must NOT have proceeded yet + await Future.delayed(const Duration(milliseconds: 50)); + expect( + loginCalled.isCompleted, + isFalse, + reason: + 'loginWithPassword must not write data while cleanup is still ' + 'in-flight', + ); + + // Unblock cleanup + cleanupGate.complete(); + await logoutFuture; + + // Now login should complete + await loginFuture; + expect(loginCalled.isCompleted, isTrue); + expect(container.read(authProvider).value, isTrue); + }, + ); }); } diff --git a/packages/testpress/lib/navigation/app_router.dart b/packages/testpress/lib/navigation/app_router.dart index 71efa8f3..1133e6c2 100644 --- a/packages/testpress/lib/navigation/app_router.dart +++ b/packages/testpress/lib/navigation/app_router.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:flutter/widgets.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'routes.dart'; import 'package:core/core.dart'; @@ -117,7 +116,6 @@ class _AppShellBuilder extends ConsumerWidget { final items = activeTabs.map((tab) => tab.toTabItem(settings)).toList(); final isLogoutSheetOpen = ref.watch(isLogoutSheetOpenProvider); final activeTabId = allTabs[navigationShell.currentIndex].id; - final sessionExpiredMessage = ref.watch(sessionExpiredProvider); void closeSheet() => ref.read(isLogoutSheetOpenProvider.notifier).state = false; @@ -155,17 +153,6 @@ class _AppShellBuilder extends ConsumerWidget { ), child: navigationShell, ), - // Session expired overlay — shown above all content when a 401 fires - if (sessionExpiredMessage != null) - Positioned.fill( - child: SessionExpiredDialog( - message: sessionExpiredMessage, - onSignIn: () async { - await ref.read(authProvider.notifier).logout(); - ref.read(sessionExpiredProvider.notifier).state = null; - }, - ), - ), ], ); },