mirror of
https://github.com/block/buzz.git
synced 2026-11-14 05:12:27 +00:00
fix(invites): collect native consent for app invite handoff
Co-authored-by: Jitter <d14dfe033ef0f809866f9f984de04821b0d900d7652fd85a54776ee40ca3a68f@buzz.block.builderlab.xyz> Signed-off-by: Matt Toohey <contact@matttoohey.com>
This commit is contained in:
@@ -15,6 +15,7 @@ export type CommunityOnboardingSource =
|
||||
| "deep-link-join";
|
||||
|
||||
export type CommunityOnboardingStage =
|
||||
| "invite-consent"
|
||||
| "claiming"
|
||||
| "connecting"
|
||||
| "profile"
|
||||
@@ -61,6 +62,7 @@ export type CommunityOnboardingTransactionPatch = Partial<
|
||||
Pick<
|
||||
CommunityOnboardingTransaction,
|
||||
| "stage"
|
||||
| "policyReceipt"
|
||||
| "relayUrl"
|
||||
| "communityId"
|
||||
| "previousCommunityId"
|
||||
@@ -106,6 +108,7 @@ function isTransaction(
|
||||
typeof transaction.createdAt === "string" &&
|
||||
typeof transaction.updatedAt === "string" &&
|
||||
[
|
||||
"invite-consent",
|
||||
"claiming",
|
||||
"connecting",
|
||||
"profile",
|
||||
|
||||
@@ -30,6 +30,7 @@ import { cn } from "@/shared/lib/cn";
|
||||
import { useSystemColorScheme } from "@/shared/theme/useSystemColorScheme";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@/shared/ui/dialog";
|
||||
import { InviteRedeemForm } from "./InviteRedeemForm";
|
||||
import { MembershipDenied } from "./MembershipDenied";
|
||||
import { ONBOARDING_PRIMARY_CTA_CLASS } from "./OnboardingChrome";
|
||||
import { OnboardingCard, useOnboardingCardLayout } from "./OnboardingCard";
|
||||
@@ -525,8 +526,28 @@ export function CommunityOnboardingFlow({
|
||||
)}
|
||||
data-testid="community-onboarding-body"
|
||||
>
|
||||
{transaction.stage === "claiming" ||
|
||||
transaction.stage === "connecting" ? (
|
||||
{transaction.stage === "invite-consent" ? (
|
||||
<>
|
||||
<h1 className="mb-6 text-title font-normal">
|
||||
Join {transaction.communityName}
|
||||
</h1>
|
||||
<InviteRedeemForm
|
||||
key={`${transaction.id}:${transaction.inviteCode}`}
|
||||
initialValue={`buzz://join?${new URLSearchParams({ relay: transaction.relayUrl, code: transaction.inviteCode ?? "" })}`}
|
||||
readOnlyInvite
|
||||
error={null}
|
||||
isRedeeming={false}
|
||||
onCancel={onCancel}
|
||||
onRedeem={(_relayUrl, _inviteCode, policyReceipt) =>
|
||||
update(
|
||||
{ policyReceipt, stage: "claiming", error: undefined },
|
||||
transaction.id,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</>
|
||||
) : transaction.stage === "claiming" ||
|
||||
transaction.stage === "connecting" ? (
|
||||
<>
|
||||
<Users className="mx-auto h-10 w-10" />
|
||||
<h1 className="mt-5 text-title font-normal">
|
||||
|
||||
@@ -52,6 +52,7 @@ type InviteRedeemFormProps = {
|
||||
defaultRelayUrl?: string;
|
||||
error: string | null;
|
||||
initialValue?: string;
|
||||
readOnlyInvite?: boolean;
|
||||
isRedeeming: boolean;
|
||||
onCancel: () => void;
|
||||
onConnect?: (relayWsUrl: string) => void;
|
||||
@@ -64,6 +65,7 @@ export function InviteRedeemForm({
|
||||
defaultRelayUrl,
|
||||
error,
|
||||
initialValue = "",
|
||||
readOnlyInvite = false,
|
||||
isRedeeming,
|
||||
onCancel,
|
||||
onConnect,
|
||||
@@ -405,7 +407,8 @@ export function InviteRedeemForm({
|
||||
<Input
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoFocus
|
||||
autoFocus={!readOnlyInvite}
|
||||
readOnly={readOnlyInvite}
|
||||
className={
|
||||
isAddCommunity
|
||||
? "h-11 rounded-xl border-input bg-muted/40 px-3 shadow-none transition-colors duration-150 ease-out hover:border-muted-foreground/40 focus-visible:border-muted-foreground/50 focus-visible:ring-0"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React, { act } from "react";
|
||||
import { act, cleanup, render, waitFor } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
import {
|
||||
@@ -11,6 +12,186 @@ import {
|
||||
} from "./communityOnboarding.tsx";
|
||||
import { useClaimInvite } from "./useClaimInvite.ts";
|
||||
|
||||
let controls;
|
||||
function Claim() {
|
||||
controls = useCommunityOnboarding();
|
||||
useClaimInvite();
|
||||
return null;
|
||||
}
|
||||
const policy = {
|
||||
version: "v1",
|
||||
terms_markdown: "Terms",
|
||||
age_attestation_required: false,
|
||||
};
|
||||
const tauri = {
|
||||
invoke: async (command) => {
|
||||
if (command === "sign_event")
|
||||
return JSON.stringify({ id: "signed", tags: [] });
|
||||
throw new Error(`Unexpected command ${command}`);
|
||||
},
|
||||
};
|
||||
window.__TAURI_INTERNALS__ = tauri;
|
||||
globalThis.__TAURI_INTERNALS__ = tauri;
|
||||
|
||||
for (const configured of [true, false]) {
|
||||
test(`receipt-less invite ${configured ? "waits for native consent" : "claims a policy-free relay"}`, async () => {
|
||||
localStorage.clear();
|
||||
const requests = [];
|
||||
const original = globalThis.fetch;
|
||||
globalThis.fetch = async (url) => {
|
||||
requests.push(String(url));
|
||||
if (String(url).endsWith("/api/join-policy"))
|
||||
return Response.json(configured ? { policy } : {});
|
||||
return Response.json({
|
||||
status: "joined",
|
||||
community_id: "id",
|
||||
host: "relay.example",
|
||||
role: "member",
|
||||
});
|
||||
};
|
||||
try {
|
||||
startCommunityOnboarding({
|
||||
source: "deep-link-join",
|
||||
relayUrl: "wss://relay.example",
|
||||
inviteCode: "code",
|
||||
});
|
||||
render(
|
||||
React.createElement(
|
||||
CommunityOnboardingProvider,
|
||||
null,
|
||||
React.createElement(Claim),
|
||||
),
|
||||
);
|
||||
await waitFor(() =>
|
||||
assert.equal(
|
||||
controls.transaction.stage,
|
||||
configured ? "invite-consent" : "connecting",
|
||||
),
|
||||
);
|
||||
assert.equal(
|
||||
requests.filter((url) => url.endsWith("/claim")).length,
|
||||
configured ? 0 : 1,
|
||||
);
|
||||
} finally {
|
||||
cleanup();
|
||||
globalThis.fetch = original;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("rejected receipt returns to native consent and clears the stale proof", async () => {
|
||||
localStorage.clear();
|
||||
const original = globalThis.fetch;
|
||||
globalThis.fetch = async () =>
|
||||
Response.json({ error: "join_policy_required" }, { status: 403 });
|
||||
try {
|
||||
startCommunityOnboarding({
|
||||
source: "deep-link-join",
|
||||
relayUrl: "wss://relay.example",
|
||||
inviteCode: "code",
|
||||
policyReceipt: "expired",
|
||||
});
|
||||
render(
|
||||
React.createElement(
|
||||
CommunityOnboardingProvider,
|
||||
null,
|
||||
React.createElement(Claim),
|
||||
),
|
||||
);
|
||||
await waitFor(() =>
|
||||
assert.equal(controls.transaction.stage, "invite-consent"),
|
||||
);
|
||||
assert.equal(controls.transaction.policyReceipt, undefined);
|
||||
assert.equal(controls.transaction.error, undefined);
|
||||
} finally {
|
||||
cleanup();
|
||||
globalThis.fetch = original;
|
||||
}
|
||||
});
|
||||
|
||||
test("policy lookup failure never claims and can be retried", async () => {
|
||||
localStorage.clear();
|
||||
const original = globalThis.fetch;
|
||||
let failed = true;
|
||||
let claims = 0;
|
||||
globalThis.fetch = async (url) => {
|
||||
if (String(url).endsWith("/api/join-policy")) {
|
||||
if (failed) throw new Error("offline");
|
||||
return Response.json({ policy });
|
||||
}
|
||||
claims++;
|
||||
throw new Error("unexpected claim");
|
||||
};
|
||||
try {
|
||||
startCommunityOnboarding({
|
||||
source: "deep-link-join",
|
||||
relayUrl: "wss://relay.example",
|
||||
inviteCode: "code",
|
||||
});
|
||||
render(
|
||||
React.createElement(
|
||||
CommunityOnboardingProvider,
|
||||
null,
|
||||
React.createElement(Claim),
|
||||
),
|
||||
);
|
||||
await waitFor(() => assert.equal(controls.transaction.error, "offline"));
|
||||
failed = false;
|
||||
await act(async () => controls.update({ error: undefined }));
|
||||
await waitFor(() =>
|
||||
assert.equal(controls.transaction.stage, "invite-consent"),
|
||||
);
|
||||
assert.equal(claims, 0);
|
||||
} finally {
|
||||
cleanup();
|
||||
globalThis.fetch = original;
|
||||
}
|
||||
});
|
||||
|
||||
test("cancelled policy discovery cannot claim after its response arrives", async () => {
|
||||
localStorage.clear();
|
||||
const original = globalThis.fetch;
|
||||
let resolvePolicy;
|
||||
let started;
|
||||
const lookupStarted = new Promise((resolve) => {
|
||||
started = resolve;
|
||||
});
|
||||
const response = new Promise((resolve) => {
|
||||
resolvePolicy = resolve;
|
||||
});
|
||||
let claims = 0;
|
||||
globalThis.fetch = async (url) => {
|
||||
if (String(url).endsWith("/api/join-policy")) {
|
||||
started();
|
||||
return response;
|
||||
}
|
||||
claims++;
|
||||
throw new Error("unexpected claim");
|
||||
};
|
||||
try {
|
||||
startCommunityOnboarding({
|
||||
source: "deep-link-join",
|
||||
relayUrl: "wss://relay.example",
|
||||
inviteCode: "code",
|
||||
});
|
||||
render(
|
||||
React.createElement(
|
||||
CommunityOnboardingProvider,
|
||||
null,
|
||||
React.createElement(Claim),
|
||||
),
|
||||
);
|
||||
await lookupStarted;
|
||||
await act(async () => controls.clear());
|
||||
await act(async () => resolvePolicy(Response.json({})));
|
||||
assert.equal(controls.transaction, null);
|
||||
assert.equal(claims, 0);
|
||||
} finally {
|
||||
cleanup();
|
||||
globalThis.fetch = original;
|
||||
}
|
||||
});
|
||||
|
||||
for (const [sentinel, guidance] of [
|
||||
[
|
||||
"invite_restricted",
|
||||
@@ -46,6 +227,8 @@ for (const [sentinel, guidance] of [
|
||||
retryObserved = resolve;
|
||||
});
|
||||
globalThis.fetch = async (url, options) => {
|
||||
// Receipt-less claims first discover the policy; this relay has none.
|
||||
if (String(url).endsWith("/api/join-policy")) return Response.json({});
|
||||
assert.equal(url, "https://relay.example/api/invites/claim");
|
||||
assert.equal(JSON.parse(options.body).code, "same-code");
|
||||
attempts++;
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { useCommunityOnboarding } from "@/features/onboarding/communityOnboarding";
|
||||
import { inviteClaimErrorMessage } from "@/shared/api/inviteHelpers";
|
||||
import { claimInvite } from "@/shared/api/invites";
|
||||
import {
|
||||
inviteClaimErrorMessage,
|
||||
inviteErrorMessage,
|
||||
} from "@/shared/api/inviteHelpers";
|
||||
import { claimInvite, getJoinPolicy } from "@/shared/api/invites";
|
||||
|
||||
/**
|
||||
* Drive the `claiming` stage after machine onboarding completes: claim the
|
||||
@@ -17,28 +20,55 @@ import { claimInvite } from "@/shared/api/invites";
|
||||
export function useClaimInvite() {
|
||||
const { transaction, update } = useCommunityOnboarding();
|
||||
const [isPending, setIsPending] = React.useState(false);
|
||||
const currentTransaction = React.useRef(transaction);
|
||||
React.useEffect(() => {
|
||||
currentTransaction.current = transaction;
|
||||
return () => {
|
||||
currentTransaction.current = null;
|
||||
};
|
||||
}, [transaction]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (transaction?.stage !== "claiming" || transaction.error || isPending) {
|
||||
return;
|
||||
}
|
||||
setIsPending(true);
|
||||
void claimInvite(
|
||||
transaction.relayUrl,
|
||||
transaction.inviteCode ?? "",
|
||||
transaction.policyReceipt,
|
||||
)
|
||||
.then(() => {
|
||||
update({ stage: "connecting", error: undefined }, transaction.id);
|
||||
})
|
||||
.catch((error: unknown) =>
|
||||
void (async () => {
|
||||
const policy = !transaction.policyReceipt
|
||||
? await getJoinPolicy(transaction.relayUrl, "webview")
|
||||
: null;
|
||||
if (currentTransaction.current !== transaction) return;
|
||||
if (policy) {
|
||||
update({ stage: "invite-consent" }, transaction.id);
|
||||
return;
|
||||
}
|
||||
await claimInvite(
|
||||
transaction.relayUrl,
|
||||
transaction.inviteCode ?? "",
|
||||
transaction.policyReceipt,
|
||||
);
|
||||
update({ stage: "connecting", error: undefined }, transaction.id);
|
||||
})()
|
||||
.catch((error: unknown) => {
|
||||
if (currentTransaction.current !== transaction) return;
|
||||
if (inviteErrorMessage(error).includes("join_policy_required")) {
|
||||
update(
|
||||
{
|
||||
stage: "invite-consent",
|
||||
policyReceipt: undefined,
|
||||
error: undefined,
|
||||
},
|
||||
transaction.id,
|
||||
);
|
||||
return;
|
||||
}
|
||||
update(
|
||||
{
|
||||
error: inviteClaimErrorMessage(error),
|
||||
},
|
||||
transaction.id,
|
||||
),
|
||||
)
|
||||
);
|
||||
})
|
||||
.finally(() => setIsPending(false));
|
||||
}, [isPending, transaction, update]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user