diff --git a/lib/app.dart b/lib/app.dart index 78e0a5412..a96ced3b9 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -233,8 +233,7 @@ class _LinksysAppState extends ConsumerState } void _tryResumeSse() { - final loginType = ref.read(authProvider).value?.loginType; - if (loginType == null || loginType == LoginType.none) return; + if (!(ref.read(authProvider).value?.isLoggedIn ?? false)) return; final sseManager = ref.read(sseManagerProvider); if (sseManager == null) return; final sseState = sseManager.connection.connectionState.value; diff --git a/lib/components/layouts/root_container.dart b/lib/components/layouts/root_container.dart index b94346dea..352b307fa 100644 --- a/lib/components/layouts/root_container.dart +++ b/lib/components/layouts/root_container.dart @@ -45,7 +45,7 @@ class _AppRootContainerState extends ConsumerState { return; } // not log in yet - if (ref.read(authProvider).value?.loginType == LoginType.none) { + if (!(ref.read(authProvider).value?.isLoggedIn ?? false)) { return; } // not go into dashboard yet diff --git a/lib/components/styled/general_settings_widget/general_settings_widget.dart b/lib/components/styled/general_settings_widget/general_settings_widget.dart index ce9010025..3270badcf 100644 --- a/lib/components/styled/general_settings_widget/general_settings_widget.dart +++ b/lib/components/styled/general_settings_widget/general_settings_widget.dart @@ -25,9 +25,8 @@ class GeneralSettingsWidget extends ConsumerStatefulWidget { class _GeneralSettingsWidgetState extends ConsumerState { @override Widget build(BuildContext context) { - final loginType = - ref.watch(authProvider.select((state) => state.value?.loginType)) ?? - LoginType.none; + final isLoggedIn = ref.watch( + authProvider.select((state) => state.value?.isLoggedIn ?? false)); // Watch Theme.of(context) to trigger rebuild when global theme changes Theme.of(context); @@ -110,8 +109,7 @@ class _GeneralSettingsWidgetState extends ConsumerState { ), // Legal links and logout (hidden in remote mode) - if (!GlobalConfig.remote.isActive && - loginType != LoginType.none) ...[ + if (!GlobalConfig.remote.isActive && isLoggedIn) ...[ AppGap.md(), const AppDivider(), AppGap.md(), diff --git a/lib/core/connection/providers/app_connection_state_provider.dart b/lib/core/connection/providers/app_connection_state_provider.dart index 5df20194b..4630ebb82 100644 --- a/lib/core/connection/providers/app_connection_state_provider.dart +++ b/lib/core/connection/providers/app_connection_state_provider.dart @@ -63,8 +63,7 @@ class AppConnectionStateNotifier extends Notifier { ref.listen(authProvider, (_, next) { if (next.isLoading) return; - final loginType = next.value?.loginType; - if (loginType == null || loginType == LoginType.none) { + if (!(next.value?.isLoggedIn ?? false)) { _probeTimer?.cancel(); _probeTimer = null; _cooldownTimer?.cancel(); diff --git a/lib/page/dashboard/orchestrator/dashboard_orchestrator.dart b/lib/page/dashboard/orchestrator/dashboard_orchestrator.dart index 25b824373..9ae3ebb23 100644 --- a/lib/page/dashboard/orchestrator/dashboard_orchestrator.dart +++ b/lib/page/dashboard/orchestrator/dashboard_orchestrator.dart @@ -129,8 +129,8 @@ class DashboardOrchestrator extends AsyncNotifier { // Remote Assistance mode: client is pre-authenticated via authToken, // skip local USP auth check. - final loginType = ref.read(authProvider).value?.loginType; - final isRemoteAssistance = loginType == LoginType.remote; + final isRemoteAssistance = + ref.read(authProvider).value?.isRemoteAssistance ?? false; // On page reload WASM state is lost — attempt session restore (local only). // Use isRecovering: true because we handle auth failure via NotAuthenticatedError diff --git a/lib/page/internet_settings/providers/usp_internet_settings_notifier.dart b/lib/page/internet_settings/providers/usp_internet_settings_notifier.dart index ef16135b1..93d9aabed 100644 --- a/lib/page/internet_settings/providers/usp_internet_settings_notifier.dart +++ b/lib/page/internet_settings/providers/usp_internet_settings_notifier.dart @@ -85,10 +85,12 @@ class UspInternetSettingsNotifier detail: 'USP service not available'); } - if (!usp.isAuthenticated) { - throw const ConnectivityError(detail: 'USP not authenticated'); - } - + // No auth gate here: the router already guards all /usp routes on + // loginType (RA-aware), and the WAN service surfaces real errors. The raw + // usp.isAuthenticated flag is a WASM transport signal that stays false in + // Remote Assistance (authToken bypass), so gating on it here wrongly + // blocked RA sessions (issue #1119). The usp == null gate above (and the + // service provider itself) still cover the "USP unavailable" case. final service = ref.read(uspInternetSettingsServiceProvider); final result = await service.fetchSettings(); diff --git a/lib/page/login/views/login_local_view.dart b/lib/page/login/views/login_local_view.dart index d11eb5c5e..4c71f7487 100644 --- a/lib/page/login/views/login_local_view.dart +++ b/lib/page/login/views/login_local_view.dart @@ -107,8 +107,7 @@ class _LoginViewState extends ConsumerState { previous.isLoading && next.hasValue && !next.hasError) { - final loginType = next.value?.loginType; - if (loginType != null && loginType != LoginType.none) { + if (next.value?.isLoggedIn ?? false) { if (!context.mounted) return; context.go('/'); } diff --git a/lib/page/remote_assistance/views/remote_assistance_session_guard.dart b/lib/page/remote_assistance/views/remote_assistance_session_guard.dart index d753f18a0..6c9a8e58b 100644 --- a/lib/page/remote_assistance/views/remote_assistance_session_guard.dart +++ b/lib/page/remote_assistance/views/remote_assistance_session_guard.dart @@ -62,8 +62,7 @@ class _RemoteAssistanceSessionGuardState Future _checkAndRestoreSession() async { // Skip for Remote Assistance mode (CA side) - double check - final loginType = ref.read(authProvider).value?.loginType; - if (loginType == LoginType.remote) return; + if (ref.read(authProvider).value?.isRemoteAssistance ?? false) return; // Get device credentials from unified provider final credentials = ref.read(deviceCredentialsProvider); diff --git a/lib/page/shell/usp_top_bar.dart b/lib/page/shell/usp_top_bar.dart index 45895b3d5..786425c3d 100644 --- a/lib/page/shell/usp_top_bar.dart +++ b/lib/page/shell/usp_top_bar.dart @@ -70,9 +70,8 @@ class _UspTopBarState extends ConsumerState with DebugObserver { Row( mainAxisSize: MainAxisSize.min, children: [ - if (ref.watch(authProvider.select((v) => - v.value?.loginType != null && - v.value?.loginType != LoginType.none)) && + if (ref.watch(authProvider + .select((v) => v.value?.isLoggedIn ?? false)) && (ref.watch(appsCapabilityProvider).valueOrNull ?? false)) Tooltip( diff --git a/lib/page/wifi_settings/providers/usp_wifi_settings_provider.dart b/lib/page/wifi_settings/providers/usp_wifi_settings_provider.dart index b63e899de..cbe1e5313 100644 --- a/lib/page/wifi_settings/providers/usp_wifi_settings_provider.dart +++ b/lib/page/wifi_settings/providers/usp_wifi_settings_provider.dart @@ -75,15 +75,12 @@ class UspWifiSettingsNotifier extends AutoDisposeNotifier ); } - if (!usp.isAuthenticated) { - return ( - null, - WifiSettingsStatus( - error: const NotAuthenticatedError(detail: 'USP not authenticated'), - ) - ); - } - + // No auth gate here: the router already guards all /usp routes on + // loginType (RA-aware), and the WiFi data layer surfaces real errors. The + // raw usp.isAuthenticated flag is a WASM transport signal that stays false + // in Remote Assistance (authToken bypass), so gating on it here wrongly + // blocked RA sessions (issue #1119). The usp == null gate above still + // covers the "USP unavailable" case. logger.d('[USP][WiFi]: Fetching WiFi data...'); // Read from WiFi Data Provider (Layer 1) to avoid duplicate fetch. diff --git a/lib/providers/auth/auth_state.dart b/lib/providers/auth/auth_state.dart index c1d57a23e..52738dac6 100644 --- a/lib/providers/auth/auth_state.dart +++ b/lib/providers/auth/auth_state.dart @@ -14,7 +14,13 @@ class AuthState extends Equatable { /// Password hint for the local router admin password. final String? localPasswordHint; - /// Current login type indicating authentication method. + /// The single source of truth for login state: `none` / `local` / `remote`. + /// + /// The [isLoggedIn] and [isRemoteAssistance] getters are just named shortcuts + /// derived from this value for the two most common yes/no questions — they are + /// not a separate mechanism. Read [loginType] directly only when the full + /// three-state value is needed (e.g. detecting a transition between kinds, or + /// branching specifically on `local`); prefer the getters otherwise. final LoginType loginType; const AuthState({ @@ -27,6 +33,24 @@ class AuthState extends Equatable { return const AuthState(loginType: LoginType.none); } + /// Whether the user is logged in (local OR Remote Assistance). + /// + /// Shortcut for `loginType != LoginType.none`. This is login *intent* — + /// distinct from the transport-layer [UspClient.isAuthenticated] (a WASM flag + /// that stays false in RA mode). Canonical replacement for scattered inline + /// `loginType != LoginType.none` checks; use [isRemoteAssistance] when the + /// login *kind* matters. + bool get isLoggedIn => loginType != LoginType.none; + + /// Whether this is a Remote Assistance session. + /// + /// Shortcut for `loginType == LoginType.remote`. In RA mode the WASM client + /// is pre-authorized via authToken, so [UspClient.isAuthenticated] stays false + /// by design — RA must be detected from login intent (this getter), not from + /// the client's login state. Canonical replacement for scattered inline + /// `loginType == LoginType.remote` checks. + bool get isRemoteAssistance => loginType == LoginType.remote; + /// Creates an [AuthState] from a JSON map. factory AuthState.fromJson(Map json) { final loginType = diff --git a/lib/route/router_provider.dart b/lib/route/router_provider.dart index 28f83e849..a7c43ec05 100644 --- a/lib/route/router_provider.dart +++ b/lib/route/router_provider.dart @@ -140,9 +140,9 @@ final routerProvider = Provider((ref) { // In Remote build mode, redirect to confirm page with restored session params. if (GlobalConfig.remote.isActive) { // If already connected (USP layer active), allow access - final loginType = - ref.read(authProvider.select((value) => value.value?.loginType)); - if (loginType == LoginType.remote) { + final isRemoteAssistance = ref.read(authProvider + .select((value) => value.value?.isRemoteAssistance ?? false)); + if (isRemoteAssistance) { return state.uri.toString(); } @@ -159,9 +159,9 @@ final routerProvider = Provider((ref) { logger.i('[Route]: Remote mode no session, redirecting to RA page'); return RoutePath.remoteAssistanceConfirm; } - final loginType = - ref.watch(authProvider.select((value) => value.value?.loginType)); - if (loginType == null || loginType == LoginType.none) { + final isLoggedIn = ref.watch( + authProvider.select((value) => value.value?.isLoggedIn ?? false)); + if (!isLoggedIn) { return router._home(); } return state.uri.toString(); @@ -221,7 +221,7 @@ class RouterNotifier extends ChangeNotifier { final loginType = _ref.watch(authProvider.select((data) => data.value?.loginType)); - // if have no login type and navigate into dashboard, then back to home + // if not logged in and navigate into dashboard, then back to home if ((loginType == null || loginType == LoginType.none) && (state.matchedLocation.startsWith('/dashboard') || state.matchedLocation.startsWith('/usp'))) { diff --git a/test/page/internet_settings/providers/usp_internet_settings_notifier_test.dart b/test/page/internet_settings/providers/usp_internet_settings_notifier_test.dart index 301afeacd..6fbda46df 100644 --- a/test/page/internet_settings/providers/usp_internet_settings_notifier_test.dart +++ b/test/page/internet_settings/providers/usp_internet_settings_notifier_test.dart @@ -421,9 +421,13 @@ void main() { container.dispose(); }); - test('fetch with unauthenticated service sets error status', () async { + // Regression: issue #1119. The provider must NOT gate its fetch on the raw + // usp.isAuthenticated flag — in Remote Assistance that flag stays false by + // design (authToken bypass), yet the data layer serves fine. Auth is + // enforced by the router, not here. + test('fetch proceeds when usp.isAuthenticated is false (RA bypass)', + () async { when(() => mockUsp.isAuthenticated).thenReturn(false); - when(() => mockAuthCoordinator.restoreSession()).thenAnswer((_) async {}); when(() => mockService.fetchSettings()) .thenAnswer((_) async => testFetchResult); @@ -431,8 +435,29 @@ void main() { await Future.delayed(Duration.zero); final state = container.read(uspInternetSettingsProvider); - // Should hit the restore path which won't succeed with our mock. - expect(state.status.error, isNotNull); + expect(state.status.error, isNull); + expect(state.status.isLoading, isFalse); + expect(state.settings.current.form.connectionType, + UspWanConnectionType.dhcp); + verify(() => mockService.fetchSettings()).called(1); + container.dispose(); + }); + + test('fetch throws ServiceNotInitializedError when usp is null', () async { + final container = ProviderContainer( + overrides: [ + uspClientProvider.overrideWithValue(null), + uspInternetSettingsServiceProvider.overrideWithValue(mockService), + uspMutationLockProvider.overrideWithValue(UspMutationLock()), + uspAuthCoordinatorProvider.overrideWithValue(mockAuthCoordinator), + sseManagerProvider.overrideWithValue(mockSseManager), + ], + ); + container.listen(uspInternetSettingsProvider, (_, __) {}); + await Future.delayed(Duration.zero); + + final state = container.read(uspInternetSettingsProvider); + expect(state.status.error, isA()); container.dispose(); }); }); diff --git a/test/page/wifi_settings/providers/usp_wifi_settings_notifier_test.dart b/test/page/wifi_settings/providers/usp_wifi_settings_notifier_test.dart index 7053601cf..f3f589e82 100644 --- a/test/page/wifi_settings/providers/usp_wifi_settings_notifier_test.dart +++ b/test/page/wifi_settings/providers/usp_wifi_settings_notifier_test.dart @@ -137,6 +137,36 @@ void main() { container.dispose(); }); + // Regression: issue #1119. The provider must NOT gate its fetch on the raw + // usp.isAuthenticated flag — in Remote Assistance that flag stays false by + // design (authToken bypass), yet the WiFi data layer serves fine. Auth is + // enforced by the router, not here. + test('fetch proceeds when usp.isAuthenticated is false (RA bypass)', + () async { + when(() => mockUsp.isAuthenticated).thenReturn(false); + final networks = WifiSettingsTestData.createNetworks(); + when(() => mockService.buildWifiNetworks( + ssids: any(named: 'ssids'), + accessPoints: any(named: 'accessPoints'), + radios: any(named: 'radios'), + )).thenReturn(networks); + when(() => mockService.buildQuickSetupNetworks(any())).thenReturn(( + main: WifiSettingsTestData.createQuickSetupAggregate(), + guest: null, + isQuickSetup: true, + )); + + final container = createContainer(); + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + + final state = container.read(uspWifiSettingsProvider); + expect(state.status.error, isNull); + expect(state.settings.current.networks, hasLength(3)); + expect(state.status.isLoading, isFalse); + container.dispose(); + }); + // ----------------------------------------------------------------------- // updateNetworkField // ----------------------------------------------------------------------- diff --git a/test/providers/auth/auth_state_test.dart b/test/providers/auth/auth_state_test.dart index d94e64ab2..a414fb104 100644 --- a/test/providers/auth/auth_state_test.dart +++ b/test/providers/auth/auth_state_test.dart @@ -47,7 +47,7 @@ void main() { }); test('fromJson defaults to LoginType.none for missing type', () { - final state = AuthState.fromJson({}); + final state = AuthState.fromJson(const {}); expect(state.loginType, LoginType.none); }); @@ -62,5 +62,20 @@ void main() { final b = AuthState(localPasswordHint: 'y', loginType: LoginType.local); expect(a, isNot(b)); }); + + test('isLoggedIn is true for local and remote, false for none', () { + expect(const AuthState(loginType: LoginType.none).isLoggedIn, isFalse); + expect(const AuthState(loginType: LoginType.local).isLoggedIn, isTrue); + expect(const AuthState(loginType: LoginType.remote).isLoggedIn, isTrue); + }); + + test('isRemoteAssistance is true only for LoginType.remote', () { + expect(const AuthState(loginType: LoginType.none).isRemoteAssistance, + isFalse); + expect(const AuthState(loginType: LoginType.local).isRemoteAssistance, + isFalse); + expect(const AuthState(loginType: LoginType.remote).isRemoteAssistance, + isTrue); + }); }); }