From 7d402f47505247fd83d6bd8f88e39a684849c521 Mon Sep 17 00:00:00 2001 From: npub1rf6fvdj6ut0c4kcmjv4p5mmgh89nj58n69uu3fz3cvk3jn500hqs7emz79 <1a7496365ae2df8adb1b932a1a6f68b9cb3950f3d179c8a451c32d194e8f7dc1@sprout-oss.stage.blox.sqprod.co> Date: Sun, 19 Jul 2026 09:38:40 -0700 Subject: [PATCH 1/6] Add owned community onboarding Co-authored-by: npub1rf6fvdj6ut0c4kcmjv4p5mmgh89nj58n69uu3fz3cvk3jn500hqs7emz79 <1a7496365ae2df8adb1b932a1a6f68b9cb3950f3d179c8a451c32d194e8f7dc1@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub1rf6fvdj6ut0c4kcmjv4p5mmgh89nj58n69uu3fz3cvk3jn500hqs7emz79 <1a7496365ae2df8adb1b932a1a6f68b9cb3950f3d179c8a451c32d194e8f7dc1@sprout-oss.stage.blox.sqprod.co> --- .../communities/hostedCommunityApi.ts | 152 +++++++ .../ui/HostedCommunityOnboarding.tsx | 386 ++++++++++++++++++ .../features/communities/ui/WelcomeSetup.tsx | 19 +- .../ui/HostedCommunitiesSettingsCard.tsx | 99 +---- desktop/src/testing/e2eBridge.ts | 59 +++ desktop/tests/e2e/onboarding.spec.ts | 142 ++++++- desktop/tests/helpers/bridge.ts | 12 + 7 files changed, 758 insertions(+), 111 deletions(-) create mode 100644 desktop/src/features/communities/hostedCommunityApi.ts create mode 100644 desktop/src/features/communities/ui/HostedCommunityOnboarding.tsx diff --git a/desktop/src/features/communities/hostedCommunityApi.ts b/desktop/src/features/communities/hostedCommunityApi.ts new file mode 100644 index 0000000000..7a268a0901 --- /dev/null +++ b/desktop/src/features/communities/hostedCommunityApi.ts @@ -0,0 +1,152 @@ +import { invoke } from "@tauri-apps/api/core"; + +export const HOSTED_COMMUNITY_SUFFIX = "communities.buzz.xyz"; +export const HOSTED_COMMUNITY_LIMIT = 3; +export const VALID_HOSTED_COMMUNITY_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +export type BuilderlabAuth = { + email?: string; + name?: string; + expiresAt: string; +}; + +export type HostedCommunityApiError = { + code?: string; + message?: string; + setup_needed?: boolean; +}; + +export type HostedNostrIdentity = { + npub?: string; + pubkey_hex?: string; +}; + +export type HostedIdentityResponse = { + identity?: HostedNostrIdentity; + error?: HostedCommunityApiError; + correlation_id?: string; +}; + +export type HostedCommunity = { + id?: string; + name?: string; + slug?: string; + normalized_host?: string; + owner_pubkey?: string; + archived_at?: string | null; +}; + +export type HostedCommunitiesResponse = { + communities?: HostedCommunity[]; + error?: HostedCommunityApiError; + correlation_id?: string; +}; + +export type HostedCommunityAvailabilityResponse = { + available?: boolean; + normalized_host?: string; + error?: HostedCommunityApiError; + correlation_id?: string; +}; + +export type HostedCommunityMutationResponse = { + community?: HostedCommunity; + error?: HostedCommunityApiError; + correlation_id?: string; +}; + +export type HostedCommunityAccount = { + communities: HostedCommunity[]; + identity: HostedNostrIdentity | null; +}; + +export function hostedCommunityErrorMessage( + error: HostedCommunityApiError | undefined, + correlationId: string | undefined, + fallback: string, +) { + const messages: Record = { + missing_mapping: "Connect your Buzz identity before creating a community.", + invalid_name: "Use lowercase letters, numbers, and hyphens.", + taken: "That Buzz address is already taken.", + limit_reached: `You've reached the limit of ${HOSTED_COMMUNITY_LIMIT} hosted communities.`, + relay_unavailable: "Community provisioning is temporarily unavailable.", + identity_already_bound: + "This Builderlab account is connected to another Buzz identity.", + pubkey_already_bound: + "This Buzz identity is connected to another Builderlab account.", + not_owner: "Only the community owner can do that.", + transferee_not_registered: + "That person needs a connected Buzz identity before you can transfer ownership to them.", + }; + const message = messages[error?.code ?? ""] ?? error?.message ?? fallback; + return correlationId + ? `${message} Correlation ID: ${correlationId}` + : message; +} + +export function hostedCommunityRelayUrl(community: HostedCommunity) { + const host = community.normalized_host?.trim(); + return host ? `wss://${host.replace(/^wss?:\/\//, "")}` : null; +} + +export function getBuilderlabAuth() { + return invoke("get_builderlab_auth"); +} + +export function startBuilderlabLogin() { + return invoke("start_builderlab_login"); +} + +export async function loadHostedCommunityAccount(): Promise { + const [identityResponse, communitiesResponse] = await Promise.all([ + invoke("get_builderlab_nostr_identity"), + invoke("list_builderlab_communities"), + ]); + if ( + identityResponse.error && + identityResponse.error.code !== "unauthorized" && + !identityResponse.error.setup_needed + ) { + throw new Error( + hostedCommunityErrorMessage( + identityResponse.error, + identityResponse.correlation_id, + "Could not load the connected Buzz identity.", + ), + ); + } + if (communitiesResponse.error && !communitiesResponse.error.setup_needed) { + throw new Error( + hostedCommunityErrorMessage( + communitiesResponse.error, + communitiesResponse.correlation_id, + "Could not load communities.", + ), + ); + } + return { + identity: identityResponse.identity ?? null, + communities: communitiesResponse.communities ?? [], + }; +} + +export function bindBuilderlabIdentity() { + return invoke("bind_builderlab_nostr_identity"); +} + +export function checkHostedCommunityName(name: string) { + return invoke( + "check_builderlab_community_name", + { name }, + ); +} + +export function createHostedCommunity(name: string) { + return invoke( + "create_builderlab_community", + { + name, + }, + ); +} diff --git a/desktop/src/features/communities/ui/HostedCommunityOnboarding.tsx b/desktop/src/features/communities/ui/HostedCommunityOnboarding.tsx new file mode 100644 index 0000000000..6ee77870e2 --- /dev/null +++ b/desktop/src/features/communities/ui/HostedCommunityOnboarding.tsx @@ -0,0 +1,386 @@ +import * as React from "react"; +import { AlertCircle, CheckCircle2, LoaderCircle } from "lucide-react"; + +import { + bindBuilderlabIdentity, + checkHostedCommunityName, + createHostedCommunity, + getBuilderlabAuth, + HOSTED_COMMUNITY_LIMIT, + HOSTED_COMMUNITY_SUFFIX, + hostedCommunityErrorMessage, + hostedCommunityRelayUrl, + type BuilderlabAuth, + type HostedCommunity, + type HostedNostrIdentity, + loadHostedCommunityAccount, + startBuilderlabLogin, + VALID_HOSTED_COMMUNITY_NAME, +} from "@/features/communities/hostedCommunityApi"; +import { useCommunityOnboarding } from "@/features/onboarding/communityOnboarding"; +import { useIdentityQuery } from "@/shared/api/hooks"; +import { safeNpub } from "@/shared/lib/nostrUtils"; +import { Button } from "@/shared/ui/button"; +import { Input } from "@/shared/ui/input"; +import { OnboardingFooter } from "@/features/onboarding/ui/OnboardingFooter"; + +export function HostedCommunityOnboarding({ onBack }: { onBack: () => void }) { + const onboarding = useCommunityOnboarding(); + const localPubkey = useIdentityQuery().data?.pubkey ?? null; + const [auth, setAuth] = React.useState(null); + const [identity, setIdentity] = React.useState( + null, + ); + const [communities, setCommunities] = React.useState([]); + const [name, setName] = React.useState(""); + const [availability, setAvailability] = React.useState(null); + const [checkingName, setCheckingName] = React.useState(false); + const [loading, setLoading] = React.useState(true); + const [action, setAction] = React.useState(null); + const [error, setError] = React.useState(null); + + const loadAccount = React.useCallback(async () => { + const account = await loadHostedCommunityAccount(); + setIdentity(account.identity); + setCommunities(account.communities); + }, []); + + React.useEffect(() => { + let active = true; + void getBuilderlabAuth() + .then(async (nextAuth) => { + if (!active) return; + setAuth(nextAuth); + if (nextAuth) await loadAccount(); + }) + .catch((cause) => { + if (active) + setError(cause instanceof Error ? cause.message : String(cause)); + }) + .finally(() => { + if (active) setLoading(false); + }); + return () => { + active = false; + }; + }, [loadAccount]); + + const run = async (label: string, operation: () => Promise) => { + setAction(label); + setError(null); + try { + await operation(); + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)); + } finally { + setAction(null); + } + }; + + const signIn = () => + run("Signing in…", async () => { + const nextAuth = await startBuilderlabLogin(); + setAuth(nextAuth); + await loadAccount(); + }); + + const connectIdentity = () => + run("Connecting identity…", async () => { + const response = await bindBuilderlabIdentity(); + if (response.error) { + throw new Error( + hostedCommunityErrorMessage( + response.error, + response.correlation_id, + "Could not connect the Buzz identity.", + ), + ); + } + setIdentity(response.identity ?? null); + await loadAccount(); + }); + + const boundPubkey = identity?.pubkey_hex ?? null; + const identityMismatch = Boolean( + identity && + boundPubkey && + localPubkey && + boundPubkey.toLowerCase() !== localPubkey.toLowerCase(), + ); + const localNpub = localPubkey ? safeNpub(localPubkey) : null; + const activeCommunities = communities.filter( + (community) => !community.archived_at && hostedCommunityRelayUrl(community), + ); + const normalizedName = name.trim().toLowerCase(); + const validName = + normalizedName.length <= 63 && + VALID_HOSTED_COMMUNITY_NAME.test(normalizedName); + const atCommunityLimit = communities.length >= HOSTED_COMMUNITY_LIMIT; + + React.useEffect(() => { + if (!identity || identityMismatch || !normalizedName || !validName) { + setCheckingName(false); + return; + } + let cancelled = false; + setCheckingName(true); + const handle = window.setTimeout(() => { + void checkHostedCommunityName(normalizedName) + .then((response) => { + if (!cancelled) + setAvailability( + response.error ? null : (response.available ?? false), + ); + }) + .catch(() => { + if (!cancelled) setAvailability(null); + }) + .finally(() => { + if (!cancelled) setCheckingName(false); + }); + }, 500); + return () => { + cancelled = true; + window.clearTimeout(handle); + }; + }, [identity, identityMismatch, normalizedName, validName]); + + const connect = (community: HostedCommunity) => { + const relayUrl = hostedCommunityRelayUrl(community); + if (!relayUrl) return; + onboarding.start({ + source: "first-community", + relayUrl, + communityName: community.name ?? community.slug, + }); + }; + + const create = (event: React.FormEvent) => { + event.preventDefault(); + if (!validName || !identity || identityMismatch || atCommunityLimit) return; + void run("Creating community…", async () => { + const available = await checkHostedCommunityName(normalizedName); + if (available.error || !available.available) { + setAvailability(false); + throw new Error( + hostedCommunityErrorMessage( + available.error, + available.correlation_id, + "That Buzz address is already taken.", + ), + ); + } + const response = await createHostedCommunity(normalizedName); + if (response.error || !response.community) { + throw new Error( + hostedCommunityErrorMessage( + response.error, + response.correlation_id, + "Could not create the community.", + ), + ); + } + connect(response.community); + }); + }; + + const busy = action !== null; + + return ( +
+

Your communities

+

+ Sign in to connect a community you already own on this machine, or + create a new one. +

+ +
+ {error ? ( +
+ + {error} +
+ ) : null} + + {loading ? ( +
+ + Checking Builderlab sign-in +
+ ) : !auth ? ( +
+

Sign in or create an account

+

+ Builderlab provides Block-hosted Buzz communities. Authentication + opens in your browser and returns here. +

+ +
+ ) : !identity ? ( +
+

Connect this Buzz identity

+

+ Link this device’s Buzz key to{" "} + {auth.email ?? auth.name ?? "your Builderlab account"}. Buzz signs + a one-time challenge locally; your private key never leaves + Desktop. +

+ +
+ ) : identityMismatch ? ( +
+
+ +
+

+ This account uses a different Buzz identity +

+

+ Sign in with the account connected to this device, or switch + the identity from Hosted communities settings on a configured + Buzz installation. +

+

+ Account: {identity.npub ?? boundPubkey} +
+ This device: {localNpub ?? localPubkey} +

+
+
+
+ ) : ( + <> +
+ + Signed in{auth.email ? ` as ${auth.email}` : ""} with this Buzz + identity +
+ + {activeCommunities.length > 0 ? ( +
+

Connect to one you own

+
    + {activeCommunities.map((community, index) => ( +
  • +
    +

    + {community.name ?? + community.slug ?? + "Hosted community"} +

    +

    + {community.normalized_host} +

    +
    + +
  • + ))} +
+
+ ) : null} + +
+

Create a new community

+

+ Choose the address your team will use to connect. +

+
+ { + setName(event.target.value.toLowerCase()); + setAvailability(null); + }} + placeholder="north-star" + spellCheck={false} + value={name} + /> + + .{HOSTED_COMMUNITY_SUFFIX} + +
+ {atCommunityLimit ? ( +

+ You’ve reached the limit of {HOSTED_COMMUNITY_LIMIT} hosted + communities. +

+ ) : name && !validName ? ( +

+ Use lowercase letters, numbers, and single hyphens. +

+ ) : checkingName ? ( +

+ Checking availability… +

+ ) : availability === false ? ( +

+ That address is already taken. +

+ ) : availability === true ? ( +

+ That address is available. +

+ ) : null} + +
+ + )} +
+ + + + +
+ ); +} diff --git a/desktop/src/features/communities/ui/WelcomeSetup.tsx b/desktop/src/features/communities/ui/WelcomeSetup.tsx index 1df1f3d42a..a4e7354ac3 100644 --- a/desktop/src/features/communities/ui/WelcomeSetup.tsx +++ b/desktop/src/features/communities/ui/WelcomeSetup.tsx @@ -1,5 +1,4 @@ import * as React from "react"; -import { openUrl } from "@tauri-apps/plugin-opener"; import { Check, Copy } from "lucide-react"; import { useCommunityOnboarding } from "@/features/onboarding/communityOnboarding"; @@ -25,9 +24,10 @@ import { Input } from "@/shared/ui/input"; import { StartupWindowDragRegion } from "@/shared/ui/StartupWindowDragRegion"; import { useSystemColorScheme } from "@/shared/theme/useSystemColorScheme"; import { OnboardingChrome } from "@/features/onboarding/ui/OnboardingChrome"; +import { HostedCommunityOnboarding } from "@/features/communities/ui/HostedCommunityOnboarding"; import { writeTextToClipboard } from "@/shared/lib/clipboard"; -type WelcomeSetupPage = "welcome" | "join" | "invite"; +type WelcomeSetupPage = "welcome" | "join" | "invite" | "owned"; type WelcomeTransitionMode = "initial" | OnboardingTransitionDirection; type WelcomeSetupProps = { @@ -36,7 +36,6 @@ type WelcomeSetupProps = { onBack: () => void; }; -const CREATE_COMMUNITY_URL = "https://app.builderlab.xyz/signup?returnTo=/buzz"; const COMMUNITY_OPTION_CARD_CLASS = "flex min-h-24 w-full max-w-[352px] items-center justify-center rounded-xl bg-white/75 px-6 py-4 text-center text-sm font-normal leading-6 text-foreground transition-colors duration-150 ease-out hover:bg-white/85 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-foreground/35"; @@ -152,10 +151,12 @@ export function WelcomeSetup({ @@ -170,6 +171,14 @@ export function WelcomeSetup({ + ) : page === "owned" ? ( + + showPage("welcome")} /> + ) : page === "join" ? ( = { - missing_mapping: "Connect your Buzz identity before creating a community.", - invalid_name: "Use lowercase letters, numbers, and hyphens.", - taken: "That Buzz address is already taken.", - limit_reached: `You've reached the limit of ${MAX_COMMUNITIES} hosted communities.`, - relay_unavailable: "Community provisioning is temporarily unavailable.", - identity_already_bound: - "This Builderlab account is connected to another Buzz identity.", - pubkey_already_bound: - "This Buzz identity is connected to another Builderlab account.", - not_owner: "Only the community owner can do that.", - transferee_not_registered: - "That person needs a connected Buzz identity before you can transfer ownership to them.", - }; - const message = messages[error?.code ?? ""] ?? error?.message ?? fallback; - return correlationId - ? `${message} Correlation ID: ${correlationId}` - : message; -} - -function relayUrl(community: HostedCommunity) { - const host = community.normalized_host?.trim(); - return host ? `wss://${host.replace(/^wss?:\/\//, "")}` : null; -} - export function HostedCommunitiesSettingsCard() { const onboarding = useCommunityOnboarding(); const localPubkey = useIdentityQuery().data?.pubkey ?? null; diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 83c84a40c7..97fc1b6be6 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -125,6 +125,22 @@ type MockSearchProfileSeed = { type E2eConfig = { mode?: "mock" | "relay"; mock?: { + /** Builderlab account returned by hosted-community onboarding. Null/omitted = signed out. */ + builderlabAuth?: { + email?: string; + name?: string; + expiresAt: string; + } | null; + /** Bound Builderlab Nostr identity. Null/omitted = not linked yet. */ + builderlabIdentity?: { npub?: string; pubkey_hex?: string } | null; + /** Communities owned by the mocked Builderlab account. */ + builderlabCommunities?: Array<{ + id?: string; + name?: string; + slug?: string; + normalized_host?: string; + archived_at?: string | null; + }>; acpRuntimesCatalog?: RawAcpRuntimeCatalogEntry[]; acpRuntimesDelayMs?: number; acpAuthMethods?: Record; @@ -8764,6 +8780,49 @@ export function maybeInstallE2eTauriMocks() { window.__BUZZ_E2E_COMMAND_LOG__?.push({ command, payload }); switch (command) { + case "get_builderlab_auth": + return activeConfig?.mock?.builderlabAuth ?? null; + case "start_builderlab_login": { + const nextAuth = activeConfig?.mock?.builderlabAuth ?? { + email: "owner@example.com", + expiresAt: "2099-01-01T00:00:00Z", + }; + if (activeConfig?.mock) activeConfig.mock.builderlabAuth = nextAuth; + return nextAuth; + } + case "get_builderlab_nostr_identity": + return activeConfig?.mock?.builderlabIdentity + ? { identity: activeConfig.mock.builderlabIdentity } + : { error: { code: "missing_mapping", setup_needed: true } }; + case "bind_builderlab_nostr_identity": { + const activeIdentity = identity ?? DEFAULT_MOCK_IDENTITY; + const nextIdentity = { + pubkey_hex: activeIdentity.pubkey, + npub: `npub1${activeIdentity.pubkey}`, + }; + if (activeConfig?.mock) + activeConfig.mock.builderlabIdentity = nextIdentity; + return { identity: nextIdentity }; + } + case "list_builderlab_communities": + return { + communities: activeConfig?.mock?.builderlabCommunities ?? [], + }; + case "check_builderlab_community_name": + return { + available: true, + normalized_host: `${(payload as { name?: string })?.name ?? "community"}.communities.buzz.xyz`, + }; + case "create_builderlab_community": { + const name = (payload as { name?: string })?.name ?? "community"; + return { + community: { + id: `hosted-${name}`, + name, + normalized_host: `${name}.communities.buzz.xyz`, + }, + }; + } case "mesh_installed_models": return mockMeshState.models; case "mesh_node_status": diff --git a/desktop/tests/e2e/onboarding.spec.ts b/desktop/tests/e2e/onboarding.spec.ts index 46dd8ea9c5..7a0c253c62 100644 --- a/desktop/tests/e2e/onboarding.spec.ts +++ b/desktop/tests/e2e/onboarding.spec.ts @@ -617,29 +617,20 @@ test("first-community choices expose npub and invite input", async ({ page.getByRole("button", { name: "I have an invite link" }), ).toBeVisible(); await expect( - page.getByRole("button", { name: "I want to create a community" }), + page.getByRole("button", { + name: "Create or connect to my own community", + }), ).toBeVisible(); await page - .getByRole("button", { name: "I want to create a community" }) + .getByRole("button", { name: "Create or connect to my own community" }) .click(); - await expect - .poll(() => - page.evaluate(() => { - const log = ( - window as Window & { - __BUZZ_E2E_COMMAND_LOG__?: Array<{ - command: string; - payload: unknown; - }>; - } - ).__BUZZ_E2E_COMMAND_LOG__; - return log?.find((entry) => entry.command === "plugin:opener|open_url") - ?.payload; - }), - ) - .toMatchObject({ - url: "https://app.builderlab.xyz/signup?returnTo=/buzz", - }); + await expect( + page.getByRole("heading", { name: "Your communities" }), + ).toBeVisible(); + await expect( + page.getByRole("button", { name: "Continue with Builderlab" }), + ).toBeVisible(); + await page.getByRole("button", { name: "Back" }).click(); await page.getByRole("button", { name: "Add me to a community" }).click(); await expect(page.getByTestId("welcome-join-npub")).toHaveText( @@ -754,6 +745,113 @@ test("first-community choices expose npub and invite input", async ({ await expect(invalidInviteTip).toHaveCSS("opacity", "0"); }); +test("first-community owner can connect an existing hosted community", async ({ + page, +}) => { + await seedActiveIdentity(page, BLANK_TYLER_IDENTITY); + await page.addInitScript((pubkey) => { + window.localStorage.setItem( + `buzz-machine-onboarding-complete.v2:${pubkey}`, + "true", + ); + }, BLANK_TYLER_IDENTITY.pubkey); + await installMockBridge( + page, + { + builderlabAuth: { + email: "owner@example.com", + expiresAt: "2099-01-01T00:00:00Z", + }, + builderlabIdentity: { pubkey_hex: BLANK_TYLER_IDENTITY.pubkey }, + builderlabCommunities: [ + { + id: "owned-community", + name: "North Star", + normalized_host: "north-star.communities.buzz.xyz", + }, + ], + }, + { + relayWsUrl: "wss://default.example.com", + skipOnboardingSeed: true, + skipCommunitySeed: true, + }, + ); + await page.goto("/"); + + await page + .getByRole("button", { name: "Create or connect to my own community" }) + .click(); + await expect(page.getByText("North Star")).toBeVisible(); + await page.getByRole("button", { name: "Connect", exact: true }).click(); + await expect( + page.getByRole("heading", { name: "Build your profile" }), + ).toBeVisible(); + await expect + .poll(() => + page.evaluate(() => + window.localStorage.getItem("buzz-community-onboarding-transaction.v1"), + ), + ) + .toContain('"source":"first-community"'); + await expect + .poll(() => + page.evaluate(() => + window.localStorage.getItem("buzz-community-onboarding-transaction.v1"), + ), + ) + .toContain("wss://north-star.communities.buzz.xyz"); +}); + +test("first-community owner can create and connect a hosted community", async ({ + page, +}) => { + await seedActiveIdentity(page, BLANK_TYLER_IDENTITY); + await page.addInitScript((pubkey) => { + window.localStorage.setItem( + `buzz-machine-onboarding-complete.v2:${pubkey}`, + "true", + ); + }, BLANK_TYLER_IDENTITY.pubkey); + await installMockBridge( + page, + {}, + { + relayWsUrl: "wss://default.example.com", + skipOnboardingSeed: true, + skipCommunitySeed: true, + }, + ); + await page.goto("/"); + + await page + .getByRole("button", { name: "Create or connect to my own community" }) + .click(); + await page.getByRole("button", { name: "Continue with Builderlab" }).click(); + await expect( + page.getByRole("heading", { name: "Connect this Buzz identity" }), + ).toBeVisible(); + await page + .getByRole("button", { name: "Connect this Buzz identity" }) + .click(); + await expect(page.getByText("Signed in as owner@example.com")).toBeVisible(); + await page + .getByRole("textbox", { name: "Community address" }) + .fill("bee-lab"); + await expect(page.getByText("That address is available.")).toBeVisible(); + await page.getByRole("button", { name: "Create and connect" }).click(); + await expect( + page.getByRole("heading", { name: "Build your profile" }), + ).toBeVisible(); + await expect + .poll(() => + page.evaluate(() => + window.localStorage.getItem("buzz-community-onboarding-transaction.v1"), + ), + ) + .toContain("wss://bee-lab.communities.buzz.xyz"); +}); + test("first-community shows the scenario cards for localhost", async ({ page, }) => { @@ -781,7 +879,9 @@ test("first-community shows the scenario cards for localhost", async ({ page.getByRole("button", { name: "I have an invite link" }), ).toBeVisible(); await expect( - page.getByRole("button", { name: "I want to create a community" }), + page.getByRole("button", { + name: "Create or connect to my own community", + }), ).toBeVisible(); await page.getByTestId("welcome-setup-back").click(); diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index f4fe926817..4736f39ce3 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -127,6 +127,18 @@ export type MockAgentMemoryListing = { }; type MockBridgeOptions = { + /** Builderlab account returned by hosted-community onboarding. Null/omitted = signed out. */ + builderlabAuth?: { email?: string; name?: string; expiresAt: string } | null; + /** Bound Builderlab Nostr identity. Null/omitted = not linked yet. */ + builderlabIdentity?: { npub?: string; pubkey_hex?: string } | null; + /** Communities owned by the mocked Builderlab account. */ + builderlabCommunities?: Array<{ + id?: string; + name?: string; + slug?: string; + normalized_host?: string; + archived_at?: string | null; + }>; acpRuntimesCatalog?: Record[]; acpRuntimesDelayMs?: number; acpAuthMethods?: Record[] }>; From a582bd953605a7ce5b833d6aac1b76cd4cac68ba Mon Sep 17 00:00:00 2001 From: npub1rf6fvdj6ut0c4kcmjv4p5mmgh89nj58n69uu3fz3cvk3jn500hqs7emz79 <1a7496365ae2df8adb1b932a1a6f68b9cb3950f3d179c8a451c32d194e8f7dc1@sprout-oss.stage.blox.sqprod.co> Date: Sun, 19 Jul 2026 10:09:26 -0700 Subject: [PATCH 2/6] Harden hosted onboarding recovery Co-authored-by: npub1rf6fvdj6ut0c4kcmjv4p5mmgh89nj58n69uu3fz3cvk3jn500hqs7emz79 <1a7496365ae2df8adb1b932a1a6f68b9cb3950f3d179c8a451c32d194e8f7dc1@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub1rf6fvdj6ut0c4kcmjv4p5mmgh89nj58n69uu3fz3cvk3jn500hqs7emz79 <1a7496365ae2df8adb1b932a1a6f68b9cb3950f3d179c8a451c32d194e8f7dc1@sprout-oss.stage.blox.sqprod.co> --- .../communities/hostedCommunityApi.ts | 8 ++ .../ui/HostedCommunityOnboarding.tsx | 76 +++++++++- desktop/src/testing/e2eBridge.ts | 10 ++ desktop/tests/e2e/onboarding.spec.ts | 134 ++++++++++++++++++ 4 files changed, 224 insertions(+), 4 deletions(-) diff --git a/desktop/src/features/communities/hostedCommunityApi.ts b/desktop/src/features/communities/hostedCommunityApi.ts index 7a268a0901..310fd2ec88 100644 --- a/desktop/src/features/communities/hostedCommunityApi.ts +++ b/desktop/src/features/communities/hostedCommunityApi.ts @@ -94,6 +94,10 @@ export function getBuilderlabAuth() { return invoke("get_builderlab_auth"); } +export function clearBuilderlabAuth() { + return invoke("clear_builderlab_auth"); +} + export function startBuilderlabLogin() { return invoke("start_builderlab_login"); } @@ -135,6 +139,10 @@ export function bindBuilderlabIdentity() { return invoke("bind_builderlab_nostr_identity"); } +export function deleteBuilderlabIdentity() { + return invoke("delete_builderlab_nostr_identity"); +} + export function checkHostedCommunityName(name: string) { return invoke( "check_builderlab_community_name", diff --git a/desktop/src/features/communities/ui/HostedCommunityOnboarding.tsx b/desktop/src/features/communities/ui/HostedCommunityOnboarding.tsx index 6ee77870e2..9ecc9e2863 100644 --- a/desktop/src/features/communities/ui/HostedCommunityOnboarding.tsx +++ b/desktop/src/features/communities/ui/HostedCommunityOnboarding.tsx @@ -4,7 +4,9 @@ import { AlertCircle, CheckCircle2, LoaderCircle } from "lucide-react"; import { bindBuilderlabIdentity, checkHostedCommunityName, + clearBuilderlabAuth, createHostedCommunity, + deleteBuilderlabIdentity, getBuilderlabAuth, HOSTED_COMMUNITY_LIMIT, HOSTED_COMMUNITY_SUFFIX, @@ -84,6 +86,27 @@ export function HostedCommunityOnboarding({ onBack }: { onBack: () => void }) { await loadAccount(); }); + const signOut = () => + run("Signing out…", async () => { + await clearBuilderlabAuth(); + setAuth(null); + setIdentity(null); + setCommunities([]); + setName(""); + setAvailability(null); + }); + + const goBack = () => { + if (!auth) { + onBack(); + return; + } + void run("Signing out…", async () => { + await clearBuilderlabAuth(); + onBack(); + }); + }; + const connectIdentity = () => run("Connecting identity…", async () => { const response = await bindBuilderlabIdentity(); @@ -108,6 +131,36 @@ export function HostedCommunityOnboarding({ onBack }: { onBack: () => void }) { boundPubkey.toLowerCase() !== localPubkey.toLowerCase(), ); const localNpub = localPubkey ? safeNpub(localPubkey) : null; + + const switchToDeviceIdentity = () => + run("Switching identity…", async () => { + const released = await deleteBuilderlabIdentity(); + if (released.error) { + throw new Error( + hostedCommunityErrorMessage( + released.error, + released.correlation_id, + "Could not disconnect the account's previous Buzz identity.", + ), + ); + } + const bound = await bindBuilderlabIdentity(); + if (bound.error) { + await loadAccount(); + throw new Error( + bound.error.code === "pubkey_already_bound" + ? "This device's Buzz identity belongs to a different Builderlab account and can't be moved from here. Sign out, then sign in with the account that already owns this identity." + : hostedCommunityErrorMessage( + bound.error, + bound.correlation_id, + "Could not connect this device's Buzz identity.", + ), + ); + } + setIdentity(bound.identity ?? null); + await loadAccount(); + }); + const activeCommunities = communities.filter( (community) => !community.archived_at && hostedCommunityRelayUrl(community), ); @@ -250,15 +303,30 @@ export function HostedCommunityOnboarding({ onBack }: { onBack: () => void }) { This account uses a different Buzz identity

- Sign in with the account connected to this device, or switch - the identity from Hosted communities settings on a configured - Buzz installation. + This Builderlab account is connected to another Buzz identity. + You can disconnect that identity and reconnect this device, or + sign out to use a different email.

Account: {identity.npub ?? boundPubkey}
This device: {localNpub ?? localPubkey}

+
+ + +
@@ -374,7 +442,7 @@ export function HostedCommunityOnboarding({ onBack }: { onBack: () => void }) { + {action === "Signing in…" ? ( +
+
+ + Waiting for your browser… +
+ +
+ ) : ( + + )} ) : !identity ? (
@@ -360,7 +400,11 @@ export function HostedCommunityOnboarding({ onBack }: { onBack: () => void }) {