mirror of
https://github.com/block/buzz.git
synced 2026-11-04 08:21:48 +00:00
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:
committed by
GitHub
parent
d3a4909cca
commit
33eaa73c83
1
.github/workflows/_ci-clients.yml
vendored
1
.github/workflows/_ci-clients.yml
vendored
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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')]);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user