Fix membership-denied community onboarding recovery (#2072)

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Wes
2026-07-18 17:13:00 +00:00
committed by GitHub
co-authored by Pinky
parent 929d3aa12f
commit f347dcac25
4 changed files with 146 additions and 3 deletions
@@ -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);
@@ -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) {
@@ -55,7 +58,7 @@ export function CommunityChangeOverlay({
break;
}
},
[activeCommunity, onClose, updateCommunity],
[activeCommunity, onClose, onUpdated, updateCommunity],
);
if (!activeCommunity) return null;
@@ -53,7 +53,12 @@ export type CommunityOnboardingTransaction = {
export type CommunityOnboardingTransactionPatch = Partial<
Pick<
CommunityOnboardingTransaction,
"stage" | "communityId" | "communityName" | "error" | "acknowledged"
| "stage"
| "relayUrl"
| "communityId"
| "communityName"
| "error"
| "acknowledged"
>
>;
@@ -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,
@@ -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",
@@ -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);
@@ -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);
@@ -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);
+68
View File
@@ -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,
}) => {