From 16c9bd998ae0acb1b0d9e1dbb4da48de15a490a7 Mon Sep 17 00:00:00 2001 From: ababak Date: Thu, 13 Aug 2026 16:26:31 +0300 Subject: [PATCH 1/5] fix: make QR-code login and endpoint switch reliable (PROD-8200, #303) - Read the live ThingsboardClient via getter in Login and NotificationService: after a QR endpoint switch re-creates the client, captured references kept hitting the old host with new tokens (401 'Token is outdated'), and the failed refresh wiped the fresh session. - Rework NoauthProvider.switchEndpoint: stage the exchanged JWT pair in storage before reInit so the new client can only start with exactly these tokens (a stale session left in storage can no longer win the race), roll back to the previous endpoint (not the compiled default) on failure, and surface the server's error message instead of raw Dio text. - Handle QR links without a secret (the login-page app QR): switch the host and land on the new host's login page instead of spinning forever; SwitchEndpointArgs.secret is now optional. - Never render ThingsboardError.toString() in the noauth view (it embeds the stacktrace); navigate on isDone and add a fallback so the user is never stranded on the spinner. - Restore app-link handling in the v2 router (the TbContext listener is no longer initialized): listen in the app root, consume the cold-start link, and drop platform duplicate deliveries. - Pop the QR scanner once per scan: repeated MLKit detections popped the route twice ('There is nothing to pop'). - Guard fire-and-forget handleUserLoaded calls against unhandled async errors. --- .../routes_config/routes/noauth_routes.dart | 17 +- .../auth/login/provider/login_provider.dart | 20 +- .../data/model/switch_endpoint_args.dart | 22 +- .../model/switch_endpoint_args.freezed.dart | 28 +-- .../data/model/switch_endpoint_args.g.dart | 2 +- .../view/switch_endpoint_noauth_view.dart | 61 +++++- .../auth/noauth/provider/noauth_provider.dart | 190 ++++++++++++------ .../noauth/provider/noauth_provider.g.dart | 2 +- lib/thingsboard_app_ce.dart | 41 +++- lib/utils/services/notification_service.dart | 5 +- .../tb_client_service/tb_client_service.dart | 2 +- .../ui/qr_code_scanner/qr_code_scanner.dart | 16 +- 12 files changed, 285 insertions(+), 121 deletions(-) diff --git a/lib/config/routes/v2/routes_config/routes/noauth_routes.dart b/lib/config/routes/v2/routes_config/routes/noauth_routes.dart index 60bf60a9..5d1c29ff 100644 --- a/lib/config/routes/v2/routes_config/routes/noauth_routes.dart +++ b/lib/config/routes/v2/routes_config/routes/noauth_routes.dart @@ -8,15 +8,14 @@ final List noAuthRoutes = [ GoRoute( path: noAuthPath, builder: (context, state) { - // Try to get secret from query parameters - final secret = state.uri.queryParameters['secret']; - - if (secret == null) { - return const SwitchEndpointNoAuthView(arguments: null); - } - - // Create arguments from query parameters - final args = SwitchEndpointArgs.fromJson(state.uri.queryParameters); + // A link without a secret (e.g. the app QR from the login page) is + // still a valid host switch, so parse arguments in both cases. The + // original scanned link is passed along as the `uri` parameter. + final params = { + ...state.uri.queryParameters, + 'uri': state.uri.queryParameters['uri'] ?? state.uri.toString(), + }; + final args = SwitchEndpointArgs.fromJson(params); return SwitchEndpointNoAuthView(arguments: args); }, diff --git a/lib/core/auth/login/provider/login_provider.dart b/lib/core/auth/login/provider/login_provider.dart index f63e48ee..325a4520 100644 --- a/lib/core/auth/login/provider/login_provider.dart +++ b/lib/core/auth/login/provider/login_provider.dart @@ -23,7 +23,10 @@ part 'login_provider.g.dart'; @riverpod class Login extends _$Login { - final _tbClient = getIt().client; + // Read the live client on every access: a QR-code endpoint switch re-creates + // the client (ITbClientService.reInit), so a reference captured at build time + // would keep pointing at the old host and fail with 401 (PROD-8200). + ThingsboardClient get _tbClient => getIt().client; final _deviceInfoService = getIt(); late final StreamSubscription _listener; final _overlayService = getIt(); @@ -32,13 +35,24 @@ class Login extends _$Login { _listener = getIt().on().listen(( _, ) async { - await handleUserLoaded(); + await _safeHandleUserLoaded(); }); ref.onDispose(() => _listener.cancel()); - Future(() => handleUserLoaded()); + Future(_safeHandleUserLoaded); return const LoginState(isUserLoaded: false); } + /// handleUserLoaded runs from fire-and-forget contexts (event bus, build): + /// a failure there (e.g. the session was invalidated while loading the + /// user) must not escape as an unhandled zone error (PROD-8200). + Future _safeHandleUserLoaded() async { + try { + await handleUserLoaded(); + } catch (e) { + log('handle user loaded failed: $e'); + } + } + Future logout() async { if (getIt().apps.isNotEmpty && state.isFullyAuthenticated()) { diff --git a/lib/core/auth/noauth/data/model/switch_endpoint_args.dart b/lib/core/auth/noauth/data/model/switch_endpoint_args.dart index d908b1ea..309d36ca 100644 --- a/lib/core/auth/noauth/data/model/switch_endpoint_args.dart +++ b/lib/core/auth/noauth/data/model/switch_endpoint_args.dart @@ -5,22 +5,24 @@ part 'switch_endpoint_args.g.dart'; @freezed abstract class SwitchEndpointArgs with _$SwitchEndpointArgs { - const factory SwitchEndpointArgs( - {required String secret, - String? host, - String? ttl, - @JsonKey(fromJson: fromFluroData, toJson: uriToJson) - required Uri uri}) = _SwitchEndpointArgs; + const factory SwitchEndpointArgs({ + String? secret, + String? host, + String? ttl, + @JsonKey(fromJson: fromFluroData, toJson: uriToJson) required Uri uri, + }) = _SwitchEndpointArgs; factory SwitchEndpointArgs.fromJson(Map json) => _$SwitchEndpointArgsFromJson(json); } - Uri fromFluroData(dynamic data) { - if(data is Uri) { + +Uri fromFluroData(dynamic data) { + if (data is Uri) { return data; } -return Uri.parse(data.toString()); + return Uri.parse(data.toString()); } + String uriToJson(Uri uri) { return uri.toString(); -} \ No newline at end of file +} diff --git a/lib/core/auth/noauth/data/model/switch_endpoint_args.freezed.dart b/lib/core/auth/noauth/data/model/switch_endpoint_args.freezed.dart index d2e920d0..7b0e8732 100644 --- a/lib/core/auth/noauth/data/model/switch_endpoint_args.freezed.dart +++ b/lib/core/auth/noauth/data/model/switch_endpoint_args.freezed.dart @@ -15,7 +15,7 @@ T _$identity(T value) => value; /// @nodoc mixin _$SwitchEndpointArgs { - String get secret; String? get host; String? get ttl;@JsonKey(fromJson: fromFluroData, toJson: uriToJson) Uri get uri; + String? get secret; String? get host; String? get ttl;@JsonKey(fromJson: fromFluroData, toJson: uriToJson) Uri get uri; /// Create a copy of SwitchEndpointArgs /// with the given fields replaced by the non-null parameter values. @JsonKey(includeFromJson: false, includeToJson: false) @@ -48,7 +48,7 @@ abstract mixin class $SwitchEndpointArgsCopyWith<$Res> { factory $SwitchEndpointArgsCopyWith(SwitchEndpointArgs value, $Res Function(SwitchEndpointArgs) _then) = _$SwitchEndpointArgsCopyWithImpl; @useResult $Res call({ - String secret, String? host, String? ttl,@JsonKey(fromJson: fromFluroData, toJson: uriToJson) Uri uri + String? secret, String? host, String? ttl,@JsonKey(fromJson: fromFluroData, toJson: uriToJson) Uri uri }); @@ -65,10 +65,10 @@ class _$SwitchEndpointArgsCopyWithImpl<$Res> /// Create a copy of SwitchEndpointArgs /// with the given fields replaced by the non-null parameter values. -@pragma('vm:prefer-inline') @override $Res call({Object? secret = null,Object? host = freezed,Object? ttl = freezed,Object? uri = null,}) { +@pragma('vm:prefer-inline') @override $Res call({Object? secret = freezed,Object? host = freezed,Object? ttl = freezed,Object? uri = null,}) { return _then(_self.copyWith( -secret: null == secret ? _self.secret : secret // ignore: cast_nullable_to_non_nullable -as String,host: freezed == host ? _self.host : host // ignore: cast_nullable_to_non_nullable +secret: freezed == secret ? _self.secret : secret // ignore: cast_nullable_to_non_nullable +as String?,host: freezed == host ? _self.host : host // ignore: cast_nullable_to_non_nullable as String?,ttl: freezed == ttl ? _self.ttl : ttl // ignore: cast_nullable_to_non_nullable as String?,uri: null == uri ? _self.uri : uri // ignore: cast_nullable_to_non_nullable as Uri, @@ -156,7 +156,7 @@ return $default(_that);case _: /// } /// ``` -@optionalTypeArgs TResult maybeWhen(TResult Function( String secret, String? host, String? ttl, @JsonKey(fromJson: fromFluroData, toJson: uriToJson) Uri uri)? $default,{required TResult orElse(),}) {final _that = this; +@optionalTypeArgs TResult maybeWhen(TResult Function( String? secret, String? host, String? ttl, @JsonKey(fromJson: fromFluroData, toJson: uriToJson) Uri uri)? $default,{required TResult orElse(),}) {final _that = this; switch (_that) { case _SwitchEndpointArgs() when $default != null: return $default(_that.secret,_that.host,_that.ttl,_that.uri);case _: @@ -177,7 +177,7 @@ return $default(_that.secret,_that.host,_that.ttl,_that.uri);case _: /// } /// ``` -@optionalTypeArgs TResult when(TResult Function( String secret, String? host, String? ttl, @JsonKey(fromJson: fromFluroData, toJson: uriToJson) Uri uri) $default,) {final _that = this; +@optionalTypeArgs TResult when(TResult Function( String? secret, String? host, String? ttl, @JsonKey(fromJson: fromFluroData, toJson: uriToJson) Uri uri) $default,) {final _that = this; switch (_that) { case _SwitchEndpointArgs(): return $default(_that.secret,_that.host,_that.ttl,_that.uri);case _: @@ -197,7 +197,7 @@ return $default(_that.secret,_that.host,_that.ttl,_that.uri);case _: /// } /// ``` -@optionalTypeArgs TResult? whenOrNull(TResult? Function( String secret, String? host, String? ttl, @JsonKey(fromJson: fromFluroData, toJson: uriToJson) Uri uri)? $default,) {final _that = this; +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String? secret, String? host, String? ttl, @JsonKey(fromJson: fromFluroData, toJson: uriToJson) Uri uri)? $default,) {final _that = this; switch (_that) { case _SwitchEndpointArgs() when $default != null: return $default(_that.secret,_that.host,_that.ttl,_that.uri);case _: @@ -212,10 +212,10 @@ return $default(_that.secret,_that.host,_that.ttl,_that.uri);case _: @JsonSerializable() class _SwitchEndpointArgs implements SwitchEndpointArgs { - const _SwitchEndpointArgs({required this.secret, this.host, this.ttl, @JsonKey(fromJson: fromFluroData, toJson: uriToJson) required this.uri}); + const _SwitchEndpointArgs({this.secret, this.host, this.ttl, @JsonKey(fromJson: fromFluroData, toJson: uriToJson) required this.uri}); factory _SwitchEndpointArgs.fromJson(Map json) => _$SwitchEndpointArgsFromJson(json); -@override final String secret; +@override final String? secret; @override final String? host; @override final String? ttl; @override@JsonKey(fromJson: fromFluroData, toJson: uriToJson) final Uri uri; @@ -253,7 +253,7 @@ abstract mixin class _$SwitchEndpointArgsCopyWith<$Res> implements $SwitchEndpoi factory _$SwitchEndpointArgsCopyWith(_SwitchEndpointArgs value, $Res Function(_SwitchEndpointArgs) _then) = __$SwitchEndpointArgsCopyWithImpl; @override @useResult $Res call({ - String secret, String? host, String? ttl,@JsonKey(fromJson: fromFluroData, toJson: uriToJson) Uri uri + String? secret, String? host, String? ttl,@JsonKey(fromJson: fromFluroData, toJson: uriToJson) Uri uri }); @@ -270,10 +270,10 @@ class __$SwitchEndpointArgsCopyWithImpl<$Res> /// Create a copy of SwitchEndpointArgs /// with the given fields replaced by the non-null parameter values. -@override @pragma('vm:prefer-inline') $Res call({Object? secret = null,Object? host = freezed,Object? ttl = freezed,Object? uri = null,}) { +@override @pragma('vm:prefer-inline') $Res call({Object? secret = freezed,Object? host = freezed,Object? ttl = freezed,Object? uri = null,}) { return _then(_SwitchEndpointArgs( -secret: null == secret ? _self.secret : secret // ignore: cast_nullable_to_non_nullable -as String,host: freezed == host ? _self.host : host // ignore: cast_nullable_to_non_nullable +secret: freezed == secret ? _self.secret : secret // ignore: cast_nullable_to_non_nullable +as String?,host: freezed == host ? _self.host : host // ignore: cast_nullable_to_non_nullable as String?,ttl: freezed == ttl ? _self.ttl : ttl // ignore: cast_nullable_to_non_nullable as String?,uri: null == uri ? _self.uri : uri // ignore: cast_nullable_to_non_nullable as Uri, diff --git a/lib/core/auth/noauth/data/model/switch_endpoint_args.g.dart b/lib/core/auth/noauth/data/model/switch_endpoint_args.g.dart index a7a2327d..ec4dc2fd 100644 --- a/lib/core/auth/noauth/data/model/switch_endpoint_args.g.dart +++ b/lib/core/auth/noauth/data/model/switch_endpoint_args.g.dart @@ -8,7 +8,7 @@ part of 'switch_endpoint_args.dart'; _SwitchEndpointArgs _$SwitchEndpointArgsFromJson(Map json) => _SwitchEndpointArgs( - secret: json['secret'] as String, + secret: json['secret'] as String?, host: json['host'] as String?, ttl: json['ttl'] as String?, uri: fromFluroData(json['uri']), diff --git a/lib/core/auth/noauth/presentation/view/switch_endpoint_noauth_view.dart b/lib/core/auth/noauth/presentation/view/switch_endpoint_noauth_view.dart index 246126d0..baf6c01e 100644 --- a/lib/core/auth/noauth/presentation/view/switch_endpoint_noauth_view.dart +++ b/lib/core/auth/noauth/presentation/view/switch_endpoint_noauth_view.dart @@ -3,10 +3,14 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:thingsboard_app/core/auth/login/provider/login_provider.dart'; import 'package:thingsboard_app/core/auth/noauth/data/model/switch_endpoint_args.dart'; - import 'package:thingsboard_app/core/auth/noauth/presentation/widgets/noauth_loading_widget.dart'; import 'package:thingsboard_app/core/auth/noauth/provider/noauth_provider.dart'; +import 'package:thingsboard_app/locator.dart'; +import 'package:thingsboard_app/modules/main/providers/navigation_provider.dart'; +import 'package:thingsboard_app/thingsboard_client.dart'; +import 'package:thingsboard_app/utils/services/tb_client_service/i_tb_client_service.dart'; class SwitchEndpointNoAuthView extends HookConsumerWidget { const SwitchEndpointNoAuthView({required this.arguments, super.key}); @@ -15,24 +19,63 @@ class SwitchEndpointNoAuthView extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final noAuth = ref.watch(noauthProviderProvider); useEffect(() { - ref.invalidate(noauthProviderProvider); + ref.invalidate(noauthProviderProvider); if (arguments != null) { ref .read(noauthProviderProvider.notifier) .switchEndpoint(SwitchEndpointParams(data: arguments!)); + } else { + // Nothing to switch to: never leave the user on an endless spinner. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (context.mounted) { + context.go('/login'); + } + }); } return null; }, []); ref.listen(noauthProviderProvider, (prev, next) { if (next.error != null) { Future.delayed(const Duration(seconds: 5), () { - if (context.mounted) { + if (!context.mounted) { + return; + } + if (context.canPop()) { context.pop(); + } else { + context.go('/login'); } }); } if (next.isDone) { - + // A switch with a login secret ends authenticated: HomeHandler + // navigates to the home page once the user is fully loaded. A + // host-only switch (QR without a secret) ends unauthenticated: show + // the login page of the new host. + final authenticated = + getIt().client.isAuthenticated(); + if (!context.mounted) { + return; + } + if (!authenticated) { + context.go('/login'); + } else if (ref.read(loginProvider).isFullyAuthenticated()) { + // Already fully logged in (e.g. the same QR was scanned twice): + // HomeHandler won't see a state transition, navigate ourselves. + final navigation = ref.read(navigationProvider); + if (navigation.bottomBarPages.isNotEmpty) { + context.go(navigation.bottomBarPages.first.path); + } + } else { + // HomeHandler navigates once the user finishes loading. If that + // never happens (e.g. the new session gets rejected), don't leave + // the user on the spinner forever. + Future.delayed(const Duration(seconds: 12), () { + if (context.mounted) { + context.go('/login'); + } + }); + } } }); return Scaffold( @@ -40,6 +83,13 @@ class SwitchEndpointNoAuthView extends HookConsumerWidget { child: Builder( builder: (context) { if (noAuth.error != null) { + final error = noAuth.error; + // Never render error.toString(): for ThingsboardError it + // includes the captured stacktrace (PROD-8200). + final message = + error is ThingsboardError + ? error.message ?? noAuth.message + : noAuth.message; return Padding( padding: const EdgeInsets.symmetric(horizontal: 20), child: Center( @@ -49,8 +99,7 @@ class SwitchEndpointNoAuthView extends HookConsumerWidget { const Icon(Icons.error, color: Colors.red, size: 50), const SizedBox(height: 10), Text( - noAuth.error.toString(), - //S.of(context).somethingWentWrongRollback, + message, textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodyLarge?.copyWith( fontWeight: FontWeight.w500, diff --git a/lib/core/auth/noauth/provider/noauth_provider.dart b/lib/core/auth/noauth/provider/noauth_provider.dart index 230a83f3..6bf0154b 100644 --- a/lib/core/auth/noauth/provider/noauth_provider.dart +++ b/lib/core/auth/noauth/provider/noauth_provider.dart @@ -1,7 +1,6 @@ import 'package:dio/dio.dart'; import 'package:firebase_core/firebase_core.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; -import 'package:thingsboard_app/constants/app_constants.dart'; import 'package:thingsboard_app/core/auth/login/provider/oauth_provider.dart'; import 'package:thingsboard_app/core/auth/noauth/data/model/switch_endpoint_args.dart'; @@ -29,13 +28,30 @@ class NoauthProvider extends _$NoauthProvider { } Future switchEndpoint(SwitchEndpointParams params) async { + final uri = params.data.uri; + final key = params.data.secret; + final currentEndpoint = await getIt().getEndpoint(); try { - final uri = params.data.uri; - final host = params.data.host ?? uri.origin; - final key = params.data.secret; - final currentEndpoint = await getIt().getEndpoint(); + final host = + params.data.host ?? (uri.isAbsolute ? uri.origin : currentEndpoint); final isTheSameHost = Uri.parse(host).host.compareTo(Uri.parse(currentEndpoint).host) == 0; + _logger.debug( + 'SwitchEndpointUseCase: host=$host currentEndpoint=$currentEndpoint ' + 'isTheSameHost=$isTheSameHost hasSecret=${key != null}', + ); + + if (key == null || key.isEmpty) { + // A QR link without a secret (e.g. the mobile app QR shown on the + // login page) cannot log the user in: just switch to the target host + // and let the login page of that host take over. + await _switchHostOnly( + host: host, + currentEndpoint: currentEndpoint, + isTheSameHost: isTheSameHost, + ); + return; + } state = NoAuthState( error: null, @@ -54,7 +70,22 @@ class NoauthProvider extends _$NoauthProvider { receiveTimeout: const Duration(seconds: 20), ), ); - final secretResponse = await tempDio.get('/api/noauth/qr/${key ?? ''}'); + final Response secretResponse; + try { + secretResponse = await tempDio.get('/api/noauth/qr/$key'); + } on DioException catch (e) { + // The server replies with a ThingsboardError body (e.g. an expired + // one-time secret): surface its message instead of the raw Dio text. + final body = e.response?.data; + final serverMessage = body is Map ? body['message'] as String? : null; + throw ThingsboardError( + message: + serverMessage ?? + 'Failed to obtain a login token from $host. ' + 'Please scan a new QR code.', + status: e.response?.statusCode, + ); + } final data = secretResponse.data; final tokenStr = data is Map ? data['token'] as String? : null; final refreshTokenStr = @@ -79,56 +110,89 @@ class NoauthProvider extends _$NoauthProvider { ); } - await getIt().client.setUserFromJwtToken( - tokenStr, - refreshTokenStr, - false, - ); + // Stage the exchanged JWT pair in storage BEFORE re-creating the + // client: reInit logs in from storage, so this guarantees the new + // client starts with exactly these tokens. Never hand them to the old + // client instance, and never let a session left in storage by a + // previous host win the race (PROD-8200). + final storage = getIt(); + await storage.setItem('jwt_token', tokenStr); + if (refreshTokenStr != null) { + await storage.setItem('refresh_token', refreshTokenStr); + } else { + await storage.deleteItem('refresh_token'); + } await getIt().setEndpoint(host); - if (!isTheSameHost) { - _logger.debug('SwitchEndpointUseCase:deleteFB App'); - if (Firebase.apps.isNotEmpty) { - getIt() - ..removeApp() - ..removeApp(name: currentEndpoint); - } - - // If we revert to the original host configured in the app_constants - final t = await getIt().isCustomEndpoint(); - _logger.debug(t); - if (!t) { - await _initDefaultFbApp(); - } + await _switchFirebaseApps(currentEndpoint); } - // A re-initialization is required if we set 'notifyUser' to true for - // 'setUserFromJwtToken'. This code will be executed twice. await getIt().reInit( endpoint: host, - onDone: () { - ref.invalidate(oauthProvider); - // await ref.read(loginProvider.notifier).handleUserLoaded(); - }, + onDone: () => ref.invalidate(oauthProvider), onAuthError: (e) { - _logger.error('SwitchEndpointUseCase:onError $e'); - throw e; + // Client-level errors are surfaced by the client service itself; + // throwing here would escape the callback as an unhandled zone + // error and leak a raw stacktrace to the UI (PROD-8200). + _logger.error('SwitchEndpointUseCase:onAuthError $e'); }, ); + _logger.debug('SwitchEndpointUseCase: switch to $host done'); state = NoAuthState(error: null, isDone: true, message: ''); } catch (e) { - await reset(params); - if (e is ThingsboardError) { - _logger.error('SwitchEndpointUseCase:ThingsboardError $e', e); - state = NoAuthState( - error: e, - isDone: false, - message: e.message ?? e.toString(), - ); - return; - } _logger.error('SwitchEndpointUseCase:catch $e', e); - state = NoAuthState(error: e, isDone: false, message: e.toString()); + await reset(previousEndpoint: currentEndpoint); + state = NoAuthState( + error: e, + isDone: false, + message: e is ThingsboardError ? e.message ?? e.toString() : '$e', + ); + } + } + + Future _switchHostOnly({ + required String host, + required String currentEndpoint, + required bool isTheSameHost, + }) async { + if (!isTheSameHost) { + state = NoAuthState( + error: null, + isDone: false, + message: 'Switching you to the new host $host', + ); + // A host switch without a login secret ends on the login page of the + // new host: the previous host's session tokens are meaningless there. + await getIt().client.setUserFromJwtToken( + null, + null, + false, + ); + await getIt().setEndpoint(host); + await _switchFirebaseApps(currentEndpoint); + await getIt().reInit( + endpoint: host, + onDone: () => ref.invalidate(oauthProvider), + onAuthError: (e) { + _logger.error('SwitchEndpointUseCase:onAuthError $e'); + }, + ); + } + state = NoAuthState(error: null, isDone: true, message: ''); + } + + Future _switchFirebaseApps(String previousEndpoint) async { + _logger.debug('SwitchEndpointUseCase:deleteFB App'); + if (Firebase.apps.isNotEmpty) { + getIt() + ..removeApp() + ..removeApp(name: previousEndpoint); + } + + // If we revert to the original host configured in the app_constants + final isCustom = await getIt().isCustomEndpoint(); + if (!isCustom) { + await _initDefaultFbApp(); } } @@ -145,35 +209,29 @@ class NoauthProvider extends _$NoauthProvider { } } - Future reset(SwitchEndpointParams params) async { + /// Rolls the app back to the endpoint that was active before the failed + /// switch. The previous session tokens are still in storage (the new ones + /// are only persisted after a successful reInit), so a logged-in user keeps + /// their session. + Future reset({required String previousEndpoint}) async { try { - await getIt().setEndpoint( - ThingsboardAppConstants.thingsBoardApiEndpoint, - ); + await getIt().setEndpoint(previousEndpoint); await getIt().clearApps(); - await _initDefaultFbApp(); - _reInitClient( - endpoint: ThingsboardAppConstants.thingsBoardApiEndpoint, - params: params, + final isCustom = await getIt().isCustomEndpoint(); + if (!isCustom) { + await _initDefaultFbApp(); + } + await getIt().reInit( + endpoint: previousEndpoint, + onDone: () => ref.invalidate(oauthProvider), + onAuthError: (e) { + _logger.error('SwitchEndpointUseCaseReset:onAuthError $e'); + }, ); } catch (e) { _logger.error('SwitchEndpointUseCaseReset:onError $e'); } } - - Future _reInitClient({ - required String endpoint, - required SwitchEndpointParams params, - }) async { - await getIt().reInit( - endpoint: endpoint, - onDone: () {}, - onAuthError: (e) { - _logger.error('SwitchEndpointUseCase:onError $e'); - throw e; - }, - ); - } } class NoAuthState { diff --git a/lib/core/auth/noauth/provider/noauth_provider.g.dart b/lib/core/auth/noauth/provider/noauth_provider.g.dart index b119f9e5..3d8d04d2 100644 --- a/lib/core/auth/noauth/provider/noauth_provider.g.dart +++ b/lib/core/auth/noauth/provider/noauth_provider.g.dart @@ -6,7 +6,7 @@ part of 'noauth_provider.dart'; // RiverpodGenerator // ************************************************************************** -String _$noauthProviderHash() => r'098288b187c0dc0d812e6674e8f86501e956ec99'; +String _$noauthProviderHash() => r'814ea03eaa086b3f1577f40c81392db4c40972fa'; /// See also [NoauthProvider]. @ProviderFor(NoauthProvider) diff --git a/lib/thingsboard_app_ce.dart b/lib/thingsboard_app_ce.dart index c76ce4a0..61b82c10 100644 --- a/lib/thingsboard_app_ce.dart +++ b/lib/thingsboard_app_ce.dart @@ -1,13 +1,18 @@ +import 'package:app_links/app_links.dart'; import 'package:country_picker/country_picker.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_localized_locales/flutter_localized_locales.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:thingsboard_app/config/routes/router.dart'; import 'package:thingsboard_app/config/routes/v2/router_2.dart'; import 'package:thingsboard_app/config/themes/dark_theme.dart'; import 'package:thingsboard_app/config/themes/tb_ce_theme.dart'; import 'package:thingsboard_app/generated/l10n.dart'; +import 'package:thingsboard_app/locator.dart'; +import 'package:thingsboard_app/utils/services/local_database/i_local_database_service.dart'; import 'package:toastification/toastification.dart'; class ThingsboardApp extends HookConsumerWidget { @@ -17,8 +22,40 @@ class ThingsboardApp extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final router = ref.watch(routerProvider); + // App links (e.g. the login QR code scanned with the camera app) used to + // be handled by TbContext.init, which the v2 router no longer calls. + // Listen here and route through the same navigateByAppLink path the + // in-app QR scanner uses. The platform may deliver the same intent more + // than once, so consecutive duplicates are dropped. + useEffect(() { + String? lastLink; + DateTime? lastLinkAt; + final sub = AppLinks().uriLinkStream.listen((link) { + final now = DateTime.now(); + final isDuplicate = + link.toString() == lastLink && + lastLinkAt != null && + now.difference(lastLinkAt!) < const Duration(seconds: 2); + lastLink = link.toString(); + lastLinkAt = now; + if (!isDuplicate) { + getIt().navigateByAppLink(link.toString()); + } + }); + + // Consume the link the app was cold-started with (stored in main()). + WidgetsBinding.instance.addPostFrameCallback((_) async { + final initialLink = + await getIt().getInitialAppLink(); + if (initialLink != null && initialLink != lastLink) { + getIt().navigateByAppLink(initialLink); + } + }); + return sub.cancel; + }, const []); + return ToastificationWrapper( - child : ColoredBox( + child: ColoredBox( color: tbCeTheme.scaffoldBackgroundColor, child: MaterialApp.router( debugShowCheckedModeBanner: false, @@ -38,7 +75,7 @@ class ThingsboardApp extends HookConsumerWidget { darkTheme: tbDarkTheme, routerConfig: router, ), - ) + ), ); } } diff --git a/lib/utils/services/notification_service.dart b/lib/utils/services/notification_service.dart index 18980c5e..68de6cb2 100644 --- a/lib/utils/services/notification_service.dart +++ b/lib/utils/services/notification_service.dart @@ -18,7 +18,10 @@ class NotificationService { static FirebaseMessaging _messaging = FirebaseMessaging.instance; late NotificationDetails _notificationDetails; final TbLogger _log = getIt(); - final ThingsboardClient _tbClient = getIt().client; + // Read the live client on every access: a QR-code endpoint switch re-creates + // the client (ITbClientService.reInit), so a reference captured at + // construction would keep pointing at the old host (PROD-8200). + ThingsboardClient get _tbClient => getIt().client; final INotificationsLocalService _localService = NotificationsLocalService(); StreamSubscription? _foregroundMessageSubscription; StreamSubscription? _onMessageOpenedAppSubscription; diff --git a/lib/utils/services/tb_client_service/tb_client_service.dart b/lib/utils/services/tb_client_service/tb_client_service.dart index fba12374..74394038 100644 --- a/lib/utils/services/tb_client_service/tb_client_service.dart +++ b/lib/utils/services/tb_client_service/tb_client_service.dart @@ -108,7 +108,7 @@ class TbClientService implements ITbClientService { required VoidCallback onDone, required ErrorCallback onAuthError, }) async { - log('TbClient:reinit()'); + log('TbClient:reinit() endpoint: $endpoint'); _client = _createClient( endpoint, onError: (e) { diff --git a/lib/utils/ui/qr_code_scanner/qr_code_scanner.dart b/lib/utils/ui/qr_code_scanner/qr_code_scanner.dart index 1c80f6af..9dc21829 100644 --- a/lib/utils/ui/qr_code_scanner/qr_code_scanner.dart +++ b/lib/utils/ui/qr_code_scanner/qr_code_scanner.dart @@ -36,6 +36,9 @@ class QrCodeScannerPage extends HookWidget { final isBackCameraActive = useState(true); final controller = useMemoized(() => MobileScannerController()); + // The scanner keeps detecting the same code on every frame: pop with the + // first result only, or GoRouter throws "There is nothing to pop". + final detectionHandled = useRef(false); // Check camera permission initially useEffect(() { @@ -91,13 +94,12 @@ class QrCodeScannerPage extends HookWidget { errorBuilder: (p0, p1) => const ScannerErrorWidget(), controller: controller, onDetect: (barcodes) { - if (barcodes.barcodes.isNotEmpty) { - if (context.mounted) { - context.pop( - barcodes.barcodes.first, - - ); - } + if (barcodes.barcodes.isNotEmpty && + !detectionHandled.value && + context.mounted && + context.canPop()) { + detectionHandled.value = true; + context.pop(barcodes.barcodes.first); } }, ), From 9a3bbafc8d21c1e50a1335fe0ce4e3356c1b16f3 Mon Sep 17 00:00:00 2001 From: ababak Date: Thu, 13 Aug 2026 16:33:56 +0300 Subject: [PATCH 2/5] fix: compensate for generated client behavior in the app layer (PROD-8200) The dart client is autogenerated and must stay unmodified, so: - TbClientService suppresses error toasts while client.init() runs (init and reInit): the client's internal best-effort version check hits /api/admin/updates, which answers 403 for non-SYS_ADMIN users and otherwise surfaced as an error toast on every (re)init. Real init failures still propagate and are handled by the callers. - switchEndpoint re-applies the exchanged JWT pair to the new client if it comes out of reInit unauthenticated: a failing background refresh of the previous session may clear the shared token storage after the pair was staged but before init read it. --- .../auth/noauth/provider/noauth_provider.dart | 12 ++++++++++++ .../tb_client_service/tb_client_service.dart | 19 ++++++++++++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/lib/core/auth/noauth/provider/noauth_provider.dart b/lib/core/auth/noauth/provider/noauth_provider.dart index 6bf0154b..3429bc7c 100644 --- a/lib/core/auth/noauth/provider/noauth_provider.dart +++ b/lib/core/auth/noauth/provider/noauth_provider.dart @@ -137,6 +137,18 @@ class NoauthProvider extends _$NoauthProvider { _logger.error('SwitchEndpointUseCase:onAuthError $e'); }, ); + if (!getIt().client.isAuthenticated()) { + // The staged tokens were lost before init picked them up (e.g. a + // failing background refresh of the previous session cleared the + // shared storage in the meantime): apply the exchanged pair to the + // new client directly. + _logger.debug('SwitchEndpointUseCase: re-applying exchanged tokens'); + await getIt().client.setUserFromJwtToken( + tokenStr, + refreshTokenStr, + true, + ); + } _logger.debug('SwitchEndpointUseCase: switch to $host done'); state = NoAuthState(error: null, isDone: true, message: ''); } catch (e) { diff --git a/lib/utils/services/tb_client_service/tb_client_service.dart b/lib/utils/services/tb_client_service/tb_client_service.dart index 74394038..353f9739 100644 --- a/lib/utils/services/tb_client_service/tb_client_service.dart +++ b/lib/utils/services/tb_client_service/tb_client_service.dart @@ -19,6 +19,12 @@ class TbClientService implements ITbClientService { ThingsboardClient get client => _client; final IOverlayService _overlayService = getIt(); + // The client performs best-effort internal calls during init() (e.g. the + // server version check hits /api/admin/updates, which answers 403 for + // non-SYS_ADMIN users). Those must not surface as error toasts, and the + // generated client library can't be modified to ignore them (PROD-8200). + bool _suppressErrorNotifications = false; + ThingsboardClient _createClient( String endpoint, { required ErrorCallback onError, @@ -42,10 +48,13 @@ class TbClientService implements ITbClientService { _client = _createClient(endpoint, onError: onClientError); try { + _suppressErrorNotifications = true; await _client.init(); } catch (e) { log('Failed to init tbClient: $e'); onInitError(e); + } finally { + _suppressErrorNotifications = false; } } @@ -76,6 +85,9 @@ class TbClientService implements ITbClientService { void onClientError(ThingsboardError e) { log('client on error: $e'); + if (_suppressErrorNotifications) { + return; + } WidgetsBinding.instance.addPostFrameCallback((_) { if (Utils.isConnectionError(e)) { _overlayService.showAlertDialog( @@ -116,7 +128,12 @@ class TbClientService implements ITbClientService { onClientError(e); }, ); - await _client.init(); + try { + _suppressErrorNotifications = true; + await _client.init(); + } finally { + _suppressErrorNotifications = false; + } onDone(); } } From 363545e82840fc40cd4f7be06512fe028561b2bf Mon Sep 17 00:00:00 2001 From: ababak Date: Thu, 13 Aug 2026 16:48:13 +0300 Subject: [PATCH 3/5] fix: silence best-effort login mobile info fetch (PROD-8200) getLoginMobileInfo already has a graceful fallback (QR-only button list), but its failure still surfaced through the interceptor's global error channel as a 'You don't have permission' toast right after a successful QR switch: some servers answer 403 for an unknown mobile package. Pass ignoreErrors/ignoreLoading via the request extras so the interceptor stays quiet; the fallback behavior is unchanged. --- lib/core/auth/login/provider/oauth_provider.dart | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/lib/core/auth/login/provider/oauth_provider.dart b/lib/core/auth/login/provider/oauth_provider.dart index a2eaf020..d6254a5f 100644 --- a/lib/core/auth/login/provider/oauth_provider.dart +++ b/lib/core/auth/login/provider/oauth_provider.dart @@ -21,11 +21,19 @@ Future oauth(Ref ref) async { final tbClient = getIt().client; final deviceInfoService = getIt(); try { + // Best-effort call with a graceful fallback below: don't let the + // interceptor surface its failures as error toasts (e.g. some servers + // answer 403 for a package they don't know about) (PROD-8200). final response = await tbClient .getMobileAppControllerApi() .getLoginMobileInfo( pkgName: deviceInfoService.getApplicationId(), platform: deviceInfoService.getPlatformType().name, + extra: + InterceptorConfig( + ignoreErrors: true, + ignoreLoading: true, + ).toExtra(), ); final loginInfo = response.data; if (loginInfo != null) { From beae2e4aeba556b948e0599823405b728536eb30 Mon Sep 17 00:00:00 2001 From: ababak Date: Thu, 13 Aug 2026 17:20:08 +0300 Subject: [PATCH 4/5] fix: keep best-effort calls after QR login from toasting errors (PROD-8200) Two more sources of the 'You don't have permission' toast right after a successful QR switch: - NotificationService mobile-session calls (get/save/removeMobileSession, unread count) run against servers that may not know the mobile package and answer 403. Mark them ignoreErrors/ignoreLoading and guard the session sync so notification setup failures never surface to the user or abort init. - The generated client delivers error callbacks via Future(() => cb()), so the internal version-check 403 raised during client.init() reaches onClientError one event-loop turn AFTER init() returns - just outside the suppression window. Release the suppression flag after a short grace period instead of synchronously. --- lib/utils/services/notification_service.dart | 69 +++++++++++++------ .../tb_client_service/tb_client_service.dart | 14 +++- 2 files changed, 61 insertions(+), 22 deletions(-) diff --git a/lib/utils/services/notification_service.dart b/lib/utils/services/notification_service.dart index 68de6cb2..61072358 100644 --- a/lib/utils/services/notification_service.dart +++ b/lib/utils/services/notification_service.dart @@ -22,6 +22,12 @@ class NotificationService { // the client (ITbClientService.reInit), so a reference captured at // construction would keep pointing at the old host (PROD-8200). ThingsboardClient get _tbClient => getIt().client; + + // Notifications are best-effort: some servers answer 403 for a mobile + // package they don't know about, and that must not surface as an error + // toast right after a successful login (PROD-8200). + static Map get _bestEffortRequestExtra => + InterceptorConfig(ignoreErrors: true, ignoreLoading: true).toExtra(); final INotificationsLocalService _localService = NotificationsLocalService(); StreamSubscription? _foregroundMessageSubscription; StreamSubscription? _onMessageOpenedAppSubscription; @@ -58,7 +64,10 @@ class NotificationService { if (_fcmToken != null) { _tbClient .getUserControllerApi() - .removeMobileSession(xMobileToken: _fcmToken!) + .removeMobileSession( + xMobileToken: _fcmToken!, + extra: _bestEffortRequestExtra, + ) .then((_) { _fcmToken = token; if (_fcmToken != null) { @@ -103,6 +112,7 @@ class NotificationService { ); _tbClient.getUserControllerApi().removeMobileSession( xMobileToken: _fcmToken!, + extra: _bestEffortRequestExtra, ); } @@ -176,7 +186,10 @@ class NotificationService { Future _resetToken(String? token) async { if (token != null) { - _tbClient.getUserControllerApi().removeMobileSession(xMobileToken: token); + _tbClient.getUserControllerApi().removeMobileSession( + xMobileToken: token, + extra: _bestEffortRequestExtra, + ); } await _messaging.deleteToken(); @@ -184,27 +197,39 @@ class NotificationService { } Future _getAndSaveToken() async { - String? fcmToken = await getToken(); + final fcmToken = await getToken(); _log.debug('FCM token: $fcmToken'); - if (fcmToken != null) { - final mobileInfo = - (await _tbClient.getUserControllerApi().getMobileSession( - xMobileToken: fcmToken, - )).data; - if (mobileInfo != null) { - final int timeAfterCreatedToken = - DateTime.now().millisecondsSinceEpoch - - (mobileInfo.fcmTokenTimestamp ?? 0); - if (timeAfterCreatedToken > const Duration(days: 30).inMilliseconds) { - fcmToken = await _resetToken(fcmToken); - if (fcmToken != null) { - await _saveToken(fcmToken); - } + try { + await _syncMobileSession(fcmToken); + } catch (e) { + // The server may reject the session for an unknown mobile package: + // push notifications simply stay off, nothing else should break. + _log.error('NotificationService: failed to sync mobile session $e'); + } + } + + Future _syncMobileSession(String? fcmToken) async { + if (fcmToken == null) { + return; + } + final mobileInfo = + (await _tbClient.getUserControllerApi().getMobileSession( + xMobileToken: fcmToken, + extra: _bestEffortRequestExtra, + )).data; + if (mobileInfo != null) { + final int timeAfterCreatedToken = + DateTime.now().millisecondsSinceEpoch - + (mobileInfo.fcmTokenTimestamp ?? 0); + if (timeAfterCreatedToken > const Duration(days: 30).inMilliseconds) { + final refreshedToken = await _resetToken(fcmToken); + if (refreshedToken != null) { + await _saveToken(refreshedToken); } - } else { - await _saveToken(fcmToken); } + } else { + await _saveToken(fcmToken); } } @@ -214,6 +239,7 @@ class NotificationService { mobileSessionInfo: MobileSessionInfo( (b) => b..fcmTokenTimestamp = DateTime.now().millisecondsSinceEpoch, ), + extra: _bestEffortRequestExtra, ); } @@ -318,7 +344,10 @@ class NotificationService { try { final resp = await _tbClient .getNotificationControllerApi() - .getUnreadNotificationsCount(deliveryMethod: 'MOBILE_APP'); + .getUnreadNotificationsCount( + deliveryMethod: 'MOBILE_APP', + extra: _bestEffortRequestExtra, + ); return resp.data ?? 0; } catch (_) { return 0; diff --git a/lib/utils/services/tb_client_service/tb_client_service.dart b/lib/utils/services/tb_client_service/tb_client_service.dart index 353f9739..b4a304b7 100644 --- a/lib/utils/services/tb_client_service/tb_client_service.dart +++ b/lib/utils/services/tb_client_service/tb_client_service.dart @@ -25,6 +25,16 @@ class TbClientService implements ITbClientService { // generated client library can't be modified to ignore them (PROD-8200). bool _suppressErrorNotifications = false; + // The client delivers error callbacks via Future(() => cb(error)), so an + // error raised during init() reaches onClientError one event-loop turn + // AFTER init() returns. Keep suppressing for a grace period instead of + // lifting the flag synchronously. + void _scheduleErrorNotificationsRestore() { + Future.delayed(const Duration(seconds: 2), () { + _suppressErrorNotifications = false; + }); + } + ThingsboardClient _createClient( String endpoint, { required ErrorCallback onError, @@ -54,7 +64,7 @@ class TbClientService implements ITbClientService { log('Failed to init tbClient: $e'); onInitError(e); } finally { - _suppressErrorNotifications = false; + _scheduleErrorNotificationsRestore(); } } @@ -132,7 +142,7 @@ class TbClientService implements ITbClientService { _suppressErrorNotifications = true; await _client.init(); } finally { - _suppressErrorNotifications = false; + _scheduleErrorNotificationsRestore(); } onDone(); } From 150ede419475051d92ced8fa429b2157248ea8f9 Mon Sep 17 00:00:00 2001 From: ababak Date: Thu, 13 Aug 2026 17:37:16 +0300 Subject: [PATCH 5/5] fix: verify exchanged QR tokens before switching (PROD-8200) The JWT pair returned for a QR secret is bound to the secret, so re-scanning the same code after a logout hands the app tokens issued before the logout watermark: the exchange succeeds but every authenticated call answers 401 'Token is outdated', which sent the switch into a silent ~10s login/refresh loop that ended on the login page with no explanation. Verify the pair with GET /api/auth/user on the target host before committing the switch: a rejected pair now shows the server's message within seconds and rolls back, same as an expired QR code. --- .../auth/noauth/provider/noauth_provider.dart | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/lib/core/auth/noauth/provider/noauth_provider.dart b/lib/core/auth/noauth/provider/noauth_provider.dart index 3429bc7c..6ff56c2e 100644 --- a/lib/core/auth/noauth/provider/noauth_provider.dart +++ b/lib/core/auth/noauth/provider/noauth_provider.dart @@ -96,6 +96,28 @@ class NoauthProvider extends _$NoauthProvider { ); } + // The server can hand out an already-revoked pair: the pair is bound + // to the QR secret, so re-scanning the same code after a logout yields + // tokens issued before the logout watermark ('Token is outdated'). + // Verify the pair against the target host BEFORE switching, otherwise + // the new client would enter a login/refresh loop it can never win. + try { + await tempDio.get( + '/api/auth/user', + options: Options(headers: {'X-Authorization': 'Bearer $tokenStr'}), + ); + } on DioException catch (e) { + final body = e.response?.data; + final serverMessage = body is Map ? body['message'] as String? : null; + throw ThingsboardError( + message: + serverMessage ?? + 'The QR code session is no longer valid. ' + 'Please refresh the QR code and scan again.', + status: e.response?.statusCode, + ); + } + if (isTheSameHost) { state = NoAuthState( error: null,