Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@ import { CommunityEditForm } from "./CommunityEditForm";

type CommunityChangeOverlayProps = {
onClose: () => void;
onUpdated?: (name: string, relayUrl: string) => void;
};

export function CommunityChangeOverlay({
onClose,
onUpdated,
}: CommunityChangeOverlayProps) {
const { activeCommunity, updateCommunity } = useCommunities();
const [error, setError] = React.useState<string | null>(null);
Expand Down Expand Up @@ -40,6 +42,7 @@ export function CommunityChangeOverlay({
onClose();
break;
case "updated":
onUpdated?.(name, relayUrl);
// If reinit is needed, the communityKey change will trigger a remount.
// If not (name-only), just close.
if (!result.requiresReinit) {
Expand All @@ -55,7 +58,7 @@ export function CommunityChangeOverlay({
break;
}
},
[activeCommunity, onClose, updateCommunity],
[activeCommunity, onClose, onUpdated, updateCommunity],
);

if (!activeCommunity) return null;
Expand Down
7 changes: 6 additions & 1 deletion desktop/src/features/onboarding/communityOnboarding.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,12 @@ export type CommunityOnboardingTransaction = {
export type CommunityOnboardingTransactionPatch = Partial<
Pick<
CommunityOnboardingTransaction,
"stage" | "communityId" | "communityName" | "error" | "acknowledged"
| "stage"
| "relayUrl"
| "communityId"
| "communityName"
| "error"
| "acknowledged"
>
>;

Expand Down
69 changes: 68 additions & 1 deletion desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,21 +8,25 @@ import {
} from "@/features/onboarding/communityOnboarding";
import { initializeStarterChannels } from "@/features/onboarding/hooks";
import { useClaimInvite } from "@/features/onboarding/useClaimInvite";
import { CommunityChangeOverlay } from "@/features/communities/ui/CommunityChangeOverlay";
import {
takePendingWelcomeChannelForDirectEntry,
WELCOME_SURFACE_READY_EVENT,
} from "@/features/onboarding/welcome";
import { profileQueryKey } from "@/features/profile/hooks";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import {
parseEmojiAvatarDataUrl,
ProfileAvatarEditor,
} from "@/features/profile/ui/ProfileAvatarEditor";
import { updateProfile } from "@/shared/api/tauriProfiles";
import { getIdentity } from "@/shared/api/tauriIdentity";
import { getIdentity, importIdentity } from "@/shared/api/tauriIdentity";
import { listPersonas } from "@/shared/api/tauriPersonas";
import { relayClient } from "@/shared/api/relayClient";
import type { AgentPersona } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { MembershipDenied } from "./MembershipDenied";
import { StartupWindowDragRegion } from "@/shared/ui/StartupWindowDragRegion";
import {
ONBOARDING_PRIMARY_CTA_CLASS,
Expand All @@ -35,6 +39,16 @@ import {
ONBOARDING_KEY_TEXT_CLASS,
} from "./NsecMaskedDisplay";

function isRelayMembershipDeniedError(error: unknown): boolean {
if (!(error instanceof Error)) return false;
return (
error.message.includes("You must be a relay member") ||
error.message.includes("relay_membership_required") ||
error.message.includes("restricted: not a relay member") ||
error.message.includes("invalid: you are not a relay member")
);
}

const STARTER_PERSONA_ANIMATIONS: Record<string, string> = {
Fizz: "/onboarding/starter-team/fizz.png",
Honey: "/onboarding/starter-team/honey.png",
Expand Down Expand Up @@ -113,6 +127,10 @@ export function CommunityOnboardingFlow({
[],
);
const [isPending, setIsPending] = React.useState(false);
const [deniedPubkey, setDeniedPubkey] = React.useState("");
const [isMembershipDenied, setIsMembershipDenied] = React.useState(false);
const [isCommunityChangeOpen, setIsCommunityChangeOpen] =
React.useState(false);
const [isCurtainFading, setIsCurtainFading] = React.useState(false);
const nameInputRef = React.useRef<HTMLInputElement | null>(null);

Expand Down Expand Up @@ -228,6 +246,45 @@ export function CommunityOnboardingFlow({

if (!transaction) return null;

if (isMembershipDenied) {
return (
<>
<MembershipDenied
activeRelayUrl={transaction.relayUrl}
onBack={() => setIsMembershipDenied(false)}
onChangeCommunity={() => setIsCommunityChangeOpen(true)}
onImportKey={async (nsec) => {
const identity = await importIdentity(nsec);
relayClient.disconnect();
queryClient.setQueryData(["identity"], identity);
queryClient.removeQueries({ queryKey: profileQueryKey });
setIsMembershipDenied(false);
update({ stage: "connecting", error: undefined });
}}
onRetry={() => {
setIsMembershipDenied(false);
update({ stage: "connecting", error: undefined });
}}
pubkey={deniedPubkey}
/>
{isCommunityChangeOpen ? (
<CommunityChangeOverlay
onClose={() => setIsCommunityChangeOpen(false)}
onUpdated={(communityName, updatedRelayUrl) => {
update({
communityName,
relayUrl: updatedRelayUrl,
stage: "connecting",
error: undefined,
});
setIsMembershipDenied(false);
}}
/>
) : null}
</>
);
}

const saveProfile = async () => {
if (!displayName.trim()) return;
setIsPending(true);
Expand All @@ -238,6 +295,16 @@ export function CommunityOnboardingFlow({
});
update({ stage: "team-intro", error: undefined });
} catch (error) {
if (isRelayMembershipDeniedError(error)) {
try {
const identity = await getIdentity();
setDeniedPubkey(identity.pubkey);
} catch {
setDeniedPubkey("");
}
setIsMembershipDenied(true);
return;
}
update({ error: error instanceof Error ? error.message : String(error) });
} finally {
setIsPending(false);
Expand Down
68 changes: 68 additions & 0 deletions desktop/tests/e2e/onboarding.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -869,6 +869,74 @@ test("connected first-community profile step cannot discard resumable onboarding
.not.toBeNull();
});

test("membership denial on community profile save offers recovery", async ({
page,
}) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await page.addInitScript(
({ pubkey, transactionStorageKey }) => {
window.localStorage.setItem(
`buzz-machine-onboarding-complete.v2:${pubkey}`,
"true",
);
const timestamp = new Date().toISOString();
window.localStorage.setItem(
transactionStorageKey,
JSON.stringify({
id: "txn-membership-denied",
source: "first-community",
stage: "profile",
relayUrl: "wss://denied.example.com",
communityName: "Denied",
communityId: "e2e-default-community",
createdAt: timestamp,
updatedAt: timestamp,
}),
);
},
{
pubkey: BLANK_TYLER_IDENTITY.pubkey,
transactionStorageKey: COMMUNITY_ONBOARDING_TRANSACTION_STORAGE_KEY,
},
);
await installMockBridge(
page,
{
profileUpdateError:
"relay returned 403 Forbidden: You must be a relay member to access this relay",
},
{
relayWsUrl: "wss://denied.example.com",
skipOnboardingSeed: true,
},
);
await page.goto("/");

await page.getByTestId("community-profile-name-key").fill("Kalvin");
await page.getByTestId("community-profile-next").click();

await expect(page.getByTestId("membership-denied")).toBeVisible();
await expect(
page.getByRole("heading", { name: "Not a member yet" }),
).toBeVisible();
await page.getByRole("button", { name: "Change community" }).click();
await expect(page.getByTestId("community-change-overlay")).toBeVisible();
await page.getByLabel("Community URL").fill("wss://invited.example.com");
await page.getByRole("button", { name: "Save changes" }).click();
await page.getByRole("button", { name: "Use anyway" }).click();
await expect(
page.getByRole("heading", { name: "Build your profile" }),
).toBeVisible();
await expect
.poll(() =>
page.evaluate(
(key) => window.localStorage.getItem(key),
COMMUNITY_ONBOARDING_TRANSACTION_STORAGE_KEY,
),
)
.toContain("wss://invited.example.com");
});

test("identity fallback text does not count as a real onboarding name", async ({
page,
}) => {
Expand Down
Loading