Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
12 changes: 6 additions & 6 deletions lib/app/notifications/models/notification.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}

Expand Down
24 changes: 18 additions & 6 deletions lib/app/router/main_shell.dart
Original file line number Diff line number Diff line change
Expand Up @@ -107,11 +107,16 @@ class _MainShellState extends State<MainShell> with WidgetsBindingObserver {
final notificationsBloc = context.read<NotificationsBloc>();

_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));
},
Comment thread
SERDUN marked this conversation as resolved.
Outdated
onPreLogout: () {
notificationsBloc.add(NotificationsSubmitted(SessionExpiredNotification()));
onPreLogout: (e) {
Comment thread
SERDUN marked this conversation as resolved.
Outdated
notificationsBloc.add(
NotificationsSubmitted(
e is UserNotFoundException ? const AccountNotFoundNotification() : const SessionExpiredNotification(),
),
);
},
);
}
Expand Down Expand Up @@ -562,8 +567,15 @@ class _MainShellState extends State<MainShell> with WidgetsBindingObserver {
signalingModule: _signalingModule,
peerConnectionManager: peerConnectionManager,
connectivityService: context.read<ConnectivityService>(),
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());
},
Expand Down
23 changes: 13 additions & 10 deletions lib/app/session/router_logout_session_guard.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import 'package:webtrit_phone/common/common.dart';

import 'session_guard.dart';

typedef AsyncVoidCallback = FutureOr<void> Function();
typedef SessionGuardCallback = FutureOr<void> Function(Exception e);

final _log = Logger('RouterLogoutSessionGuard');

Expand All @@ -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;
Expand All @@ -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);
});
}

Expand All @@ -61,19 +64,19 @@ class RouterLogoutSessionGuard implements SessionGuard, Disposable {
return true;
}

Future<void> _runHookSafe() async {
Future<void> _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);
Comment thread
Copilot marked this conversation as resolved.
Outdated
}
}

Future<void> _runLogoutSafe() async {
Future<void> _runLogoutSafe(Exception e) async {
try {
await performLogout();
await performLogout(e);
} catch (err, st) {
_log.severe('logoutLocal failed', err, st);
}
Expand Down
5 changes: 3 additions & 2 deletions lib/blocs/app/app_bloc.dart
Original file line number Diff line number Diff line change
Expand Up @@ -131,8 +131,9 @@ class AppBloc extends Bloc<AppEvent, AppState> {
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) {
Expand Down
10 changes: 10 additions & 0 deletions lib/blocs/app/app_event.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
34 changes: 26 additions & 8 deletions lib/features/call/bloc/call_bloc.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand Down Expand Up @@ -481,24 +493,30 @@ class CallBloc extends Bloc<CallEvent, CallState> 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<void> _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<void> _invalidateSession() async {
onSessionInvalidated(await _resolveInvalidationReason());
}
Comment thread
SERDUN marked this conversation as resolved.

// TODO: Consider moving this probe to a separate repository
Future<SignalingSessionInvalidationReason> _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;
}

//
Expand Down
20 changes: 18 additions & 2 deletions lib/features/session_status/view/teardown_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -48,14 +48,30 @@ class _TeardownScreenState extends State<TeardownScreen> {

@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<AppBloc>().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),
),
],
],
),
),
Expand Down
6 changes: 6 additions & 0 deletions lib/l10n/app_localizations.g.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 4 additions & 0 deletions lib/l10n/app_localizations_en.g.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
4 changes: 4 additions & 0 deletions lib/l10n/app_localizations_it.g.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
4 changes: 4 additions & 0 deletions lib/l10n/app_localizations_uk.g.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 => 'Підключення до ядра не вдалося, спроба з\'єднання';

Expand Down
4 changes: 4 additions & 0 deletions lib/l10n/arb/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
4 changes: 4 additions & 0 deletions lib/l10n/arb/app_it.arb
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
4 changes: 4 additions & 0 deletions lib/l10n/arb/app_uk.arb
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
Loading
Loading