Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions desktop/src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -758,6 +758,7 @@ function MachineBootstrap({ sharedIdentity }: { sharedIdentity: boolean }) {
complete={completeMachineOnboarding}
continueWithIdentity={machine.continueWithIdentity}
continueWithRecoveredIdentity={machine.continueWithRecoveredIdentity}
existingIdentityPubkey={machine.existingIdentityPubkey}
identityLost={machine.identityLost}
initialPage={machineInitialPage}
navigateAfterComplete={navigateAfterOnboarding}
Expand Down
12 changes: 12 additions & 0 deletions desktop/src/features/onboarding/machineOnboarding.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import assert from "node:assert/strict";
import test from "node:test";

import {
existingMachineIdentityPubkey,
migrateMachineOnboardingCompletion,
readMachineOnboardingCompletion,
} from "./machineOnboarding.ts";
Expand Down Expand Up @@ -46,6 +47,17 @@ const PUBKEY_B =
const LEGACY_KEY = `buzz-onboarding-complete.v1:${PUBKEY_A}`;
const V2_KEY = `buzz-machine-onboarding-complete.v2:${PUBKEY_A}`;

test("existing community state presents the recovered identity as a continuation", () => {
assert.equal(existingMachineIdentityPubkey(PUBKEY_A, null), PUBKEY_A);
assert.equal(existingMachineIdentityPubkey(PUBKEY_A, PUBKEY_A), PUBKEY_A);
assert.equal(existingMachineIdentityPubkey(PUBKEY_A, PUBKEY_B), PUBKEY_A);
});

test("blank first launch does not present the generated identity as recovered", () => {
assert.equal(existingMachineIdentityPubkey(PUBKEY_A, undefined), null);
assert.equal(existingMachineIdentityPubkey(null, null), null);
});

// ── Fix A regression case ────────────────────────────────────────────────────

test("migrate_mismatched_community_pubkey_does_not_vouch_for_current_key", () => {
Expand Down
22 changes: 22 additions & 0 deletions desktop/src/features/onboarding/machineOnboarding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,24 @@ export function readMachineOnboardingCompletion(pubkey: string | null) {
);
}

/**
* Return the already-loaded identity when persisted community state proves this
* is an upgrade/recovery onboarding pass rather than a blank first launch.
*
* This is deliberately presentation-only: an unstamped or mismatched community
* still cannot vouch for onboarding completion in
* `migrateMachineOnboardingCompletion`. It only prevents the landing action
* from claiming that it will create a key when the native layer has already
* recovered the user's current key.
*/
export function existingMachineIdentityPubkey(
currentPubkey: string | null,
activeCommunityPubkey: string | null | undefined,
) {
if (!currentPubkey || activeCommunityPubkey === undefined) return null;
return currentPubkey;
}

function clearMachineOnboardingCompletion(pubkey: string | null) {
if (typeof window === "undefined" || !pubkey) return;
window.localStorage.removeItem(
Expand Down Expand Up @@ -250,6 +268,10 @@ export function useMachineOnboardingState({
continueWithIdentity,
continueWithRecoveredIdentity,
currentPubkey,
existingIdentityPubkey: existingMachineIdentityPubkey(
currentPubkey,
activeCommunityPubkey,
),
identityLost,
queryClient,
reopen,
Expand Down
48 changes: 45 additions & 3 deletions desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ export function MachineOnboardingFlow({
complete,
continueWithIdentity,
continueWithRecoveredIdentity,
existingIdentityPubkey,
identityLost,
initialPage,
queryClient,
Expand All @@ -72,6 +73,7 @@ export function MachineOnboardingFlow({
complete: (pubkey?: string) => void;
continueWithIdentity: (pubkey: string) => void;
continueWithRecoveredIdentity: (pubkey: string) => void;
existingIdentityPubkey?: string | null;
identityLost: boolean;
initialPage?: MachineOnboardingPage;
queryClient: QueryClient;
Expand Down Expand Up @@ -100,7 +102,7 @@ export function MachineOnboardingFlow({
>(null);
const [phoneRecoveryStep, setPhoneRecoveryStep] = React.useState("loading");
const [selectedPubkey, setSelectedPubkey] = React.useState<string | null>(
null,
existingIdentityPubkey ?? null,
);
const [identityStorage, setIdentityStorage] = React.useState<
IdentityStorage | undefined
Expand Down Expand Up @@ -172,6 +174,27 @@ export function MachineOnboardingFlow({
}
}, [continueWithRecoveredIdentity, queryClient]);

const continueWithExistingIdentity = React.useCallback(async () => {
setIsPending(true);
setError(null);
try {
const identity = await getIdentity();
continueWithIdentity(identity.pubkey);
queryClient.setQueryData(["identity"], identity);
setIdentityWasImported(false);
setSelectedPubkey(identity.pubkey);
setIdentityStorage(identity.storage);
setTransitionDirection("forward");
setPage("setup");
} catch (cause) {
setError(
cause instanceof Error ? cause.message : "Failed to load identity",
);
} finally {
setIsPending(false);
}
}, [continueWithIdentity, queryClient]);

const replaceLostIdentity = React.useCallback(async () => {
const confirmed = window.confirm(
"This will create a new identity and abandon your previous key. This cannot be undone. Continue?",
Expand Down Expand Up @@ -236,6 +259,15 @@ export function MachineOnboardingFlow({
}, [backupSession]);

const backFromSetup = React.useCallback(() => {
if (
existingIdentityPubkey &&
selectedPubkey === existingIdentityPubkey &&
!identityWasImported
) {
setTransitionDirection("backward");
setPage("identity");
return;
}
if (identityWasImported) {
setKeyImportFormKey((current) => current + 1);
setKeyImportStage("key-entry");
Expand All @@ -250,7 +282,13 @@ export function MachineOnboardingFlow({
setTransitionDirection("backward");
setReturningFromSecurity(false);
setPage("backup");
}, [backupSession, backupSubview, identityWasImported]);
}, [
backupSession,
backupSubview,
existingIdentityPubkey,
identityWasImported,
selectedPubkey,
]);

const chromeBackAction =
page === "key-import" &&
Expand Down Expand Up @@ -327,7 +365,11 @@ export function MachineOnboardingFlow({
<Button
className={ONBOARDING_LANDING_CTA_CLASS}
disabled={isPending}
onClick={() => void loadFreshIdentity()}
onClick={() =>
void (selectedPubkey
? continueWithExistingIdentity()
: loadFreshIdentity())
}
type="button"
>
{isPending
Expand Down
9 changes: 4 additions & 5 deletions desktop/tests/e2e/harness-management.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@
import { expect, test } from "@playwright/test";

import { installMockBridge } from "../helpers/bridge";
import { passThroughBackupStep } from "../helpers/onboarding";

// ── Shared catalog fixtures ───────────────────────────────────────────────────

Expand Down Expand Up @@ -669,10 +668,10 @@ test("onboarding setup More-harnesses click navigates to Settings → Agents", a
});
await page.goto("/");

// Reach setup by creating a new identity key and continuing past the
// created-key page without opening the optional backup options.
await page.getByRole("button", { name: "Create a new identity key" }).click();
await passThroughBackupStep(page);
// The native bridge has already recovered the machine identity, while the
// foreign community deliberately cannot vouch for onboarding completion.
// Continue with that recovered identity instead of creating/replacing it.
await page.getByRole("button", { name: "Continue setup" }).click();

// Now on the setup page.
await expect(
Expand Down