mirror of
https://github.com/block/buzz.git
synced 2026-11-08 10:21:46 +00:00
## Summary This fixes a dead end in mobile Builderlab sign-in. A new account (unbound, with no backups) on a device that already had saved identities could only pick one of those identities. Any saved key forced the picker, and creating a key required the device to have none. If the service then definitively rejected binding the chosen key, the app showed "Let's try that again" with no way forward: the chosen key stayed in the account's slot, so every retry and every launch re-sent the same rejected bind. For an unclaimed account, the picker now also offers **Create a new identity**: - Creation writes only this account's empty identity slot, so every saved and device key is kept. - Creation is offered only when every current device key was among the choices shown, so a new identity never silently passes over a key the user didn't see. - Bound accounts, accounts with backups, pairing and pending import never offer it. When binding a chosen key is definitively rejected, the blocked screen offers **Choose a different identity**: - It appears only for a 400, 404, 409 or 422 from `/verify` in the current attempt, for the current account and pubkey, and never during pairing, whose transferred key is the only choice. Evidence from an earlier attempt is cleared before each bind. A challenge rejection, a 403, a 503 or any other ambiguous failure offers nothing. - Before releasing the slot, it rechecks the account: still unbound (`/current` and status), no backups, the same subject, and a slot that still holds that pubkey without a receipt. - It releases the slot only when another durable copy of the same key exists (the device inventory or another account slot), then verifies the slot is empty. No key is deleted: only the account's pointer to the rejected choice is cleared. A failed or refused release keeps the slot and the choice, so it can be retried in place or after a restart. - After release, the picker runs again and offers the saved keys plus creation. The recovery code lives in `hosted_flow_identity_recovery.dart`, a part of `hosted_flow.dart`, to stay within the mobile file-size limit. ### Related issue None found. ### Testing **Physical iPhone, a signed release build of `1fb633d55` installed over the existing app (keychain and saved identities kept):** a saved identity was rejected, then Choose a different identity, the picker, Create a new identity and Join your community. Joining then completed. This head differs from `1fb633d55` only in the guards for leaving, pairing and a failed or refused release listed below, which this journey does not reach, and in no UI code. The status of that phone's original rejection was not captured, so it is not confirmed to be one of the rejections above. The 409 case is covered by tests. **Tests (red on `main`, green here):** - Identity service and store: creation beside offered keys; refusal for unseen or partly offered device keys; release when another copy exists; refusal for a sole copy, a binding, a backup, a receipt, another account or an unusable recheck; failed delete and retry; a cancelled attempt. - Onboarding journeys: create beside saved keys; select, rejected bind, restart, choose a different identity, create and bind; ambiguous 503 and 403 failures; challenge rejection and stale evidence. - Release boundaries: an account switch at `/current`, at status, or between the recheck and the delete; cancelling between the recheck and the delete; leaving the picker before a late creation; an unreadable device inventory; leaving while a release is in flight; a failed delete retried in place and after a restart; a release refused because the inventory is unreadable, then retried in place; a rejected paired key. Each keeps every key, leaves the new account's slot untouched and leaves a cancelled flow idle, and a retry converges. - Mutating the creation guard, the generation check, the binding recheck, the store's receipt check, the account and cancellation checks before the delete, or the device-inventory error handling fails a test. #### Screenshots Physical iPhone at `1fb633d55` (frames from a screen recording; saved public keys redacted): | Rejected bind | After Choose a different identity | After Create a new identity | | --- | --- | --- | |  |  |  | Widget-test renders of the same screens (the real `HostedFlow` against a mocked account service, with a fixture key; the square at the top left is the test renderer's placeholder for an asset): | Picker on an unclaimed account | Rejected bind | | --- | --- | |  |  | 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: John Tennant <jtennant@squareup.com> Co-authored-by: Xander <ff30c859b96bd5233ffaf5d071200458f1274bd0dab03589214cf1055198491d@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
312 lines
12 KiB
Dart
312 lines
12 KiB
Dart
import 'package:flutter/material.dart';
|
||
import 'package:flutter/cupertino.dart';
|
||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||
|
||
import '../../shared/hosted/hosted_flow.dart';
|
||
import '../../shared/widgets/onboarding_step_page.dart';
|
||
import '../../shared/widgets/bee_loading_mark.dart';
|
||
import '../../shared/widgets/onboarding_loading_page.dart';
|
||
import '../../shared/identity_transfer/nsec_import_sheet.dart';
|
||
import '../../shared/hosted/hosted_flow_problem_message.dart';
|
||
import '../../shared/utils/string_utils.dart';
|
||
import '../../shared/theme/grid.dart';
|
||
import '../../shared/theme/theme_extensions.dart';
|
||
import 'hosted_community_selection.dart';
|
||
import 'hosted_join_policy_view.dart';
|
||
import 'hosted_profile_setup.dart';
|
||
|
||
/// Presents the app-scoped real flow without retaining credentials or keys.
|
||
class HostedConnectSheet extends HookConsumerWidget {
|
||
const HostedConnectSheet({
|
||
super.key,
|
||
required this.flow,
|
||
this.onAddCommunity,
|
||
this.fullScreen = false,
|
||
this.onBack,
|
||
this.presentedStage,
|
||
});
|
||
final HostedFlow flow;
|
||
final VoidCallback? onAddCommunity;
|
||
final bool fullScreen;
|
||
final VoidCallback? onBack;
|
||
final HostedFlowStage? presentedStage;
|
||
|
||
@override
|
||
Widget build(BuildContext context, WidgetRef ref) {
|
||
useListenable(flow);
|
||
final stage = presentedStage ?? flow.stage;
|
||
final showLinkedIdentity = useState(false);
|
||
Future<void> importIdentity() async {
|
||
Future<void>? pendingImport;
|
||
Future<void> save(String nsec) =>
|
||
pendingImport = flow.importIdentity(nsec);
|
||
const mismatch =
|
||
'This key doesn’t match the identity linked to your Builderlab account.';
|
||
if (fullScreen) {
|
||
await Navigator.of(context).push<bool>(
|
||
CupertinoPageRoute(
|
||
builder: (_) => NsecImportSheet(
|
||
onImport: save,
|
||
mismatchMessage: mismatch,
|
||
fullScreen: true,
|
||
),
|
||
),
|
||
);
|
||
} else {
|
||
await showNsecImportSheet(
|
||
context,
|
||
mismatchMessage: mismatch,
|
||
onImport: save,
|
||
);
|
||
}
|
||
// Dismissing the sheet does not cancel a secure-store write in flight.
|
||
// Finish that operation before resuming, even when the modal returned null.
|
||
if (pendingImport == null) return;
|
||
try {
|
||
await pendingImport;
|
||
} catch (_) {
|
||
return; // The sheet already presented the import failure.
|
||
}
|
||
if (context.mounted && flow.stage == HostedFlowStage.setupIdentity) {
|
||
await flow.retry();
|
||
}
|
||
}
|
||
|
||
final actionStyle = FilledButton.styleFrom(
|
||
minimumSize: const Size.fromHeight(52),
|
||
shape: const StadiumBorder(),
|
||
);
|
||
final working = stage == HostedFlowStage.working;
|
||
final message = switch (stage) {
|
||
HostedFlowStage.idle => 'Continue connecting your account.',
|
||
HostedFlowStage.working =>
|
||
flow.addingCommunityName == null
|
||
? 'Securing your account and finding communities…'
|
||
: 'Adding ${flow.addingCommunityName}…',
|
||
HostedFlowStage.setupIdentity =>
|
||
flow.requiredIdentityPubkey == null
|
||
? flow.canCreateIdentity
|
||
? 'Create a new Buzz identity, or import the identity you already use.'
|
||
: 'Bring your existing Buzz identity from your other device.'
|
||
: 'Your identity hasn’t been backed up yet. Bring it from your other device to keep using the same Buzz account.',
|
||
HostedFlowStage.chooseCommunities => 'Select a community',
|
||
HostedFlowStage.communityConflict =>
|
||
'${flow.addingCommunityName} is already saved with a different identity. '
|
||
'Keep that identity and skip this addition, or return to your choices?',
|
||
HostedFlowStage.chooseIdentity =>
|
||
flow.identityChoices.where((choice) => choice.fromCloud).length > 1
|
||
? 'Choose a backed-up identity to connect to Builderlab. Your other backups and saved community keys will be kept. This app cannot change the selected Builderlab identity later.'
|
||
: flow.identityChoices.any((choice) => choice.fromCloud)
|
||
? 'Use your Builderlab identity on this device? It differs from your saved community identities. Those keys and your cloud backups will be kept. This app cannot change the selected Builderlab identity later.'
|
||
: 'Choose the identity to connect to Builderlab${flow.canCreateIdentity ? ', or create a new one' : ''}. It will be bound to your account and backed up. Existing keys will be kept. This app cannot change the selected Builderlab identity later.',
|
||
HostedFlowStage.profile =>
|
||
'What should people in this community call you? You can add a photo in Settings.',
|
||
HostedFlowStage.acceptPolicy =>
|
||
'Review this community’s terms and privacy policy before joining.',
|
||
HostedFlowStage.signIn => 'Complete sign-in in your browser.',
|
||
HostedFlowStage.browserReturned =>
|
||
'Continue setup in your browser. Open your community in Buzz when it is ready.',
|
||
HostedFlowStage.complete => 'Your community is ready.',
|
||
HostedFlowStage.blocked => hostedFlowProblemMessage(flow.problem),
|
||
};
|
||
if (fullScreen && stage == HostedFlowStage.chooseCommunities) {
|
||
return HostedCommunitySelection(
|
||
flow: flow,
|
||
fullScreen: true,
|
||
onAddCommunity: onAddCommunity,
|
||
onBack: onBack,
|
||
);
|
||
}
|
||
if (fullScreen &&
|
||
stage == HostedFlowStage.acceptPolicy &&
|
||
flow.joinPolicy != null) {
|
||
return HostedJoinPolicyView(
|
||
key: ValueKey(flow.joinPolicy!.version),
|
||
policy: flow.joinPolicy!,
|
||
onAccept: flow.acceptJoinPolicy,
|
||
fullScreen: true,
|
||
onBack: onBack,
|
||
);
|
||
}
|
||
final children = <Widget>[
|
||
if (!fullScreen && stage == HostedFlowStage.setupIdentity) ...[
|
||
Text(
|
||
flow.requiredIdentityPubkey == null
|
||
? flow.canCreateIdentity
|
||
? 'Create or import a Buzz identity'
|
||
: 'Import your Buzz identity'
|
||
: 'Bring your identity with you',
|
||
style: context.textTheme.titleLarge?.copyWith(
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
const SizedBox(height: Grid.twelve),
|
||
],
|
||
if (!fullScreen) Text(message, style: context.textTheme.bodyLarge),
|
||
const SizedBox(height: Grid.sm),
|
||
for (final choice in flow.identityChoices)
|
||
Padding(
|
||
padding: const EdgeInsets.only(bottom: Grid.sm),
|
||
child: OutlinedButton(
|
||
onPressed: () => flow.chooseIdentity(choice.pubkey),
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(Grid.sm),
|
||
child: Column(
|
||
children: [
|
||
Text(choice.label),
|
||
Text(fullNpub(choice.pubkey) ?? choice.pubkey),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
if (stage == HostedFlowStage.chooseIdentity && flow.canCreateIdentity)
|
||
FilledButton(
|
||
style: actionStyle,
|
||
onPressed: flow.createIdentity,
|
||
child: const Text('Create a new identity'),
|
||
),
|
||
if (stage == HostedFlowStage.setupIdentity) ...[
|
||
if (flow.canCreateIdentity) ...[
|
||
FilledButton(
|
||
style: actionStyle,
|
||
onPressed: flow.createIdentity,
|
||
child: const Text('Create my Buzz identity'),
|
||
),
|
||
const SizedBox(height: Grid.xxs),
|
||
],
|
||
FilledButton(
|
||
style: actionStyle.copyWith(
|
||
backgroundColor: WidgetStatePropertyAll(context.colors.primary),
|
||
foregroundColor: WidgetStatePropertyAll(context.colors.onPrimary),
|
||
),
|
||
onPressed: importIdentity,
|
||
child: const Text('Import existing identity'),
|
||
),
|
||
const SizedBox(height: Grid.twelve),
|
||
Text(
|
||
'We’ll securely back it up so your next sign-in is easier.',
|
||
textAlign: TextAlign.center,
|
||
style: context.textTheme.bodySmall,
|
||
),
|
||
if (flow.requiredIdentityPubkey != null) ...[
|
||
const SizedBox(height: Grid.xxs),
|
||
TextButton(
|
||
onPressed: () =>
|
||
showLinkedIdentity.value = !showLinkedIdentity.value,
|
||
child: Text(
|
||
showLinkedIdentity.value
|
||
? 'Hide linked identity'
|
||
: 'View linked identity',
|
||
),
|
||
),
|
||
if (showLinkedIdentity.value)
|
||
SelectableText(
|
||
fullNpub(flow.requiredIdentityPubkey!) ??
|
||
flow.requiredIdentityPubkey!,
|
||
style: context.textTheme.bodySmall,
|
||
),
|
||
],
|
||
],
|
||
if (stage == HostedFlowStage.profile && flow.saveProfile != null)
|
||
HostedProfileSetup(save: flow.saveProfile!, skip: flow.finishProfile),
|
||
if (stage == HostedFlowStage.acceptPolicy && flow.joinPolicy != null)
|
||
HostedJoinPolicyView(
|
||
key: ValueKey(flow.joinPolicy!.version),
|
||
policy: flow.joinPolicy!,
|
||
onAccept: flow.acceptJoinPolicy,
|
||
),
|
||
if (stage == HostedFlowStage.chooseCommunities)
|
||
HostedCommunitySelection(flow: flow, onAddCommunity: onAddCommunity),
|
||
if (stage == HostedFlowStage.communityConflict)
|
||
FilledButton(
|
||
onPressed: flow.skipConflictingCommunity,
|
||
child: const Text('Keep saved identity and skip'),
|
||
),
|
||
if (flow.canChooseDifferentIdentity)
|
||
FilledButton(
|
||
onPressed: flow.chooseDifferentIdentity,
|
||
child: const Text('Choose a different identity'),
|
||
),
|
||
if (flow.canSkipFailedCommunity)
|
||
TextButton(
|
||
onPressed: flow.skipFailedCommunity,
|
||
child: const Text('Skip this community'),
|
||
),
|
||
if (flow.canChangeCommunities)
|
||
TextButton(
|
||
onPressed: flow.backToCommunityChoices,
|
||
child: const Text('Back to choices'),
|
||
),
|
||
if ((stage == HostedFlowStage.blocked ||
|
||
stage == HostedFlowStage.communityConflict) &&
|
||
flow.addedCommunityCount > 0)
|
||
Text(
|
||
'${flow.addedCommunityCount} '
|
||
'${flow.addedCommunityCount == 1 ? "community" : "communities"} added and kept.'
|
||
'${flow.canRetry && stage == HostedFlowStage.blocked ? " Retry continues the remaining additions." : ""}',
|
||
),
|
||
if (working && !fullScreen) const Center(child: BeeLoadingMark()),
|
||
if (!working &&
|
||
stage != HostedFlowStage.profile &&
|
||
stage != HostedFlowStage.acceptPolicy &&
|
||
stage != HostedFlowStage.chooseIdentity &&
|
||
stage != HostedFlowStage.setupIdentity &&
|
||
stage != HostedFlowStage.chooseCommunities &&
|
||
stage != HostedFlowStage.communityConflict &&
|
||
stage != HostedFlowStage.complete &&
|
||
stage != HostedFlowStage.browserReturned &&
|
||
flow.canRetry)
|
||
FilledButton(
|
||
onPressed: flow.retry,
|
||
child: Text(
|
||
stage == HostedFlowStage.signIn ? 'Start login again' : 'Try again',
|
||
),
|
||
),
|
||
];
|
||
if (!fullScreen) {
|
||
return SingleChildScrollView(
|
||
padding: const EdgeInsets.all(Grid.gutter),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: children,
|
||
),
|
||
);
|
||
}
|
||
final title = switch (stage) {
|
||
HostedFlowStage.setupIdentity =>
|
||
flow.requiredIdentityPubkey == null
|
||
? flow.canCreateIdentity
|
||
? 'Create or import a Buzz identity'
|
||
: 'Import your Buzz identity'
|
||
: 'Bring your identity with you',
|
||
HostedFlowStage.chooseIdentity => 'Choose your identity',
|
||
HostedFlowStage.working =>
|
||
flow.addingCommunityName == null
|
||
? 'Getting ready…'
|
||
: 'Joining your community',
|
||
HostedFlowStage.blocked =>
|
||
flow.failedCommunityName == null
|
||
? 'Let’s try that again'
|
||
: 'Couldn’t open ${flow.failedCommunityName}',
|
||
HostedFlowStage.communityConflict => 'Keep your saved identity',
|
||
HostedFlowStage.browserReturned => 'Continue in your browser',
|
||
_ => 'Connect to Builderlab',
|
||
};
|
||
if (working) return OnboardingLoadingPage(title: title, onCancel: onBack);
|
||
bool isAction(Widget widget) => widget is ButtonStyleButton;
|
||
return OnboardingStepPage(
|
||
title: title,
|
||
subtitle: message,
|
||
onBack: onBack ?? () {},
|
||
content: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: children.where((widget) => !isAction(widget)).toList(),
|
||
),
|
||
actions: children.where(isAction).toList(),
|
||
);
|
||
}
|
||
}
|