diff --git a/lib/app/notifications/models/notification.dart b/lib/app/notifications/models/notification.dart index 329de651e..a76c33977 100644 --- a/lib/app/notifications/models/notification.dart +++ b/lib/app/notifications/models/notification.dart @@ -125,21 +125,21 @@ class NoInternetConnectionNotification extends MessageNotification { } } -class SelfCarePasswordExpiredNotification extends MessageNotification { - const SelfCarePasswordExpiredNotification(); +class SessionExpiredNotification extends MessageNotification { + const SessionExpiredNotification(); @override String l10n(BuildContext context) { - return context.l10n.account_selfCarePasswordExpired_message; + return context.l10n.notifications_errorSnackBar_sessionExpired; } } -class SessionExpiredNotification extends MessageNotification { - const SessionExpiredNotification(); +class AccountNotFoundNotification extends MessageNotification { + const AccountNotFoundNotification(); @override String l10n(BuildContext context) { - return context.l10n.notifications_errorSnackBar_sessionExpired; + return context.l10n.notifications_errorSnackBar_accountNotFound; } } diff --git a/lib/app/router/main_shell.dart b/lib/app/router/main_shell.dart index 02fee16af..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,18 +108,28 @@ class _MainShellState extends State with WidgetsBindingObserver { startPendingTimeout: kSignalingStartPendingTimeout, )..connect(); - final notificationsBloc = context.read(); + _notificationsBloc = context.read(); _sessionGuard = RouterLogoutSessionGuard( - performLogout: () { - _appBloc.add(const AppLogoutRequested(reason: AppLogoutReason.serverRejection)); - }, - onPreLogout: () { - notificationsBloc.add(NotificationsSubmitted(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(); @@ -562,8 +576,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/app/session/router_logout_session_guard.dart b/lib/app/session/router_logout_session_guard.dart index a0f86bbde..6d495f134 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); + _log.warning('onPreLogout 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..5db7a836f 100644 --- a/lib/blocs/app/app_event.dart +++ b/lib/blocs/app/app_event.dart @@ -12,6 +12,16 @@ 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, + + /// 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..e6279f193 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,45 @@ 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 { + 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 { 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 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) { _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 8e7841536..e8fe7b6f5 100644 --- a/lib/features/session_status/view/teardown_screen.dart +++ b/lib/features/session_status/view/teardown_screen.dart @@ -48,14 +48,30 @@ 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; + final reasonText = switch (reason) { + AppLogoutReason.userNotFound => context.l10n.notifications_errorSnackBar_accountNotFound, + AppLogoutReason.passwordChangeRequired => context.l10n.account_selfCarePasswordExpired_message, + _ => null, + }; + 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 (reasonText != null) ...[ + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Text(reasonText, textAlign: TextAlign.center), + ), + ], ], ), ), 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." diff --git a/test/features/session_status/view/teardown_screen_test.dart b/test/features/session_status/view/teardown_screen_test.dart index c553c1c2b..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; @@ -13,6 +14,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 +23,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 +71,7 @@ void main() { setUp(() { appBloc = _MockAppBloc(); + when(() => appBloc.state).thenReturn(_appState()); platform = _FakePlatform(); SignalingServicePlatform.instance = platform; }); @@ -85,5 +102,33 @@ 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('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); + }); }); }