mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
Reference in New Issue
Block a user