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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions lib/app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -233,8 +233,7 @@ class _LinksysAppState extends ConsumerState<LinksysApp>
}

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;
Expand Down
2 changes: 1 addition & 1 deletion lib/components/layouts/root_container.dart
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ class _AppRootContainerState extends ConsumerState<AppRootContainer> {
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,8 @@ class GeneralSettingsWidget extends ConsumerStatefulWidget {
class _GeneralSettingsWidgetState extends ConsumerState<GeneralSettingsWidget> {
@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);
Expand Down Expand Up @@ -110,8 +109,7 @@ class _GeneralSettingsWidgetState extends ConsumerState<GeneralSettingsWidget> {
),

// 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(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,7 @@ class AppConnectionStateNotifier extends Notifier<AppConnectionState> {

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();
Expand Down
4 changes: 2 additions & 2 deletions lib/page/dashboard/orchestrator/dashboard_orchestrator.dart
Original file line number Diff line number Diff line change
Expand Up @@ -129,8 +129,8 @@ class DashboardOrchestrator extends AsyncNotifier<DashboardOrchestratorState> {

// 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
3 changes: 1 addition & 2 deletions lib/page/login/views/login_local_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,7 @@ class _LoginViewState extends ConsumerState<LoginLocalView> {
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('/');
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,7 @@ class _RemoteAssistanceSessionGuardState

Future<void> _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);
Expand Down
5 changes: 2 additions & 3 deletions lib/page/shell/usp_top_bar.dart
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,8 @@ class _UspTopBarState extends ConsumerState<UspTopBar> 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(
Expand Down
15 changes: 6 additions & 9 deletions lib/page/wifi_settings/providers/usp_wifi_settings_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -75,15 +75,12 @@ class UspWifiSettingsNotifier extends AutoDisposeNotifier<UspWifiSettingsState>
);
}

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.
Expand Down
26 changes: 25 additions & 1 deletion lib/providers/auth/auth_state.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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<String, dynamic> json) {
final loginType =
Expand Down
14 changes: 7 additions & 7 deletions lib/route/router_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -140,9 +140,9 @@ final routerProvider = Provider<GoRouter>((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();
}

Expand All @@ -159,9 +159,9 @@ final routerProvider = Provider<GoRouter>((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();
Expand Down Expand Up @@ -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'))) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -421,18 +421,43 @@ 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);

final container = createContainer();
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<ServiceNotInitializedError>());
container.dispose();
});
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
// -----------------------------------------------------------------------
Expand Down
17 changes: 16 additions & 1 deletion test/providers/auth/auth_state_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

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