From 33eaa73c8313ff1520b8e25fd33c4f0e1c04f58f Mon Sep 17 00:00:00 2001 From: John Matthew Tennant Date: Tue, 3 Nov 2026 13:25:54 -0500 Subject: [PATCH] fix(mobile): let users sign out after leaving all communities (#8993) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Fixes a dead-end in signed-in mobile onboarding: after leaving every community, Back kept returning to the same Add a community screen without a way to end the account session. Show “Signed in as [email]” on that screen. Back asks “Sign out of BuilderLab?” with the email; Cancel keeps the session, and Sign out returns to the welcome screen while retaining saved Buzz identities. Nested Back and Add community from an existing session keep their navigation behavior. ### Related issue None found in the existing issue and PR search for onboarding sign-out. ### Testing The new widget journey failed before the change and passes through the real onboarding widgets, account client, storage and flow with external HTTP/browser boundaries mocked. It covers nested Back, Cancel, system Back, durable session removal, retained identity/backup, and starting email sign-in again. The existing hosted onboarding journey also covers accounts without an email. On iPhone 18 Pro simulator (iOS 27), rendered the production onboarding screen and native confirmation using a disposable account fixture. Live browser account selection has not been verified; this clears the app session, not website cookies. To reproduce on an auth-enabled mobile build: sign in, leave all communities, check the displayed email, tap Back, and choose Cancel. Tap Back again and choose Sign out. The yellow welcome screen should appear, with email sign-in available. ## Screenshots Before, from the original reported phone screen: Before: account state not shown After: Signed-in landing and native sign-out confirmation, using a disposable account: Signed-in landing Sign-out confirmation --------- Signed-off-by: Aaron Signed-off-by: John Tennant Co-authored-by: Aaron --- .github/workflows/_ci-clients.yml | 1 + .../add_community_connection.dart | 3 +- mobile/lib/features/pairing/pairing_page.dart | 8 ++- mobile/lib/onboarding_app_entry.dart | 49 +++++++++++-- .../lib/shared/hosted/builderlab_account.dart | 10 ++- mobile/lib/shared/hosted/hosted_flow.dart | 15 ++++ .../onboarding/hosted_journeys_test.dart | 7 +- .../onboarding/onboarding_sign_out_test.dart | 69 +++++++++++++++++++ .../helpers/hosted_communities_fixture.dart | 6 +- .../hosted/builderlab_account_test.dart | 49 +++++++++++-- 10 files changed, 197 insertions(+), 20 deletions(-) create mode 100644 mobile/test/features/onboarding/onboarding_sign_out_test.dart 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); + } +}