fix(mobile): let users sign out after leaving all communities (#8993)

## 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:

<img
src="https://raw.githubusercontent.com/block/buzz/07440b3b10c3f48a6fd19c923f2ee5ba66d574f0/pr-8993--before.png"
alt="Before: account state not shown" width="300">

After:

Signed-in landing and native sign-out confirmation, using a disposable
account:

<img
src="https://raw.githubusercontent.com/block/buzz/a4e18c85bc3a19d971b08ff595ba6b081c67e20f/pr-8993--landing.png"
alt="Signed-in landing" width="300"> <img
src="https://raw.githubusercontent.com/block/buzz/a4e18c85bc3a19d971b08ff595ba6b081c67e20f/pr-8993--confirmation.png"
alt="Sign-out confirmation" width="300">

---------

Signed-off-by: Aaron <f425b86c3b780d43f8d9cf1802429d04dd985bf92eac44fca9468b5ad3887c5e@buzz.block.builderlab.xyz>
Signed-off-by: John Tennant <jtennant@squareup.com>
Co-authored-by: Aaron <f425b86c3b780d43f8d9cf1802429d04dd985bf92eac44fca9468b5ad3887c5e@buzz.block.builderlab.xyz>
This commit is contained in:
John Matthew Tennant
2026-11-03 13:25:54 -05:00
committed by GitHub
parent d3a4909cca
commit 33eaa73c83
10 changed files with 197 additions and 20 deletions

View File

@@ -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

View File

@@ -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,

View File

@@ -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 ||

View File

@@ -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<void> 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();

View File

@@ -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<void> clearLocalSession() => _serial(_clear);
Future<void> _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<Map<String, dynamic>> _request(

View File

@@ -1005,6 +1005,21 @@ class HostedFlow extends ChangeNotifier {
addingCommunityName = null;
}
/// Ends the account session without removing saved identities or communities.
Future<void> 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<void> leave({bool noCommunities = false}) async {
_nextGeneration();
final generation = _generation;

View File

@@ -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')]);

View File

@@ -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();
});
}

View File

@@ -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,
);
}

View File

@@ -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<void> 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);
}
}