diff --git a/.github/workflows/_ci-clients.yml b/.github/workflows/_ci-clients.yml index 8c5862b0c6..eeb5cd509b 100644 --- a/.github/workflows/_ci-clients.yml +++ b/.github/workflows/_ci-clients.yml @@ -154,6 +154,7 @@ jobs: test/shared/hosted/relay_auth_isolation_test.dart test/features/onboarding/hosted_app_gate_environment_test.dart test/features/onboarding/hosted_journeys_test.dart + test/features/onboarding/onboarding_sign_out_test.dart test/features/onboarding/hosted_communities_test.dart test/features/pairing/pairing_hosted_handoff_test.dart test/shared/agents/remote_agents_test.dart diff --git a/mobile/lib/features/pairing/add_community_page/add_community_connection.dart b/mobile/lib/features/pairing/add_community_page/add_community_connection.dart index b3b0a2dc72..a3f4e76231 100644 --- a/mobile/lib/features/pairing/add_community_page/add_community_connection.dart +++ b/mobile/lib/features/pairing/add_community_page/add_community_connection.dart @@ -78,7 +78,8 @@ class _AddCommunityConnection extends HookWidget { subtitle: enteringLink ? 'Paste an invite link or community address.' : onboarding - ? 'Join your people or create a space of your own.' + ? accountStatus ?? + 'Join your people or create a space of your own.' : accountStatus ?? 'Scan a QR code or enter a community link.', onBack: onBack, foregroundProgress: scannerProgress, diff --git a/mobile/lib/features/pairing/pairing_page.dart b/mobile/lib/features/pairing/pairing_page.dart index d0815f3b95..1764a5ea3e 100644 --- a/mobile/lib/features/pairing/pairing_page.dart +++ b/mobile/lib/features/pairing/pairing_page.dart @@ -1,19 +1,25 @@ import 'package:buzz/shared/widgets/feedback_snackbar.dart'; + import 'community_link_focus.dart'; import '../../shared/widgets/ios_community_link_input.dart'; + import 'dart:async'; + import '../../shared/hosted/hosted_flow_provider.dart'; import '../../shared/hosted/hosted_flow.dart'; import '../../shared/hosted/hosted_community_creation.dart'; import '../../shared/hosted/hosted_environment.dart'; import '../../shared/hosted/hosted_flow_problem_message.dart'; + import 'dart:math' show sqrt2; import 'dart:math' as math; import 'dart:ui' show ImageFilter; import 'package:flutter/material.dart'; import 'package:flutter/foundation.dart'; + import '../../shared/widgets/frosted_app_bar.dart'; + import 'package:flutter/services.dart'; import 'package:flutter/semantics.dart'; import 'package:flutter/gestures.dart'; @@ -316,7 +322,7 @@ class PairingPage extends HookConsumerWidget { ref.read(pendingDeepLinkProvider.notifier).open(uri); }, onSubmitPairing: (code) => unawaited(handleScannerResult(code)), - accountStatus: signedInOnboarding ? null : accountStatus, + accountStatus: accountStatus, busy: isBusy || builderlabBusy || diff --git a/mobile/lib/onboarding_app_entry.dart b/mobile/lib/onboarding_app_entry.dart index f350f60f7f..bac5678aa0 100644 --- a/mobile/lib/onboarding_app_entry.dart +++ b/mobile/lib/onboarding_app_entry.dart @@ -1,4 +1,5 @@ import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -12,6 +13,7 @@ import 'shared/hosted/use_known_communities.dart'; import 'shared/hosted/hosted_environment.dart'; import 'shared/hosted/hosted_community_creation.dart'; import 'shared/hosted/hosted_flow_problem_message.dart'; +import 'shared/widgets/confirmation_dialog.dart'; /// Navigation over the existing branded welcome surface. class OnboardingAppEntry extends HookConsumerWidget { @@ -93,7 +95,10 @@ class OnboardingAppEntry extends HookConsumerWidget { flow.accountOnly && (flow.stage == HostedFlowStage.working || flow.stage == HostedFlowStage.signIn); - final connected = flow?.account.account != null; + final account = flow?.account.account; + final connected = account != null; + final signedInLanding = connected && !addingCommunity && !signingIn; + final email = account?.email?.trim(); final hasKnownCommunities = useKnownCommunities( accountKey: flow?.account.account?.subject, active: @@ -113,11 +118,37 @@ class OnboardingAppEntry extends HookConsumerWidget { ? hostedFlowProblemMessage(problem) : busy ? null + : connected + ? email != null && email.isNotEmpty + ? 'Signed in as $email' + : 'Signed in to BuilderLab' : flow.noCommunitiesToAdd ? 'No communities available to add. Scan a QR code or use an invite link.' - : connected - ? 'Scan a QR code or enter a community link.' : null; + final confirmingSignOut = useRef(false); + Future confirmSignOut() async { + if (confirmingSignOut.value) return; + confirmingSignOut.value = true; + try { + final confirmed = await showDestructiveConfirmation( + context: context, + title: 'Sign out of BuilderLab?', + message: + '${email != null && email.isNotEmpty ? 'You’re signed in as $email. ' : ''}' + 'Your saved Buzz identities will stay on this device.', + confirmLabel: 'Sign out', + ); + if (!context.mounted || + !confirmed || + flow?.account.account?.subject != account?.subject) { + return; + } + await flow?.signOutAccount(); + } finally { + confirmingSignOut.value = false; + } + } + final handlingBack = useRef(false); void back() { // Nested page and entry PopScopes receive the same system Back event. @@ -126,6 +157,14 @@ class OnboardingAppEntry extends HookConsumerWidget { WidgetsBinding.instance.addPostFrameCallback((_) { handlingBack.value = false; }); + if (signedInLanding && + !busy && + !existingUser.value && + !enteringCommunityLink.value && + !creating.value) { + unawaited(confirmSignOut()); + return; + } if (!existingUser.value && !enteringCommunityLink.value && !creating.value && @@ -147,6 +186,7 @@ class OnboardingAppEntry extends HookConsumerWidget { return PopScope( canPop: + !signedInLanding && onExit == null && !existingUser.value && !enteringCommunityLink.value && @@ -161,7 +201,8 @@ class OnboardingAppEntry extends HookConsumerWidget { return; } if (!didPop && - (existingUser.value || + (signedInLanding || + existingUser.value || creating.value || enteringCommunityLink.value)) { back(); diff --git a/mobile/lib/shared/hosted/builderlab_account.dart b/mobile/lib/shared/hosted/builderlab_account.dart index 31bdd3eb69..b13cb0f679 100644 --- a/mobile/lib/shared/hosted/builderlab_account.dart +++ b/mobile/lib/shared/hosted/builderlab_account.dart @@ -1,4 +1,5 @@ import '../storage/serial_queue.dart'; + import 'dart:async'; import 'dart:convert'; @@ -37,10 +38,12 @@ class BuilderlabAccount { const BuilderlabAccount({ required this.subject, required this.expiresAt, + this.email, this.needsAccountSetup = false, }); final bool needsAccountSetup; final String subject; + final String? email; final DateTime expiresAt; } @@ -241,6 +244,7 @@ class BuilderlabAccountClient { result['account_status'] == 'ACCOUNT_STATUS_NEEDS_DETAILS'; return BuilderlabAccount( subject: subject, + email: result['email'] is String ? result['email'] as String : null, expiresAt: parsed, needsAccountSetup: needsSetup, ); @@ -336,14 +340,14 @@ class BuilderlabAccountClient { Future clearLocalSession() => _serial(_clear); Future _clear() async { - _credential = null; - _account = null; - if (!_disposed) _subject.value = null; try { await _storage.delete(key: _storageKey); } catch (_) { throw AccountFailure.storage; } + _credential = null; + _account = null; + if (!_disposed) _subject.value = null; } Future> _request( diff --git a/mobile/lib/shared/hosted/hosted_flow.dart b/mobile/lib/shared/hosted/hosted_flow.dart index 1a74c563d1..13128d252d 100644 --- a/mobile/lib/shared/hosted/hosted_flow.dart +++ b/mobile/lib/shared/hosted/hosted_flow.dart @@ -1005,6 +1005,21 @@ class HostedFlow extends ChangeNotifier { addingCommunityName = null; } + /// Ends the account session without removing saved identities or communities. + Future signOutAccount() async { + await leave(); + final generation = _generation; + if (_disposed || stage != HostedFlowStage.idle) return; + accountOnly = true; + _set(HostedFlowStage.working); + await _enqueue(() async { + await account.clearLocalSession(); + if (_disposed || generation != _generation) return; + accountOnly = false; + _set(HostedFlowStage.idle); + }); + } + Future leave({bool noCommunities = false}) async { _nextGeneration(); final generation = _generation; diff --git a/mobile/test/features/onboarding/hosted_journeys_test.dart b/mobile/test/features/onboarding/hosted_journeys_test.dart index c240a034f8..dc4ed6e2e2 100644 --- a/mobile/test/features/onboarding/hosted_journeys_test.dart +++ b/mobile/test/features/onboarding/hosted_journeys_test.dart @@ -1078,12 +1078,7 @@ void main() { await f.tap(tester, 'Try again'); expect(f.flow.stage, HostedFlowStage.idle); expect(f.probes, isEmpty); - expect( - find.text( - 'No communities available to add. Scan a QR code or use an invite link.', - ), - findsOneWidget, - ); + expect(find.text('Signed in to BuilderLab'), findsOneWidget); expect(find.text('Scan a QR code'), findsOneWidget); expect(find.text('Use pairing code'), findsNothing); f.rows.addAll([f.row('one'), f.row('two')]); diff --git a/mobile/test/features/onboarding/onboarding_sign_out_test.dart b/mobile/test/features/onboarding/onboarding_sign_out_test.dart new file mode 100644 index 0000000000..40ea0b53e6 --- /dev/null +++ b/mobile/test/features/onboarding/onboarding_sign_out_test.dart @@ -0,0 +1,69 @@ +import 'package:buzz/onboarding_app_entry.dart'; +import 'package:buzz/shared/hosted/hosted_environment.dart'; +import 'package:buzz/shared/hosted/hosted_flow_provider.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers/hosted_communities_fixture.dart'; +import '../../helpers/widget_helpers.dart'; + +void main() { + testWidgets('signed-in landing confirms sign-out and keeps saved identity', ( + tester, + ) async { + if (!HostedEnvironment.enabled) return; + await tester.binding.setSurfaceSize(const Size(440, 956)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + FlutterSecureStorage.setMockInitialValues({}); + final fixture = HostedCommunitiesFixture(); + await fixture.initialize(); + addTearDown(fixture.dispose); + await fixture.flow.leave(noCommunities: true); + await tester.pumpWidget( + WidgetHelpers.testable( + disableAnimations: true, + overrides: [hostedFlowProvider.overrideWithValue(fixture.flow)], + child: const OnboardingAppEntry(), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Signed in as owner@example.com'), findsOneWidget); + + // Back within a form navigates without offering sign-out. + await tester.tap(find.text('Enter community link')); + await tester.pumpAndSettle(); + await tester.tap(find.byTooltip('Back')); + await tester.pumpAndSettle(); + expect(find.text('Sign out of BuilderLab?'), findsNothing); + expect(fixture.account.account, isNotNull); + + await tester.tap(find.byTooltip('Back')); + await tester.pumpAndSettle(); + expect(find.text('Sign out of BuilderLab?'), findsOneWidget); + expect(find.textContaining('owner@example.com'), findsNWidgets(2)); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(fixture.account.account, isNotNull); + expect(find.text('Add a community'), findsOneWidget); + + // System Back follows the same confirmation path. + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + await tester.tap(find.text('Sign out')); + await tester.pumpAndSettle(); + expect(find.text('Add a community'), findsNothing); + expect(find.text('or sign in with email').hitTestable(), findsOneWidget); + expect(fixture.account.account, isNull); + expect(await fixture.account.restore(), isNull); + expect((await fixture.store.read())!.pubkey, fixture.keys.public); + expect((await fixture.store.read())!.backup, isNotNull); + + await tester.tap(find.text('or sign in with email').hitTestable()); + await tester.pumpAndSettle(); + expect(fixture.browser.startedIntent, {'type': 'account'}); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pumpAndSettle(); + }); +} diff --git a/mobile/test/helpers/hosted_communities_fixture.dart b/mobile/test/helpers/hosted_communities_fixture.dart index 5b00911ea8..f74d143c83 100644 --- a/mobile/test/helpers/hosted_communities_fixture.dart +++ b/mobile/test/helpers/hosted_communities_fixture.dart @@ -120,7 +120,11 @@ class HostedCommunitiesFixture { if (path.endsWith('/me')) { if (expired) return http.Response('{}', 401); return http.Response( - jsonEncode({'subject': subject, 'expires_at': expiry}), + jsonEncode({ + 'subject': subject, + 'email': 'owner@example.com', + 'expires_at': expiry, + }), 200, ); } diff --git a/mobile/test/shared/hosted/builderlab_account_test.dart b/mobile/test/shared/hosted/builderlab_account_test.dart index e9a09f0912..30309044e4 100644 --- a/mobile/test/shared/hosted/builderlab_account_test.dart +++ b/mobile/test/shared/hosted/builderlab_account_test.dart @@ -1,5 +1,7 @@ import 'dart:async'; + import 'hosted_http_fixture.dart'; + import 'dart:convert'; import 'package:buzz/shared/hosted/builderlab_account.dart'; @@ -42,18 +44,39 @@ void main() { } expect(request.headers['X-BB-Session-Credential'], 'secret'); return http.Response( - jsonEncode({'subject': 'account-1', 'expires_at': expiry}), + jsonEncode({ + 'subject': 'account-1', + 'email': 'owner@example.com', + 'expires_at': expiry, + }), 200, ); }); - final client = BuilderlabAccountClient(httpClient: transport); + final storage = _SessionStorage(); + final client = BuilderlabAccountClient( + httpClient: transport, + storage: storage, + ); expect( (await client.exchange(code: 'one-use', verifier: verifier)).subject, 'account-1', ); - final restored = BuilderlabAccountClient(httpClient: transport); - expect((await restored.restore())!.subject, 'account-1'); + final restored = BuilderlabAccountClient( + httpClient: transport, + storage: storage, + ); + final restoredAccount = (await restored.restore())!; + expect(restoredAccount.subject, 'account-1'); + expect(restoredAccount.email, 'owner@example.com'); expect(requests.length, 3); + storage.failDelete = true; + await expectLater( + restored.clearLocalSession(), + throwsA(AccountFailure.storage), + ); + expect(restored.account?.subject, 'account-1'); + expect(await client.restore(), isNotNull); + storage.failDelete = false; await restored.clearLocalSession(); expect( await BuilderlabAccountClient(httpClient: transport).restore(), @@ -358,3 +381,21 @@ void main() { ); }); } + +class _SessionStorage extends FlutterSecureStorage { + bool failDelete = false; + + @override + Future delete({ + required String key, + AppleOptions? iOptions, + AndroidOptions? aOptions, + LinuxOptions? lOptions, + WebOptions? webOptions, + AppleOptions? mOptions, + WindowsOptions? wOptions, + }) async { + if (failDelete) throw StateError('storage unavailable'); + await super.delete(key: key); + } +}