mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Add owned community onboarding (#2123)
Signed-off-by: npub1rf6fvdj6ut0c4kcmjv4p5mmgh89nj58n69uu3fz3cvk3jn500hqs7emz79 <1a7496365ae2df8adb1b932a1a6f68b9cb3950f3d179c8a451c32d194e8f7dc1@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1rf6fvdj6ut0c4kcmjv4p5mmgh89nj58n69uu3fz3cvk3jn500hqs7emz79 <1a7496365ae2df8adb1b932a1a6f68b9cb3950f3d179c8a451c32d194e8f7dc1@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
co-authored by
npub1rf6fvdj6ut0c4kcmjv4p5mmgh89nj58n69uu3fz3cvk3jn500hqs7emz79
parent
bf5acabdde
commit
cd0e7402d7
@@ -24,6 +24,14 @@ const BUILDERLAB_ORIGIN: &str = "https://app.builderlab.xyz";
|
||||
#[derive(Default)]
|
||||
pub(crate) struct BuilderlabSession(Mutex<Option<StoredSession>>);
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct BuilderlabLogin(Mutex<Option<PendingLogin>>);
|
||||
|
||||
struct PendingLogin {
|
||||
id: uuid::Uuid,
|
||||
cancel: oneshot::Sender<()>,
|
||||
}
|
||||
|
||||
struct StoredSession {
|
||||
credential: String,
|
||||
}
|
||||
@@ -130,6 +138,7 @@ pub(crate) async fn start_builderlab_login(
|
||||
app: tauri::AppHandle,
|
||||
app_state: tauri::State<'_, crate::app_state::AppState>,
|
||||
session: tauri::State<'_, BuilderlabSession>,
|
||||
login: tauri::State<'_, BuilderlabLogin>,
|
||||
) -> Result<BuilderlabAuthInfo, String> {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
@@ -158,19 +167,38 @@ pub(crate) async fn start_builderlab_login(
|
||||
return Err(format!("could not open Builderlab authentication: {error}"));
|
||||
}
|
||||
|
||||
let exchange_code = match tokio::time::timeout(LOGIN_TIMEOUT, receiver).await {
|
||||
Ok(Ok(Ok(code))) => code,
|
||||
Ok(Ok(Err(error))) => {
|
||||
server.abort();
|
||||
return Err(error);
|
||||
let login_id = uuid::Uuid::new_v4();
|
||||
let (cancel_sender, mut cancel_receiver) = oneshot::channel();
|
||||
{
|
||||
let mut pending = login.0.lock().map_err(|error| error.to_string())?;
|
||||
if let Some(previous) = pending.take() {
|
||||
let _ = previous.cancel.send(());
|
||||
}
|
||||
Ok(Err(_)) => {
|
||||
*pending = Some(PendingLogin {
|
||||
id: login_id,
|
||||
cancel: cancel_sender,
|
||||
});
|
||||
}
|
||||
|
||||
let exchange_code = tokio::select! {
|
||||
result = tokio::time::timeout(LOGIN_TIMEOUT, receiver) => match result {
|
||||
Ok(Ok(Ok(code))) => code,
|
||||
Ok(Ok(Err(error))) => {
|
||||
server.abort();
|
||||
return Err(error);
|
||||
}
|
||||
Ok(Err(_)) => {
|
||||
server.abort();
|
||||
return Err("local authentication callback stopped unexpectedly".to_owned());
|
||||
}
|
||||
Err(_) => {
|
||||
server.abort();
|
||||
return Err("Builderlab authentication timed out".to_owned());
|
||||
}
|
||||
},
|
||||
_ = &mut cancel_receiver => {
|
||||
server.abort();
|
||||
return Err("local authentication callback stopped unexpectedly".to_owned());
|
||||
}
|
||||
Err(_) => {
|
||||
server.abort();
|
||||
return Err("Builderlab authentication timed out".to_owned());
|
||||
return Err("Builderlab authentication canceled".to_owned());
|
||||
}
|
||||
};
|
||||
server.abort();
|
||||
@@ -206,6 +234,16 @@ pub(crate) async fn start_builderlab_login(
|
||||
email: me.email,
|
||||
name: me.name,
|
||||
};
|
||||
{
|
||||
let mut pending = login.0.lock().map_err(|error| error.to_string())?;
|
||||
if pending
|
||||
.as_ref()
|
||||
.is_none_or(|pending| pending.id != login_id)
|
||||
{
|
||||
return Err("Builderlab authentication canceled".to_owned());
|
||||
}
|
||||
*pending = None;
|
||||
}
|
||||
*session.0.lock().map_err(|error| error.to_string())? = Some(StoredSession {
|
||||
credential: exchanged.session_credential,
|
||||
});
|
||||
@@ -242,6 +280,16 @@ pub(crate) async fn get_builderlab_auth(
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn cancel_builderlab_login(
|
||||
login: tauri::State<'_, BuilderlabLogin>,
|
||||
) -> Result<(), String> {
|
||||
if let Some(pending) = login.0.lock().map_err(|error| error.to_string())?.take() {
|
||||
let _ = pending.cancel.send(());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn clear_builderlab_auth(
|
||||
session: tauri::State<'_, BuilderlabSession>,
|
||||
|
||||
@@ -426,9 +426,7 @@ pub fn run() {
|
||||
.build()
|
||||
});
|
||||
|
||||
// Only register the updater in release builds that were compiled with a
|
||||
// real updater configuration. Local unsigned builds omit that config and
|
||||
// should still launch for debugging.
|
||||
// Register the updater only in configured release builds; omit it locally.
|
||||
#[cfg(buzz_updater_enabled)]
|
||||
let builder = if cfg!(debug_assertions) {
|
||||
builder
|
||||
@@ -451,6 +449,7 @@ pub fn run() {
|
||||
.manage(ClipboardState::new())
|
||||
.manage(PendingCommunityDeepLinks::default())
|
||||
.manage(BuilderlabSession::default())
|
||||
.manage(BuilderlabLogin::default())
|
||||
.manage(commands::pairing::PairingHandle::new())
|
||||
.setup(move |app| {
|
||||
let app_handle = app.handle().clone();
|
||||
@@ -744,6 +743,7 @@ pub fn run() {
|
||||
take_pending_community_deep_link,
|
||||
acknowledge_pending_community_deep_link,
|
||||
start_builderlab_login,
|
||||
cancel_builderlab_login,
|
||||
get_builderlab_auth,
|
||||
clear_builderlab_auth,
|
||||
get_builderlab_nostr_identity,
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
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<string, string> = {
|
||||
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<BuilderlabAuth | null>("get_builderlab_auth");
|
||||
}
|
||||
|
||||
export function cancelBuilderlabLogin() {
|
||||
return invoke<void>("cancel_builderlab_login");
|
||||
}
|
||||
|
||||
export function clearBuilderlabAuth() {
|
||||
return invoke<void>("clear_builderlab_auth");
|
||||
}
|
||||
|
||||
export function startBuilderlabLogin() {
|
||||
return invoke<BuilderlabAuth>("start_builderlab_login");
|
||||
}
|
||||
|
||||
export async function loadHostedCommunityAccount(): Promise<HostedCommunityAccount> {
|
||||
const [identityResponse, communitiesResponse] = await Promise.all([
|
||||
invoke<HostedIdentityResponse>("get_builderlab_nostr_identity"),
|
||||
invoke<HostedCommunitiesResponse>("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<HostedIdentityResponse>("bind_builderlab_nostr_identity");
|
||||
}
|
||||
|
||||
export function deleteBuilderlabIdentity() {
|
||||
return invoke<HostedIdentityResponse>("delete_builderlab_nostr_identity");
|
||||
}
|
||||
|
||||
export function checkHostedCommunityName(name: string) {
|
||||
return invoke<HostedCommunityAvailabilityResponse>(
|
||||
"check_builderlab_community_name",
|
||||
{ name },
|
||||
);
|
||||
}
|
||||
|
||||
export function createHostedCommunity(name: string) {
|
||||
return invoke<HostedCommunityMutationResponse>(
|
||||
"create_builderlab_community",
|
||||
{
|
||||
name,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
import * as React from "react";
|
||||
import { AlertCircle, CheckCircle2, LoaderCircle } from "lucide-react";
|
||||
|
||||
import {
|
||||
bindBuilderlabIdentity,
|
||||
cancelBuilderlabLogin,
|
||||
checkHostedCommunityName,
|
||||
clearBuilderlabAuth,
|
||||
createHostedCommunity,
|
||||
deleteBuilderlabIdentity,
|
||||
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<BuilderlabAuth | null>(null);
|
||||
const [identity, setIdentity] = React.useState<HostedNostrIdentity | null>(
|
||||
null,
|
||||
);
|
||||
const [communities, setCommunities] = React.useState<HostedCommunity[]>([]);
|
||||
const [name, setName] = React.useState("");
|
||||
const [availability, setAvailability] = React.useState<boolean | null>(null);
|
||||
const [checkingName, setCheckingName] = React.useState(false);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [action, setAction] = React.useState<string | null>(null);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const loginAttempt = React.useRef(0);
|
||||
|
||||
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<void>) => {
|
||||
setAction(label);
|
||||
setError(null);
|
||||
try {
|
||||
await operation();
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : String(cause));
|
||||
} finally {
|
||||
setAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
const signIn = () => {
|
||||
const attempt = ++loginAttempt.current;
|
||||
setAction("Signing in…");
|
||||
setError(null);
|
||||
void startBuilderlabLogin()
|
||||
.then(async (nextAuth) => {
|
||||
if (loginAttempt.current !== attempt) return;
|
||||
setAuth(nextAuth);
|
||||
await loadAccount();
|
||||
})
|
||||
.catch((cause) => {
|
||||
if (loginAttempt.current !== attempt) return;
|
||||
setError(cause instanceof Error ? cause.message : String(cause));
|
||||
})
|
||||
.finally(() => {
|
||||
if (loginAttempt.current === attempt) setAction(null);
|
||||
});
|
||||
};
|
||||
|
||||
const cancelSignIn = () => {
|
||||
loginAttempt.current += 1;
|
||||
setAction(null);
|
||||
setError(null);
|
||||
void cancelBuilderlabLogin().catch((cause) => {
|
||||
setError(cause instanceof Error ? cause.message : String(cause));
|
||||
});
|
||||
};
|
||||
|
||||
const signOut = () =>
|
||||
run("Signing out…", async () => {
|
||||
await clearBuilderlabAuth();
|
||||
setAuth(null);
|
||||
setIdentity(null);
|
||||
setCommunities([]);
|
||||
setName("");
|
||||
setAvailability(null);
|
||||
});
|
||||
|
||||
const goBack = () => {
|
||||
void run("Signing out…", async () => {
|
||||
await clearBuilderlabAuth();
|
||||
onBack();
|
||||
});
|
||||
};
|
||||
|
||||
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 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),
|
||||
);
|
||||
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, created = false) => {
|
||||
const relayUrl = hostedCommunityRelayUrl(community);
|
||||
const retryPrefix = created ? "The community was created, but " : "";
|
||||
if (!relayUrl) {
|
||||
throw new Error(
|
||||
`${retryPrefix}Builderlab did not return its relay address. Try connecting it again, or contact support if it does not appear in your communities.`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
!onboarding.start({
|
||||
source: "first-community",
|
||||
relayUrl,
|
||||
communityName: community.name ?? community.slug,
|
||||
})
|
||||
) {
|
||||
throw new Error(
|
||||
`${retryPrefix}onboarding is already in progress for another community. Go back and finish or restart that connection, then connect this community from your owned communities list.`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
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, true);
|
||||
});
|
||||
};
|
||||
|
||||
const busy = action !== null;
|
||||
|
||||
return (
|
||||
<div className="flex w-full max-w-[640px] flex-col items-center text-center">
|
||||
<h1 className="text-title font-normal">Your communities</h1>
|
||||
<p className="mx-auto mt-3 max-w-[480px] text-sm leading-6 text-foreground/80">
|
||||
Sign in to connect a community you already own on this machine, or
|
||||
create a new one.
|
||||
</p>
|
||||
|
||||
<div className="mt-10 w-full space-y-5 text-left">
|
||||
{error ? (
|
||||
<div
|
||||
className="flex items-start gap-2 rounded-xl border border-destructive/40 bg-destructive/5 p-4 text-sm text-destructive"
|
||||
role="alert"
|
||||
>
|
||||
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-10" role="status">
|
||||
<LoaderCircle className="h-6 w-6 animate-spin" />
|
||||
<span className="sr-only">Checking Builderlab sign-in</span>
|
||||
</div>
|
||||
) : !auth ? (
|
||||
<section className="rounded-xl bg-white/75 p-6 text-center">
|
||||
<h2 className="font-medium">Sign in or create an account</h2>
|
||||
<p className="mt-2 text-sm text-foreground/70">
|
||||
Builderlab provides Block-hosted Buzz communities. Authentication
|
||||
opens in your browser and returns here.
|
||||
</p>
|
||||
{action === "Signing in…" ? (
|
||||
<div className="mt-5 flex flex-col items-center gap-3">
|
||||
<div className="flex items-center gap-2 text-sm text-foreground/70">
|
||||
<LoaderCircle className="h-4 w-4 animate-spin" />
|
||||
Waiting for your browser…
|
||||
</div>
|
||||
<Button onClick={cancelSignIn} variant="outline">
|
||||
Cancel sign-in
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button className="mt-5 rounded-full px-6" onClick={signIn}>
|
||||
Continue with Builderlab
|
||||
</Button>
|
||||
)}
|
||||
</section>
|
||||
) : !identity ? (
|
||||
<section className="rounded-xl bg-white/75 p-6 text-center">
|
||||
<h2 className="font-medium">Connect this Buzz identity</h2>
|
||||
<p className="mt-2 text-sm text-foreground/70">
|
||||
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.
|
||||
</p>
|
||||
<Button
|
||||
className="mt-5 rounded-full px-6"
|
||||
disabled={busy}
|
||||
onClick={() => void connectIdentity()}
|
||||
>
|
||||
{busy ? <LoaderCircle className="h-4 w-4 animate-spin" /> : null}
|
||||
{action ?? "Connect this Buzz identity"}
|
||||
</Button>
|
||||
</section>
|
||||
) : identityMismatch ? (
|
||||
<section className="rounded-xl border border-amber-500/50 bg-amber-500/5 p-6">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600" />
|
||||
<div>
|
||||
<h2 className="font-medium">
|
||||
This account uses a different Buzz identity
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-foreground/70">
|
||||
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.
|
||||
</p>
|
||||
<p className="mt-3 break-all font-mono text-xs text-foreground/60">
|
||||
Account: {identity.npub ?? boundPubkey}
|
||||
<br />
|
||||
This device: {localNpub ?? localPubkey}
|
||||
</p>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<Button
|
||||
disabled={busy}
|
||||
onClick={() => void switchToDeviceIdentity()}
|
||||
>
|
||||
{action ?? "Use this device's identity"}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={busy}
|
||||
onClick={() => void signOut()}
|
||||
variant="outline"
|
||||
>
|
||||
Sign in with a different email
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-2 rounded-xl bg-white/60 px-4 py-3 text-sm text-foreground/70">
|
||||
<CheckCircle2 className="h-4 w-4 text-emerald-600" />
|
||||
Signed in{auth.email ? ` as ${auth.email}` : ""} with this Buzz
|
||||
identity
|
||||
</div>
|
||||
|
||||
{activeCommunities.length > 0 ? (
|
||||
<section className="rounded-xl bg-white/75 p-5">
|
||||
<h2 className="font-medium">Connect to one you own</h2>
|
||||
<ul className="mt-3 space-y-2">
|
||||
{activeCommunities.map((community, index) => (
|
||||
<li
|
||||
className="flex items-center justify-between gap-4 rounded-lg border border-foreground/10 bg-white/50 p-3"
|
||||
key={community.id ?? community.normalized_host ?? index}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium">
|
||||
{community.name ??
|
||||
community.slug ??
|
||||
"Hosted community"}
|
||||
</p>
|
||||
<p className="truncate text-xs text-foreground/60">
|
||||
{community.normalized_host}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
className="rounded-full"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
void run("Connecting community…", async () => {
|
||||
connect(community);
|
||||
})
|
||||
}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
Connect
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<form className="rounded-xl bg-white/75 p-5" onSubmit={create}>
|
||||
<h2 className="font-medium">Create a new community</h2>
|
||||
<p className="mt-1 text-sm text-foreground/70">
|
||||
Choose the address your team will use to connect.
|
||||
</p>
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<Input
|
||||
aria-label="Community address"
|
||||
autoComplete="off"
|
||||
disabled={busy || atCommunityLimit}
|
||||
maxLength={63}
|
||||
onChange={(event) => {
|
||||
setName(event.target.value.toLowerCase());
|
||||
setAvailability(null);
|
||||
}}
|
||||
placeholder="north-star"
|
||||
spellCheck={false}
|
||||
value={name}
|
||||
/>
|
||||
<span className="shrink-0 text-sm text-foreground/60">
|
||||
.{HOSTED_COMMUNITY_SUFFIX}
|
||||
</span>
|
||||
</div>
|
||||
{atCommunityLimit ? (
|
||||
<p className="mt-2 text-sm text-foreground/60">
|
||||
You’ve reached the limit of {HOSTED_COMMUNITY_LIMIT} hosted
|
||||
communities.
|
||||
</p>
|
||||
) : name && !validName ? (
|
||||
<p className="mt-2 text-sm text-destructive">
|
||||
Use lowercase letters, numbers, and single hyphens.
|
||||
</p>
|
||||
) : checkingName ? (
|
||||
<p className="mt-2 text-sm text-foreground/60">
|
||||
Checking availability…
|
||||
</p>
|
||||
) : availability === false ? (
|
||||
<p className="mt-2 text-sm text-destructive">
|
||||
That address is already taken.
|
||||
</p>
|
||||
) : availability === true ? (
|
||||
<p className="mt-2 text-sm text-emerald-600">
|
||||
That address is available.
|
||||
</p>
|
||||
) : null}
|
||||
<Button
|
||||
className="mt-4 rounded-full px-6"
|
||||
disabled={
|
||||
!validName ||
|
||||
availability === false ||
|
||||
checkingName ||
|
||||
busy ||
|
||||
atCommunityLimit
|
||||
}
|
||||
type="submit"
|
||||
>
|
||||
{busy ? (
|
||||
<LoaderCircle className="h-4 w-4 animate-spin" />
|
||||
) : null}
|
||||
{action ?? "Create and connect"}
|
||||
</Button>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<OnboardingFooter>
|
||||
<Button
|
||||
className="h-9 rounded-full bg-foreground/10 px-6 hover:bg-foreground/15"
|
||||
disabled={busy}
|
||||
onClick={goBack}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
</OnboardingFooter>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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({
|
||||
</button>
|
||||
<button
|
||||
className={COMMUNITY_OPTION_CARD_CLASS}
|
||||
onClick={() => void openUrl(CREATE_COMMUNITY_URL)}
|
||||
onClick={() => showPage("owned")}
|
||||
type="button"
|
||||
>
|
||||
<span className="max-w-44">I want to create a community</span>
|
||||
<span className="max-w-52">
|
||||
Create or connect to my own community
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<OnboardingFooter>
|
||||
@@ -170,6 +171,14 @@ export function WelcomeSetup({
|
||||
</Button>
|
||||
</OnboardingFooter>
|
||||
</OnboardingSlideTransition>
|
||||
) : page === "owned" ? (
|
||||
<OnboardingSlideTransition
|
||||
className="flex w-full flex-col items-center text-center"
|
||||
direction={transitionDirection}
|
||||
transitionKey={`owned-${transitionDirection}`}
|
||||
>
|
||||
<HostedCommunityOnboarding onBack={() => showPage("welcome")} />
|
||||
</OnboardingSlideTransition>
|
||||
) : page === "join" ? (
|
||||
<OnboardingSlideTransition
|
||||
className="flex min-h-[calc(100dvh-15.625rem)] w-full flex-col items-center text-center"
|
||||
|
||||
@@ -14,6 +14,20 @@ import {
|
||||
} from "lucide-react";
|
||||
|
||||
import { useIdentityQuery } from "@/shared/api/hooks";
|
||||
import {
|
||||
HOSTED_COMMUNITY_LIMIT as MAX_COMMUNITIES,
|
||||
HOSTED_COMMUNITY_SUFFIX as HOST_SUFFIX,
|
||||
hostedCommunityErrorMessage as errorMessage,
|
||||
hostedCommunityRelayUrl as relayUrl,
|
||||
type BuilderlabAuth,
|
||||
type HostedCommunityAvailabilityResponse as AvailabilityResponse,
|
||||
type HostedCommunitiesResponse as CommunitiesResponse,
|
||||
type HostedCommunity,
|
||||
type HostedCommunityMutationResponse as CommunityMutationResponse,
|
||||
type HostedIdentityResponse as IdentityResponse,
|
||||
type HostedNostrIdentity as NostrIdentity,
|
||||
VALID_HOSTED_COMMUNITY_NAME as VALID_NAME,
|
||||
} from "@/features/communities/hostedCommunityApi";
|
||||
import { safeNpub } from "@/shared/lib/nostrUtils";
|
||||
import { useCommunityOnboarding } from "@/features/onboarding/communityOnboarding";
|
||||
import {
|
||||
@@ -38,91 +52,6 @@ import {
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { SettingsSectionHeader } from "./SettingsSectionHeader";
|
||||
|
||||
const HOST_SUFFIX = "communities.buzz.xyz";
|
||||
const VALID_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||||
const MAX_COMMUNITIES = 3;
|
||||
|
||||
type BuilderlabAuth = {
|
||||
email?: string;
|
||||
name?: string;
|
||||
expiresAt: string;
|
||||
};
|
||||
|
||||
type ApiError = {
|
||||
code?: string;
|
||||
message?: string;
|
||||
setup_needed?: boolean;
|
||||
};
|
||||
|
||||
type NostrIdentity = {
|
||||
npub?: string;
|
||||
pubkey_hex?: string;
|
||||
};
|
||||
|
||||
type IdentityResponse = {
|
||||
identity?: NostrIdentity;
|
||||
error?: ApiError;
|
||||
correlation_id?: string;
|
||||
};
|
||||
|
||||
type HostedCommunity = {
|
||||
id?: string;
|
||||
name?: string;
|
||||
slug?: string;
|
||||
normalized_host?: string;
|
||||
owner_pubkey?: string;
|
||||
archived_at?: string | null;
|
||||
};
|
||||
|
||||
type CommunitiesResponse = {
|
||||
communities?: HostedCommunity[];
|
||||
error?: ApiError;
|
||||
correlation_id?: string;
|
||||
};
|
||||
|
||||
type AvailabilityResponse = {
|
||||
available?: boolean;
|
||||
normalized_host?: string;
|
||||
error?: ApiError;
|
||||
correlation_id?: string;
|
||||
};
|
||||
|
||||
type CommunityMutationResponse = {
|
||||
community?: HostedCommunity;
|
||||
error?: ApiError;
|
||||
correlation_id?: string;
|
||||
};
|
||||
|
||||
function errorMessage(
|
||||
error: ApiError | undefined,
|
||||
correlationId: string | undefined,
|
||||
fallback: string,
|
||||
) {
|
||||
const messages: Record<string, string> = {
|
||||
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;
|
||||
|
||||
@@ -125,6 +125,34 @@ 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;
|
||||
/** Delay Builderlab login completion so cancellation/retry UI can be tested. */
|
||||
builderlabLoginDelayMs?: number;
|
||||
/** Bound Builderlab Nostr identity. Null/omitted = not linked yet. */
|
||||
builderlabIdentity?: { npub?: string; pubkey_hex?: string } | null;
|
||||
/** Structured error returned when onboarding tries to bind the local identity. */
|
||||
builderlabBindError?: { code?: string; message?: string };
|
||||
/** Communities owned by the mocked Builderlab account. */
|
||||
builderlabCommunities?: Array<{
|
||||
id?: string;
|
||||
name?: string;
|
||||
slug?: string;
|
||||
normalized_host?: string;
|
||||
archived_at?: string | null;
|
||||
}>;
|
||||
/** Override the community returned after hosted creation. */
|
||||
builderlabCreatedCommunity?: {
|
||||
id?: string;
|
||||
name?: string;
|
||||
slug?: string;
|
||||
normalized_host?: string;
|
||||
archived_at?: string | null;
|
||||
};
|
||||
acpRuntimesCatalog?: RawAcpRuntimeCatalogEntry[];
|
||||
acpRuntimesDelayMs?: number;
|
||||
acpAuthMethods?: Record<string, RawAcpAuthMethodsResult>;
|
||||
@@ -8774,6 +8802,62 @@ 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 delayMs = activeConfig?.mock?.builderlabLoginDelayMs ?? 0;
|
||||
if (delayMs > 0)
|
||||
await new Promise((resolve) => window.setTimeout(resolve, delayMs));
|
||||
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 "cancel_builderlab_login":
|
||||
return null;
|
||||
case "clear_builderlab_auth":
|
||||
if (activeConfig?.mock) activeConfig.mock.builderlabAuth = null;
|
||||
return null;
|
||||
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": {
|
||||
if (activeConfig?.mock?.builderlabBindError)
|
||||
return { error: activeConfig.mock.builderlabBindError };
|
||||
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 "delete_builderlab_nostr_identity":
|
||||
if (activeConfig?.mock) activeConfig.mock.builderlabIdentity = null;
|
||||
return {};
|
||||
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: activeConfig?.mock?.builderlabCreatedCommunity ?? {
|
||||
id: `hosted-${name}`,
|
||||
name,
|
||||
normalized_host: `${name}.communities.buzz.xyz`,
|
||||
},
|
||||
};
|
||||
}
|
||||
case "mesh_installed_models":
|
||||
return mockMeshState.models;
|
||||
case "mesh_node_status":
|
||||
|
||||
@@ -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,338 @@ 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 reports a created community without a relay address", 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 },
|
||||
builderlabCreatedCommunity: {
|
||||
id: "hosted-bee-lab",
|
||||
name: "bee-lab",
|
||||
},
|
||||
},
|
||||
{
|
||||
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("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("alert")).toContainText(
|
||||
"The community was created, but Builderlab did not return its relay address.",
|
||||
);
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Build your profile" }),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("first-community can cancel a closed-browser sign-in and retry", 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,
|
||||
{ builderlabLoginDelayMs: 5_000 },
|
||||
{
|
||||
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.getByText("Waiting for your browser…")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Cancel sign-in" }).click();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Continue with Builderlab" }),
|
||||
).toBeVisible();
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => window.__BUZZ_E2E_COMMANDS__ ?? []))
|
||||
.toEqual(expect.arrayContaining(["cancel_builderlab_login"]));
|
||||
|
||||
await page.evaluate(() => {
|
||||
if (window.__BUZZ_E2E_CONFIG__?.mock)
|
||||
window.__BUZZ_E2E_CONFIG__.mock.builderlabLoginDelayMs = 0;
|
||||
});
|
||||
await page.getByRole("button", { name: "Continue with Builderlab" }).click();
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Connect this Buzz identity" }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("first-community owner can replace a mismatched account identity", 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: "old-owner@example.com",
|
||||
expiresAt: "2099-01-01T00:00:00Z",
|
||||
},
|
||||
builderlabIdentity: { pubkey_hex: "f".repeat(64) },
|
||||
},
|
||||
{
|
||||
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.getByRole("heading", {
|
||||
name: "This account uses a different Buzz identity",
|
||||
}),
|
||||
).toBeVisible();
|
||||
await page
|
||||
.getByRole("button", { name: "Use this device's identity" })
|
||||
.click();
|
||||
await expect(
|
||||
page.getByText("Signed in as old-owner@example.com"),
|
||||
).toBeVisible();
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => window.__BUZZ_E2E_COMMANDS__ ?? []))
|
||||
.toEqual(
|
||||
expect.arrayContaining([
|
||||
"delete_builderlab_nostr_identity",
|
||||
"bind_builderlab_nostr_identity",
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test("first-community explains when the local identity belongs to another account", 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: "wrong-owner@example.com",
|
||||
expiresAt: "2099-01-01T00:00:00Z",
|
||||
},
|
||||
builderlabIdentity: { pubkey_hex: "e".repeat(64) },
|
||||
builderlabBindError: { code: "pubkey_already_bound" },
|
||||
},
|
||||
{
|
||||
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: "Use this device's identity" })
|
||||
.click();
|
||||
await expect(
|
||||
page.getByText(
|
||||
"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.",
|
||||
),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Connect this Buzz identity" }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("back clears Builderlab auth before returning to first-community choices", 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 },
|
||||
},
|
||||
{
|
||||
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: "Back" }).click();
|
||||
await page
|
||||
.getByRole("button", { name: "Create or connect to my own community" })
|
||||
.click();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Continue with Builderlab" }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("first-community shows the scenario cards for localhost", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -781,7 +1104,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();
|
||||
|
||||
@@ -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<string, unknown>[];
|
||||
acpRuntimesDelayMs?: number;
|
||||
acpAuthMethods?: Record<string, { methods: Record<string, unknown>[] }>;
|
||||
|
||||
Reference in New Issue
Block a user