Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion app/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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),
),
Expand All @@ -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;
},
);
}
Expand Down
21 changes: 17 additions & 4 deletions packages/core/lib/data/auth/auth_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ final cachedAuthFlagProvider = Provider<bool>((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<void> _cleanupFuture = Future.value();

@override
FutureOr<bool> build() async {
return await _repository.isUserLoggedIn();
Expand All @@ -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<void> loginWithGoogle() async {
await _cleanupFuture;
await _repository.loginWithGoogle();

state = const AsyncData(true);
Expand All @@ -89,6 +97,7 @@ class Auth extends _$Auth {
String? phone,
String? countryCode,
}) async {
await _cleanupFuture;
await _repository.register(
username: username,
email: email,
Expand Down Expand Up @@ -117,6 +126,7 @@ class Auth extends _$Auth {
required String phoneNumber,
String? email,
}) async {
await _cleanupFuture;
await _repository.verifyOtp(
otp: otp,
phoneNumber: phoneNumber,
Expand All @@ -127,6 +137,13 @@ class Auth extends _$Auth {
}

Future<void> 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);
Comment thread
syed-tp marked this conversation as resolved.
_cleanupFuture = _runCleanup();
}

Future<void> _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);
Expand All @@ -136,8 +153,6 @@ class Auth extends _$Auth {
await resetUseCase.execute();

await _repository.logout();

state = const AsyncData(false);
} catch (e, stackTrace) {
ref
.read(sentryServiceProvider)
Expand All @@ -146,8 +161,6 @@ class Auth extends _$Auth {
stackTrace: stackTrace,
level: AppErrorLevel.error,
);
state = const AsyncData(false);
rethrow;
}
}

Expand Down
7 changes: 4 additions & 3 deletions packages/core/lib/data/auth/auth_repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -117,12 +117,13 @@ class AuthRepository {

Future<void> 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();
}
}

Expand Down
96 changes: 96 additions & 0 deletions packages/core/test/data/auth/auth_provider_test.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import 'dart:async';

import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
Expand Down Expand Up @@ -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<void>();
final cleanupGate = Completer<void>();

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<void>();
final loginCalled = Completer<void>();

// 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<void>.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);
},
);
});
}
13 changes: 0 additions & 13 deletions packages/testpress/lib/navigation/app_router.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
},
),
),
],
);
},
Expand Down