From 54083097738fe0523223533057a953167b3449a0 Mon Sep 17 00:00:00 2001 From: Dmytro Serdun Date: Tue, 9 Jun 2026 10:52:26 +0300 Subject: [PATCH 1/4] fix: show account-not-found message on 404 logout (WT-956) When Core returns 404 (UserNotFoundException) for the current account it is now distinguished from a generic session expiry: the user gets a dedicated "account not found" notification and is logged out with a userNotFound reason that skips the doomed remote session revoke. - SessionGuard callbacks receive the unauthorized exception so the wiring can branch on its type - add AppLogoutReason.userNotFound; excluded from remote revoke in cleanup - add AccountNotFoundNotification + l10n keys (en/it/uk) --- .../notifications/models/notification.dart | 9 ++++++++ lib/app/router/main_shell.dart | 13 +++++++---- .../session/router_logout_session_guard.dart | 23 +++++++++++-------- lib/blocs/app/app_bloc.dart | 5 ++-- lib/blocs/app/app_event.dart | 5 ++++ lib/l10n/app_localizations.g.dart | 6 +++++ lib/l10n/app_localizations_en.g.dart | 4 ++++ lib/l10n/app_localizations_it.g.dart | 4 ++++ lib/l10n/app_localizations_uk.g.dart | 4 ++++ lib/l10n/arb/app_en.arb | 4 ++++ lib/l10n/arb/app_it.arb | 4 ++++ lib/l10n/arb/app_uk.arb | 4 ++++ 12 files changed, 69 insertions(+), 16 deletions(-) diff --git a/lib/app/notifications/models/notification.dart b/lib/app/notifications/models/notification.dart index 329de651e..b71b14f5b 100644 --- a/lib/app/notifications/models/notification.dart +++ b/lib/app/notifications/models/notification.dart @@ -143,6 +143,15 @@ class SessionExpiredNotification extends MessageNotification { } } +class AccountNotFoundNotification extends MessageNotification { + const AccountNotFoundNotification(); + + @override + String l10n(BuildContext context) { + return context.l10n.notifications_errorSnackBar_accountNotFound; + } +} + class DeleteAccountNotSupportedNotification extends MessageNotification { const DeleteAccountNotSupportedNotification(); diff --git a/lib/app/router/main_shell.dart b/lib/app/router/main_shell.dart index 02fee16af..1ad37ddef 100644 --- a/lib/app/router/main_shell.dart +++ b/lib/app/router/main_shell.dart @@ -107,11 +107,16 @@ class _MainShellState extends State with WidgetsBindingObserver { final notificationsBloc = context.read(); _sessionGuard = RouterLogoutSessionGuard( - performLogout: () { - _appBloc.add(const AppLogoutRequested(reason: AppLogoutReason.serverRejection)); + performLogout: (e) { + final reason = e is UserNotFoundException ? AppLogoutReason.userNotFound : AppLogoutReason.serverRejection; + _appBloc.add(AppLogoutRequested(reason: reason)); }, - onPreLogout: () { - notificationsBloc.add(NotificationsSubmitted(SessionExpiredNotification())); + onPreLogout: (e) { + notificationsBloc.add( + NotificationsSubmitted( + e is UserNotFoundException ? const AccountNotFoundNotification() : const SessionExpiredNotification(), + ), + ); }, ); } diff --git a/lib/app/session/router_logout_session_guard.dart b/lib/app/session/router_logout_session_guard.dart index a0f86bbde..36b5d7c9c 100644 --- a/lib/app/session/router_logout_session_guard.dart +++ b/lib/app/session/router_logout_session_guard.dart @@ -6,7 +6,7 @@ import 'package:webtrit_phone/common/common.dart'; import 'session_guard.dart'; -typedef AsyncVoidCallback = FutureOr Function(); +typedef SessionGuardCallback = FutureOr Function(Exception e); final _log = Logger('RouterLogoutSessionGuard'); @@ -26,11 +26,14 @@ class RouterLogoutSessionGuard implements SessionGuard, Disposable { RouterLogoutSessionGuard({required this.performLogout, this.onPreLogout}); /// Function that performs the actual logout (e.g. dispatching a logout event). - final AsyncVoidCallback performLogout; + /// Receives the unauthorized [Exception] so callers can tailor the logout + /// (e.g. distinguish an expired session from a deleted account). + final SessionGuardCallback performLogout; /// Optional hook executed before [performLogout]. - /// Useful for cleaning up resources or saving state. - final AsyncVoidCallback? onPreLogout; + /// Useful for cleaning up resources or saving state. Receives the same + /// unauthorized [Exception] passed to [performLogout]. + final SessionGuardCallback? onPreLogout; bool _handled = false; bool _disposed = false; @@ -50,8 +53,8 @@ class RouterLogoutSessionGuard implements SessionGuard, Disposable { _log.warning('Unauthorized access detected: ${e.toString()}'); - await _runHookSafe(); - await _runLogoutSafe(); + await _runHookSafe(e); + await _runLogoutSafe(e); }); } @@ -61,19 +64,19 @@ class RouterLogoutSessionGuard implements SessionGuard, Disposable { return true; } - Future _runHookSafe() async { + Future _runHookSafe(Exception e) async { final hook = onPreLogout; if (hook == null) return; try { - await hook(); + await hook(e); } catch (err, st) { _log.warning('onBeforeLogout failed', err, st); } } - Future _runLogoutSafe() async { + Future _runLogoutSafe(Exception e) async { try { - await performLogout(); + await performLogout(e); } catch (err, st) { _log.severe('logoutLocal failed', err, st); } diff --git a/lib/blocs/app/app_bloc.dart b/lib/blocs/app/app_bloc.dart index 7aa18db9d..8618d2327 100644 --- a/lib/blocs/app/app_bloc.dart +++ b/lib/blocs/app/app_bloc.dart @@ -131,8 +131,9 @@ class AppBloc extends Bloc { final currentSession = sessionRepository.getCurrent(); // Determine if we should attempt to revoke the session on the server. - // We skip this only for 'sessionMissed' because the socket error (4201) - // guarantees the session is already terminated. + // We skip this for 'sessionMissed' because the socket error (4201) + // guarantees the session is already terminated, and for 'userNotFound' + // because the account no longer exists (the revoke would just 404 again). final shouldRevokeRemote = reason == AppLogoutReason.userRequest || reason == AppLogoutReason.serverRejection; if (shouldRevokeRemote && currentSession.isLoggedIn) { diff --git a/lib/blocs/app/app_event.dart b/lib/blocs/app/app_event.dart index 29fe2c5d3..be8a14449 100644 --- a/lib/blocs/app/app_event.dart +++ b/lib/blocs/app/app_event.dart @@ -12,6 +12,11 @@ enum AppLogoutReason { /// The server explicitly rejected a request due to authentication. /// Requires both remote session revocation and local data cleanup. serverRejection, + + /// The account no longer exists on the server (404 UserNotFoundException), + /// e.g. it was deactivated or removed. Terminal; requires only local data + /// cleanup since the remote user record is already gone. + userNotFound, } sealed class AppEvent extends Equatable { diff --git a/lib/l10n/app_localizations.g.dart b/lib/l10n/app_localizations.g.dart index 44142edb6..d5a5485a6 100644 --- a/lib/l10n/app_localizations.g.dart +++ b/lib/l10n/app_localizations.g.dart @@ -2526,6 +2526,12 @@ abstract class AppLocalizations { /// **'Your session has expired. Please log in again.'** String get notifications_errorSnackBar_sessionExpired; + /// Shown in a notification or snackbar when the backend returns 404 (user not found) for the current account, e.g. it was deactivated or removed on the server. The user is logged out; advise them to contact their administrator. + /// + /// In en, this message translates to: + /// **'Your account was not found. It may have been deactivated or removed. Please contact your administrator.'** + String get notifications_errorSnackBar_accountNotFound; + /// Shown in a notification or snackbar when the app fails to connect to the WebTrit core and is attempting automatic reconnection. Context: occurs when the signaling/WebSocket connection cannot be established due to network outages, server unreachability, TLS/handshake failures, authentication errors (expired/invalid tokens), or firewall/VPN restrictions. Suggest the user check their network, retry, or reauthenticate if the problem persists. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.g.dart b/lib/l10n/app_localizations_en.g.dart index ee428dc57..436714f74 100644 --- a/lib/l10n/app_localizations_en.g.dart +++ b/lib/l10n/app_localizations_en.g.dart @@ -1350,6 +1350,10 @@ class AppLocalizationsEn extends AppLocalizations { @override String get notifications_errorSnackBar_sessionExpired => 'Your session has expired. Please log in again.'; + @override + String get notifications_errorSnackBar_accountNotFound => + 'Your account was not found. It may have been deactivated or removed. Please contact your administrator.'; + @override String get notifications_errorSnackBar_SignalingConnectFailed => 'Connecting to the core failed, trying to reconnect'; diff --git a/lib/l10n/app_localizations_it.g.dart b/lib/l10n/app_localizations_it.g.dart index a34e0a9fe..b21a4d4b7 100644 --- a/lib/l10n/app_localizations_it.g.dart +++ b/lib/l10n/app_localizations_it.g.dart @@ -1368,6 +1368,10 @@ class AppLocalizationsIt extends AppLocalizations { @override String get notifications_errorSnackBar_sessionExpired => 'La tua sessione è scaduta. Accedi di nuovo.'; + @override + String get notifications_errorSnackBar_accountNotFound => + 'Il tuo account non è stato trovato. Potrebbe essere stato disattivato o rimosso. Contatta il tuo amministratore.'; + @override String get notifications_errorSnackBar_SignalingConnectFailed => 'Connessione al server non riuscita, tentativo di riconnessione in corso'; diff --git a/lib/l10n/app_localizations_uk.g.dart b/lib/l10n/app_localizations_uk.g.dart index a5e7ad358..6a6caf815 100644 --- a/lib/l10n/app_localizations_uk.g.dart +++ b/lib/l10n/app_localizations_uk.g.dart @@ -1372,6 +1372,10 @@ class AppLocalizationsUk extends AppLocalizations { String get notifications_errorSnackBar_sessionExpired => 'Термін дії вашої сесії закінчився. Будь ласка, увійдіть знову.'; + @override + String get notifications_errorSnackBar_accountNotFound => + 'Ваш обліковий запис не знайдено. Можливо, його деактивовано або видалено. Зверніться до адміністратора.'; + @override String get notifications_errorSnackBar_SignalingConnectFailed => 'Підключення до ядра не вдалося, спроба з\'єднання'; diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index d7f9a97c7..397d025ad 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -1137,6 +1137,10 @@ "@notifications_errorSnackBar_sessionExpired": { "description": "Shown in a notification or snackbar when the user's authentication session has expired and re-authentication is required. Typical causes: access or refresh token expiration/revocation, failed token refresh, or the backend invalidating the session. Advise the user to sign in again to restore full functionality." }, + "notifications_errorSnackBar_accountNotFound": "Your account was not found. It may have been deactivated or removed. Please contact your administrator.", + "@notifications_errorSnackBar_accountNotFound": { + "description": "Shown in a notification or snackbar when the backend returns 404 (user not found) for the current account, e.g. it was deactivated or removed on the server. The user is logged out; advise them to contact their administrator." + }, "notifications_errorSnackBar_SignalingConnectFailed": "Connecting to the core failed, trying to reconnect", "@notifications_errorSnackBar_SignalingConnectFailed": { "description": "Shown in a notification or snackbar when the app fails to connect to the WebTrit core and is attempting automatic reconnection. Context: occurs when the signaling/WebSocket connection cannot be established due to network outages, server unreachability, TLS/handshake failures, authentication errors (expired/invalid tokens), or firewall/VPN restrictions. Suggest the user check their network, retry, or reauthenticate if the problem persists." diff --git a/lib/l10n/arb/app_it.arb b/lib/l10n/arb/app_it.arb index 89c2bb6b8..48ececd94 100644 --- a/lib/l10n/arb/app_it.arb +++ b/lib/l10n/arb/app_it.arb @@ -1137,6 +1137,10 @@ "@notifications_errorSnackBar_sessionExpired": { "description": "Shown in a notification or snackbar when the user's authentication session has expired and re-authentication is required. Typical causes: access or refresh token expiration/revocation, failed token refresh, or the backend invalidating the session. Advise the user to sign in again to restore full functionality." }, + "notifications_errorSnackBar_accountNotFound": "Il tuo account non è stato trovato. Potrebbe essere stato disattivato o rimosso. Contatta il tuo amministratore.", + "@notifications_errorSnackBar_accountNotFound": { + "description": "Shown in a notification or snackbar when the backend returns 404 (user not found) for the current account, e.g. it was deactivated or removed on the server. The user is logged out; advise them to contact their administrator." + }, "notifications_errorSnackBar_SignalingConnectFailed": "Connessione al server non riuscita, tentativo di riconnessione in corso", "@notifications_errorSnackBar_SignalingConnectFailed": { "description": "Shown in a notification or snackbar when the app fails to connect to the WebTrit core and is attempting automatic reconnection. Context: occurs when the signaling/WebSocket connection cannot be established due to network outages, server unreachability, TLS/handshake failures, authentication errors (expired/invalid tokens), or firewall/VPN restrictions. Suggest the user check their network, retry, or reauthenticate if the problem persists." diff --git a/lib/l10n/arb/app_uk.arb b/lib/l10n/arb/app_uk.arb index b1af8e1a5..7fed023d8 100644 --- a/lib/l10n/arb/app_uk.arb +++ b/lib/l10n/arb/app_uk.arb @@ -1137,6 +1137,10 @@ "@notifications_errorSnackBar_sessionExpired": { "description": "Shown in a notification or snackbar when the user's authentication session has expired and re-authentication is required. Typical causes: access or refresh token expiration/revocation, failed token refresh, or the backend invalidating the session. Advise the user to sign in again to restore full functionality." }, + "notifications_errorSnackBar_accountNotFound": "Ваш обліковий запис не знайдено. Можливо, його деактивовано або видалено. Зверніться до адміністратора.", + "@notifications_errorSnackBar_accountNotFound": { + "description": "Shown in a notification or snackbar when the backend returns 404 (user not found) for the current account, e.g. it was deactivated or removed on the server. The user is logged out; advise them to contact their administrator." + }, "notifications_errorSnackBar_SignalingConnectFailed": "Підключення до ядра не вдалося, спроба з'єднання", "@notifications_errorSnackBar_SignalingConnectFailed": { "description": "Shown in a notification or snackbar when the app fails to connect to the WebTrit core and is attempting automatic reconnection. Context: occurs when the signaling/WebSocket connection cannot be established due to network outages, server unreachability, TLS/handshake failures, authentication errors (expired/invalid tokens), or firewall/VPN restrictions. Suggest the user check their network, retry, or reauthenticate if the problem persists." From 0321e65640937bf5df45c71e54fe3b68882739f6 Mon Sep 17 00:00:00 2001 From: Dmytro Serdun Date: Tue, 9 Jun 2026 11:08:34 +0300 Subject: [PATCH 2/4] fix: show account-not-found reason on teardown screen (WT-956) For an AppLogoutReason.userNotFound logout, render the account-not-found message below the "Signing out..." label on TeardownScreen so the reason is visible during the logout transition, not only in the snackbar. --- .../session_status/view/teardown_screen.dart | 15 ++++++-- .../view/teardown_screen_test.dart | 34 +++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/lib/features/session_status/view/teardown_screen.dart b/lib/features/session_status/view/teardown_screen.dart index 8e7841536..05be4b61e 100644 --- a/lib/features/session_status/view/teardown_screen.dart +++ b/lib/features/session_status/view/teardown_screen.dart @@ -48,14 +48,25 @@ class _TeardownScreenState extends State { @override Widget build(BuildContext context) { + // Reason is still set while teardown renders (AppBloc clears it only at the + // end of cleanup, when this screen is already being replaced by login). + final reason = context.read().state.logoutReason; + return Scaffold( body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - CircularProgressIndicator(), - SizedBox(height: 16), + const CircularProgressIndicator(), + const SizedBox(height: 16), Text(context.l10n.session_Teardown_progressText), + if (reason == AppLogoutReason.userNotFound) ...[ + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Text(context.l10n.notifications_errorSnackBar_accountNotFound, textAlign: TextAlign.center), + ), + ], ], ), ), diff --git a/test/features/session_status/view/teardown_screen_test.dart b/test/features/session_status/view/teardown_screen_test.dart index c553c1c2b..fa9988f51 100644 --- a/test/features/session_status/view/teardown_screen_test.dart +++ b/test/features/session_status/view/teardown_screen_test.dart @@ -13,6 +13,8 @@ import 'package:webtrit_signaling_service_platform_interface/webtrit_signaling_s import 'package:webtrit_phone/blocs/app/app_bloc.dart'; import 'package:webtrit_phone/features/session_status/view/teardown_screen.dart'; import 'package:webtrit_phone/l10n/l10n.dart'; +import 'package:webtrit_phone/models/models.dart'; +import 'package:webtrit_phone/theme/theme.dart'; // --------------------------------------------------------------------------- // Fakes / Mocks @@ -20,6 +22,19 @@ import 'package:webtrit_phone/l10n/l10n.dart'; class _MockAppBloc extends MockBloc implements AppBloc {} +/// [AppState] requires a [ThemeSettings], but [TeardownScreen.build] never reads +/// it (only [AppState.logoutReason]), so a fake placeholder is enough. +class _FakeThemeSettings extends Fake implements ThemeSettings {} + +AppState _appState({AppLogoutReason? logoutReason}) => AppState( + logoutReason: logoutReason, + themeSettings: _FakeThemeSettings(), + themeMode: ThemeMode.system, + locale: const Locale('en'), + userAgreementStatus: AgreementStatus.accepted, + contactsAgreementStatus: AgreementStatus.accepted, +); + /// Fake platform that records the order in which operations are invoked. /// Shared [callLog] lets tests verify ordering across stopService and add(). class _FakePlatform extends Fake with MockPlatformInterfaceMixin implements SignalingServicePlatform { @@ -55,6 +70,7 @@ void main() { setUp(() { appBloc = _MockAppBloc(); + when(() => appBloc.state).thenReturn(_appState()); platform = _FakePlatform(); SignalingServicePlatform.instance = platform; }); @@ -85,5 +101,23 @@ void main() { expect(platform.callLog, ['stopService', 'AppCleanupRequested']); }); + + testWidgets('shows the account-not-found message when logoutReason is userNotFound', (tester) async { + when(() => appBloc.state).thenReturn(_appState(logoutReason: AppLogoutReason.userNotFound)); + + await tester.pumpWidget(_buildSubject(appBloc)); + + final context = tester.element(find.byType(TeardownScreen)); + expect(find.text(context.l10n.notifications_errorSnackBar_accountNotFound), findsOneWidget); + }); + + testWidgets('does not show the account-not-found message for other logout reasons', (tester) async { + when(() => appBloc.state).thenReturn(_appState(logoutReason: AppLogoutReason.userRequest)); + + await tester.pumpWidget(_buildSubject(appBloc)); + + final context = tester.element(find.byType(TeardownScreen)); + expect(find.text(context.l10n.notifications_errorSnackBar_accountNotFound), findsNothing); + }); }); } From c910aea11dfbffacd899833d293f29811ab19417 Mon Sep 17 00:00:00 2001 From: Dmytro Serdun Date: Tue, 9 Jun 2026 11:23:52 +0300 Subject: [PATCH 3/4] fix: show password-change-required reason on teardown screen (WT-956) When the signaling session drops and the cause is a self-care password change/expiry (403 password_change_required), log out with a dedicated reason and show the explanation on TeardownScreen instead of a transient snackbar. - resolve the signaling-session invalidation cause before logging out and carry it via SignalingSessionInvalidationReason (call layer stays free of app-level enums) - add AppLogoutReason.passwordChangeRequired (local cleanup only) - TeardownScreen renders the self-care password-expired message for it - drop the now-unused SelfCarePasswordExpiredNotification (snackbar) --- .../notifications/models/notification.dart | 9 ----- lib/app/router/main_shell.dart | 11 ++++-- lib/blocs/app/app_event.dart | 5 +++ lib/features/call/bloc/call_bloc.dart | 34 ++++++++++++++----- .../session_status/view/teardown_screen.dart | 9 +++-- .../view/teardown_screen_test.dart | 12 ++++++- 6 files changed, 58 insertions(+), 22 deletions(-) diff --git a/lib/app/notifications/models/notification.dart b/lib/app/notifications/models/notification.dart index b71b14f5b..a76c33977 100644 --- a/lib/app/notifications/models/notification.dart +++ b/lib/app/notifications/models/notification.dart @@ -125,15 +125,6 @@ class NoInternetConnectionNotification extends MessageNotification { } } -class SelfCarePasswordExpiredNotification extends MessageNotification { - const SelfCarePasswordExpiredNotification(); - - @override - String l10n(BuildContext context) { - return context.l10n.account_selfCarePasswordExpired_message; - } -} - class SessionExpiredNotification extends MessageNotification { const SessionExpiredNotification(); diff --git a/lib/app/router/main_shell.dart b/lib/app/router/main_shell.dart index 1ad37ddef..c90874a90 100644 --- a/lib/app/router/main_shell.dart +++ b/lib/app/router/main_shell.dart @@ -567,8 +567,15 @@ class _MainShellState extends State with WidgetsBindingObserver { signalingModule: _signalingModule, peerConnectionManager: peerConnectionManager, connectivityService: context.read(), - onSessionInvalidated: () => - appBloc.add(const AppLogoutRequested(reason: AppLogoutReason.sessionMissed)), + onSessionInvalidated: (reason) => appBloc.add( + AppLogoutRequested( + reason: switch (reason) { + SignalingSessionInvalidationReason.passwordChangeRequired => + AppLogoutReason.passwordChangeRequired, + SignalingSessionInvalidationReason.sessionMissed => AppLogoutReason.sessionMissed, + }, + ), + ), foregroundCallPushSignal: RemotePushBroker.pendingCallForegroundPushs, )..add(const CallStarted()); }, diff --git a/lib/blocs/app/app_event.dart b/lib/blocs/app/app_event.dart index be8a14449..5db7a836f 100644 --- a/lib/blocs/app/app_event.dart +++ b/lib/blocs/app/app_event.dart @@ -17,6 +17,11 @@ enum AppLogoutReason { /// e.g. it was deactivated or removed. Terminal; requires only local data /// cleanup since the remote user record is already gone. userNotFound, + + /// The self-care password expired or was changed (403 password_change_required), + /// detected after the signaling session was dropped. The server already + /// terminated the session, so only local cleanup is required. + passwordChangeRequired, } sealed class AppEvent extends Equatable { diff --git a/lib/features/call/bloc/call_bloc.dart b/lib/features/call/bloc/call_bloc.dart index a7ce00d36..cf323cf65 100644 --- a/lib/features/call/bloc/call_bloc.dart +++ b/lib/features/call/bloc/call_bloc.dart @@ -51,10 +51,22 @@ final _logger = Logger('CallBloc'); /// as parameters, allowing for detailed error logging or reporting. typedef OnDiagnosticReportRequested = void Function(String callId, CallkeepCallRequestError error); +/// Why the signaling session was invalidated, so the application layer can pick +/// a matching logout reason / user message without depending on app-level enums. +enum SignalingSessionInvalidationReason { + /// Generic: the session was missing/expired (signaling error 4201) with no + /// more specific account cause detected. + sessionMissed, + + /// The self-care password expired or was changed (403 password_change_required). + passwordChangeRequired, +} + /// Callback triggered when the signaling session is determined to be invalid /// (e.g., session revoked remotely, expired, or deleted), requiring a forced -/// application-level logout to resolve the state. -typedef SignalingSessionInvalidatedCallback = void Function(); +/// application-level logout to resolve the state. Carries the resolved +/// [SignalingSessionInvalidationReason] so the caller can tailor the logout. +typedef SignalingSessionInvalidatedCallback = void Function(SignalingSessionInvalidationReason reason); /// Resolves the final SIP `from` number for an outgoing call. /// @@ -481,24 +493,30 @@ class CallBloc extends Bloc with WidgetsBindingObserver im if (code == SignalingDisconnectCode.sessionMissedError) { _logger.info('Signaling session listener: session is missing ${current.lastSignalingDisconnectCode}'); - unawaited(_notifyAccountErrorSafely()); - onSessionInvalidated(); + unawaited(_invalidateSession()); } } } - // TODO: Consider moving this method to a separate repository - Future _notifyAccountErrorSafely() async { + /// Resolves why the session was invalidated and triggers the forced logout + /// with that reason, so the teardown UI can show a specific message. + Future _invalidateSession() async { + onSessionInvalidated(await _resolveInvalidationReason()); + } + + // TODO: Consider moving this probe to a separate repository + Future _resolveInvalidationReason() async { try { await userRepository.getRemoteInfo(); } on PasswordChangeRequiredException { - _logger.info('Account session revoked'); - submitNotification(const SelfCarePasswordExpiredNotification()); + _logger.info('Account session revoked: password change required'); + return SignalingSessionInvalidationReason.passwordChangeRequired; } on RequestFailure catch (e) { _logger.warning('Account error code: ${e.error?.code}'); } catch (e, st) { _logger.warning('Unexpected error during account info refresh', e, st); } + return SignalingSessionInvalidationReason.sessionMissed; } // diff --git a/lib/features/session_status/view/teardown_screen.dart b/lib/features/session_status/view/teardown_screen.dart index 05be4b61e..e8fe7b6f5 100644 --- a/lib/features/session_status/view/teardown_screen.dart +++ b/lib/features/session_status/view/teardown_screen.dart @@ -51,6 +51,11 @@ class _TeardownScreenState extends State { // Reason is still set while teardown renders (AppBloc clears it only at the // end of cleanup, when this screen is already being replaced by login). final reason = context.read().state.logoutReason; + final reasonText = switch (reason) { + AppLogoutReason.userNotFound => context.l10n.notifications_errorSnackBar_accountNotFound, + AppLogoutReason.passwordChangeRequired => context.l10n.account_selfCarePasswordExpired_message, + _ => null, + }; return Scaffold( body: Center( @@ -60,11 +65,11 @@ class _TeardownScreenState extends State { const CircularProgressIndicator(), const SizedBox(height: 16), Text(context.l10n.session_Teardown_progressText), - if (reason == AppLogoutReason.userNotFound) ...[ + if (reasonText != null) ...[ const SizedBox(height: 8), Padding( padding: const EdgeInsets.symmetric(horizontal: 32), - child: Text(context.l10n.notifications_errorSnackBar_accountNotFound, textAlign: TextAlign.center), + child: Text(reasonText, textAlign: TextAlign.center), ), ], ], diff --git a/test/features/session_status/view/teardown_screen_test.dart b/test/features/session_status/view/teardown_screen_test.dart index fa9988f51..021ae9b6a 100644 --- a/test/features/session_status/view/teardown_screen_test.dart +++ b/test/features/session_status/view/teardown_screen_test.dart @@ -111,13 +111,23 @@ void main() { expect(find.text(context.l10n.notifications_errorSnackBar_accountNotFound), findsOneWidget); }); - testWidgets('does not show the account-not-found message for other logout reasons', (tester) async { + testWidgets('shows the password-expired message when logoutReason is passwordChangeRequired', (tester) async { + when(() => appBloc.state).thenReturn(_appState(logoutReason: AppLogoutReason.passwordChangeRequired)); + + await tester.pumpWidget(_buildSubject(appBloc)); + + final context = tester.element(find.byType(TeardownScreen)); + expect(find.text(context.l10n.account_selfCarePasswordExpired_message), findsOneWidget); + }); + + testWidgets('does not show a reason message for other logout reasons', (tester) async { when(() => appBloc.state).thenReturn(_appState(logoutReason: AppLogoutReason.userRequest)); await tester.pumpWidget(_buildSubject(appBloc)); final context = tester.element(find.byType(TeardownScreen)); expect(find.text(context.l10n.notifications_errorSnackBar_accountNotFound), findsNothing); + expect(find.text(context.l10n.account_selfCarePasswordExpired_message), findsNothing); }); }); } From 6f48d3d1b2843aa69c60e22132fa561b20b9c441 Mon Sep 17 00:00:00 2001 From: Dmytro Serdun Date: Tue, 9 Jun 2026 12:31:35 +0300 Subject: [PATCH 4/4] refactor: address review feedback on WT-956 logout flow - avoid a second, conflicting AppLogoutRequested when the signaling probe hits an auth error the session guard already owns (return null -> skip onSessionInvalidated); guard them all with try/catch - extract the session-guard callbacks to private methods (single-expression callbacks per AGENTS.md) - fix stale "onBeforeLogout" log message -> "onPreLogout" - reorder teardown_screen_test imports into the 6-group convention --- lib/app/router/main_shell.dart | 33 ++++++++++++------- .../session/router_logout_session_guard.dart | 2 +- lib/features/call/bloc/call_bloc.dart | 19 +++++++++-- .../view/teardown_screen_test.dart | 7 ++-- 4 files changed, 43 insertions(+), 18 deletions(-) diff --git a/lib/app/router/main_shell.dart b/lib/app/router/main_shell.dart index c90874a90..e2098908e 100644 --- a/lib/app/router/main_shell.dart +++ b/lib/app/router/main_shell.dart @@ -50,6 +50,10 @@ class _MainShellState extends State with WidgetsBindingObserver { /// reading from a potentially deactivated [BuildContext]. late final AppBloc _appBloc; + /// Captured in [initState] so the session-guard callbacks can submit + /// notifications without touching a possibly-deactivated [BuildContext]. + late final NotificationsBloc _notificationsBloc; + /// Created and connected in [initState] so that the WebSocket handshake /// runs in parallel while the widget tree and [CallBloc] are being built. /// Late subscribers (including [CallBloc]) receive all buffered session @@ -104,23 +108,28 @@ class _MainShellState extends State with WidgetsBindingObserver { startPendingTimeout: kSignalingStartPendingTimeout, )..connect(); - final notificationsBloc = context.read(); + _notificationsBloc = context.read(); _sessionGuard = RouterLogoutSessionGuard( - performLogout: (e) { - final reason = e is UserNotFoundException ? AppLogoutReason.userNotFound : AppLogoutReason.serverRejection; - _appBloc.add(AppLogoutRequested(reason: reason)); - }, - onPreLogout: (e) { - notificationsBloc.add( - NotificationsSubmitted( - e is UserNotFoundException ? const AccountNotFoundNotification() : const SessionExpiredNotification(), - ), - ); - }, + performLogout: _onSessionGuardLogout, + onPreLogout: _onSessionGuardPreLogout, ); } + /// Maps an unauthorized [Exception] to a logout reason and triggers logout. + void _onSessionGuardLogout(Exception e) { + final reason = e is UserNotFoundException ? AppLogoutReason.userNotFound : AppLogoutReason.serverRejection; + _appBloc.add(AppLogoutRequested(reason: reason)); + } + + /// Surfaces a reason-specific notification before the guard logs the user out. + void _onSessionGuardPreLogout(Exception e) { + final notification = e is UserNotFoundException + ? const AccountNotFoundNotification() + : const SessionExpiredNotification(); + _notificationsBloc.add(NotificationsSubmitted(notification)); + } + @override void dispose() { _disposeSessionGuard(); diff --git a/lib/app/session/router_logout_session_guard.dart b/lib/app/session/router_logout_session_guard.dart index 36b5d7c9c..6d495f134 100644 --- a/lib/app/session/router_logout_session_guard.dart +++ b/lib/app/session/router_logout_session_guard.dart @@ -70,7 +70,7 @@ class RouterLogoutSessionGuard implements SessionGuard, Disposable { try { await hook(e); } catch (err, st) { - _log.warning('onBeforeLogout failed', err, st); + _log.warning('onPreLogout failed', err, st); } } diff --git a/lib/features/call/bloc/call_bloc.dart b/lib/features/call/bloc/call_bloc.dart index cf323cf65..e6279f193 100644 --- a/lib/features/call/bloc/call_bloc.dart +++ b/lib/features/call/bloc/call_bloc.dart @@ -501,16 +501,31 @@ class CallBloc extends Bloc with WidgetsBindingObserver im /// Resolves why the session was invalidated and triggers the forced logout /// with that reason, so the teardown UI can show a specific message. Future _invalidateSession() async { - onSessionInvalidated(await _resolveInvalidationReason()); + try { + final reason = await _resolveInvalidationReason(); + // A null reason means the probe hit an auth error the session guard + // already owns (it dispatches its own, more specific logout) - avoid a + // second, conflicting AppLogoutRequested. + if (reason != null) onSessionInvalidated(reason); + } catch (e, st) { + _logger.warning('Session invalidation handling failed', e, st); + } } // TODO: Consider moving this probe to a separate repository - Future _resolveInvalidationReason() async { + Future _resolveInvalidationReason() async { try { await userRepository.getRemoteInfo(); } on PasswordChangeRequiredException { _logger.info('Account session revoked: password change required'); return SignalingSessionInvalidationReason.passwordChangeRequired; + } on UnauthorizedException catch (_) { + // Handled by the session guard inside getInfo(); it drives the logout. + return null; + } on UserNotFoundException catch (_) { + return null; + } on SessionMissingException catch (_) { + return null; } on RequestFailure catch (e) { _logger.warning('Account error code: ${e.error?.code}'); } catch (e, st) { diff --git a/test/features/session_status/view/teardown_screen_test.dart b/test/features/session_status/view/teardown_screen_test.dart index 021ae9b6a..a353777cd 100644 --- a/test/features/session_status/view/teardown_screen_test.dart +++ b/test/features/session_status/view/teardown_screen_test.dart @@ -1,11 +1,12 @@ -import 'package:bloc_test/bloc_test.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:mocktail/mocktail.dart'; +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:mocktail/mocktail.dart'; // ignore: depend_on_referenced_packages import 'package:plugin_platform_interface/plugin_platform_interface.dart' show MockPlatformInterfaceMixin; + // ignore: depend_on_referenced_packages import 'package:webtrit_signaling_service_platform_interface/webtrit_signaling_service_platform_interface.dart' show SignalingServicePlatform;