mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Apply optional relay join policy across join flows (#1894)
Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> Signed-off-by: Tyler Longwell <tlongwell@squareup.com> Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> Co-authored-by: klopez4212 <klopez4212@gmail.com> Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
co-authored by
npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757
klopez4212
npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d
parent
a1626f96ce
commit
6c2d667575
@@ -0,0 +1,161 @@
|
||||
import * as React from "react";
|
||||
|
||||
type InviteJoinPolicy = {
|
||||
terms_markdown?: string;
|
||||
privacy_markdown?: string;
|
||||
age_attestation_required: boolean;
|
||||
};
|
||||
|
||||
type PolicyCheckboxProps = {
|
||||
accessibleLabel: string;
|
||||
checked: boolean;
|
||||
children: React.ReactNode;
|
||||
onCheckedChange: (checked: boolean) => void;
|
||||
};
|
||||
|
||||
function PolicyCheckbox({
|
||||
accessibleLabel,
|
||||
checked,
|
||||
children,
|
||||
onCheckedChange,
|
||||
}: PolicyCheckboxProps) {
|
||||
const id = React.useId();
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<input
|
||||
aria-label={accessibleLabel}
|
||||
checked={checked}
|
||||
className="peer sr-only"
|
||||
id={id}
|
||||
onChange={(event) => onCheckedChange(event.target.checked)}
|
||||
type="checkbox"
|
||||
/>
|
||||
<label
|
||||
className="flex cursor-pointer items-start gap-3 rounded-sm text-left text-xs leading-5 text-black/60 peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-black"
|
||||
htmlFor={id}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-[background-color,border-color] duration-150 motion-reduce:transition-none ${
|
||||
checked ? "border-black bg-black" : "border-black/40 bg-white"
|
||||
}`}
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="h-3 w-3 text-white"
|
||||
fill="none"
|
||||
viewBox="0 0 16 16"
|
||||
>
|
||||
<path
|
||||
className={
|
||||
checked
|
||||
? "transition-[stroke-dashoffset] duration-[180ms] [transition-timing-function:cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none"
|
||||
: "transition-none"
|
||||
}
|
||||
d="M3 8.25 6.25 11.5 13 4.75"
|
||||
pathLength="16"
|
||||
stroke="currentColor"
|
||||
strokeDasharray="16"
|
||||
strokeDashoffset={checked ? 0 : 16}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<span>{children}</span>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The invite page's age and legal confirmations. */
|
||||
export function InviteJoinPolicyNotice({
|
||||
ageConfirmed,
|
||||
agreementConfirmed,
|
||||
onAgeConfirmedChange,
|
||||
onAgreementConfirmedChange,
|
||||
onShowDocument,
|
||||
policy,
|
||||
}: {
|
||||
ageConfirmed: boolean;
|
||||
agreementConfirmed: boolean;
|
||||
onAgeConfirmedChange: (checked: boolean) => void;
|
||||
onAgreementConfirmedChange: (checked: boolean) => void;
|
||||
onShowDocument: (title: string, markdown: string) => void;
|
||||
policy: InviteJoinPolicy;
|
||||
}) {
|
||||
const stopLabelActivation = (
|
||||
event: React.MouseEvent<HTMLButtonElement>,
|
||||
title: string,
|
||||
markdown: string,
|
||||
) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onShowDocument(title, markdown);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-full space-y-3 rounded-xl border border-black/10 bg-black/[0.03] p-4 text-left"
|
||||
data-testid="invite-join-policy-notice"
|
||||
>
|
||||
{policy.age_attestation_required ? (
|
||||
<PolicyCheckbox
|
||||
accessibleLabel="I am 18 years of age or older."
|
||||
checked={ageConfirmed}
|
||||
onCheckedChange={onAgeConfirmedChange}
|
||||
>
|
||||
I am 18 years of age or older.
|
||||
</PolicyCheckbox>
|
||||
) : null}
|
||||
|
||||
{policy.terms_markdown || policy.privacy_markdown ? (
|
||||
<PolicyCheckbox
|
||||
accessibleLabel="I agree to the Buzz Terms of Service and Privacy Policy."
|
||||
checked={agreementConfirmed}
|
||||
onCheckedChange={onAgreementConfirmedChange}
|
||||
>
|
||||
I agree to the Buzz{" "}
|
||||
{policy.terms_markdown ? (
|
||||
<button
|
||||
className="text-black no-underline underline-offset-4 hover:text-black/70 hover:underline focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-black"
|
||||
type="button"
|
||||
onClick={(event) =>
|
||||
stopLabelActivation(
|
||||
event,
|
||||
"Terms of Service",
|
||||
policy.terms_markdown ?? "",
|
||||
)
|
||||
}
|
||||
>
|
||||
Terms of Service
|
||||
</button>
|
||||
) : (
|
||||
"Terms of Service"
|
||||
)}{" "}
|
||||
and{" "}
|
||||
{policy.privacy_markdown ? (
|
||||
<button
|
||||
className="text-black no-underline underline-offset-4 hover:text-black/70 hover:underline focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-black"
|
||||
type="button"
|
||||
onClick={(event) =>
|
||||
stopLabelActivation(
|
||||
event,
|
||||
"Privacy Policy",
|
||||
policy.privacy_markdown ?? "",
|
||||
)
|
||||
}
|
||||
>
|
||||
Privacy Policy
|
||||
</button>
|
||||
) : (
|
||||
"Privacy Policy"
|
||||
)}
|
||||
.
|
||||
</PolicyCheckbox>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +1,72 @@
|
||||
import buzzAppIcon from "@/assets/app-icon@3x.png";
|
||||
import { relayWsUrl } from "@/shared/lib/relay-url";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import * as React from "react";
|
||||
import Markdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
const DOWNLOAD_URL = "https://github.com/block/buzz/releases/latest";
|
||||
type JoinPolicy = {
|
||||
terms_markdown?: string;
|
||||
privacy_markdown?: string;
|
||||
age_attestation_required: boolean;
|
||||
version: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Landing page for a community invite link (`/invite/<code>`).
|
||||
*
|
||||
* The code is not validated here — validation happens in the desktop app when
|
||||
* the invite is claimed against `POST /api/invites/claim`, signed by the
|
||||
* joining key. This page only hands the code off via the `buzz://join` deep
|
||||
* link (or tells the visitor where to get the app first).
|
||||
*/
|
||||
type PolicyDocument = { title: string; markdown: string };
|
||||
|
||||
/** Landing page for a community invite link (`/invite/<code>`). */
|
||||
export function InvitePage({ code }: { code: string }) {
|
||||
const relay = relayWsUrl();
|
||||
const host = relay.replace(/^wss?:\/\//, "");
|
||||
const deepLink = `buzz://join?relay=${encodeURIComponent(relay)}&code=${encodeURIComponent(code)}`;
|
||||
const [policy, setPolicy] = React.useState<JoinPolicy | null | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [document, setDocument] = React.useState<PolicyDocument | null>(null);
|
||||
const [ageConfirmed, setAgeConfirmed] = React.useState(false);
|
||||
const [opening, setOpening] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
fetch("/api/join-policy")
|
||||
.then(async (response) => {
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const config = (await response.json()) as { policy?: JoinPolicy };
|
||||
setPolicy(config.policy ?? null);
|
||||
})
|
||||
.catch(() => setPolicy(undefined));
|
||||
}, []);
|
||||
|
||||
const openInvite = async () => {
|
||||
setOpening(true);
|
||||
try {
|
||||
let receipt: string | undefined;
|
||||
if (policy) {
|
||||
const response = await fetch("/api/invites/accept-policy", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
code,
|
||||
policy_version: policy.version,
|
||||
age_confirmed: ageConfirmed,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
receipt = ((await response.json()) as { receipt: string }).receipt;
|
||||
}
|
||||
const query = new URLSearchParams({ relay, code });
|
||||
if (receipt) query.set("policy_receipt", receipt);
|
||||
window.location.href = `buzz://join?${query.toString()}`;
|
||||
} finally {
|
||||
setOpening(false);
|
||||
}
|
||||
};
|
||||
|
||||
const disabled =
|
||||
policy === undefined ||
|
||||
opening ||
|
||||
Boolean(policy?.age_attestation_required && !ageConfirmed);
|
||||
const showDocument = (title: string, markdown: string) =>
|
||||
setDocument({ title, markdown });
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -24,38 +75,94 @@ export function InvitePage({ code }: { code: string }) {
|
||||
backgroundImage: "linear-gradient(180deg, #D7D72E 0%, #D7E7F6 100%)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex w-full max-w-xl flex-col items-center rounded-3xl bg-white px-6 py-10 sm:px-12 sm:py-12"
|
||||
style={{
|
||||
boxShadow:
|
||||
"0 0 0 1px rgba(0, 0, 0, 0.04), 0 8px 24px rgba(0, 0, 0, 0.04)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="h-16 w-16 overflow-hidden bg-black"
|
||||
style={{ borderRadius: "22.37%" }}
|
||||
>
|
||||
<img alt="Buzz" className="h-full w-full" src={buzzAppIcon} />
|
||||
</div>
|
||||
<h1 className="mt-6 text-2xl font-semibold tracking-tight text-black">
|
||||
You're invited to join
|
||||
</h1>
|
||||
<p className="mt-1 font-mono text-lg text-black/70">{host}</p>
|
||||
|
||||
<div className="mt-8">
|
||||
<Button
|
||||
asChild
|
||||
className="bg-black text-white hover:bg-black/90 focus-visible:ring-black"
|
||||
size="lg"
|
||||
<div className="w-full max-w-xl space-y-4">
|
||||
<div className="flex w-full flex-col items-center rounded-3xl bg-white px-6 py-10 sm:px-12 sm:py-12">
|
||||
<div
|
||||
className="h-12 w-12 overflow-hidden bg-black"
|
||||
style={{ borderRadius: "22.37%" }}
|
||||
>
|
||||
<a href={deepLink}>Accept invite in Buzz</a>
|
||||
</Button>
|
||||
</div>
|
||||
<img alt="Buzz" className="h-full w-full" src={buzzAppIcon} />
|
||||
</div>
|
||||
<h1 className="mt-4 text-2xl font-semibold tracking-tight text-black">
|
||||
You're invited to
|
||||
</h1>
|
||||
<p className="mt-9 font-mono text-lg text-black/70">{host}</p>
|
||||
|
||||
<p className="mt-6 text-sm text-black/60">
|
||||
{policy?.age_attestation_required && (
|
||||
<label className="mt-9 flex max-w-md cursor-pointer items-start gap-3 text-left text-sm text-black/70">
|
||||
<input
|
||||
className="mt-0.5 h-4 w-4 accent-black"
|
||||
type="checkbox"
|
||||
checked={ageConfirmed}
|
||||
onChange={(event) => setAgeConfirmed(event.target.checked)}
|
||||
/>
|
||||
<span>I am 18 years of age or older.</span>
|
||||
</label>
|
||||
)}
|
||||
<div className={policy?.age_attestation_required ? "mt-5" : "mt-9"}>
|
||||
{policy === null ? (
|
||||
<Button
|
||||
asChild
|
||||
className="bg-black text-white hover:bg-black/90 focus-visible:ring-black"
|
||||
size="lg"
|
||||
>
|
||||
<a
|
||||
href={`buzz://join?relay=${encodeURIComponent(relay)}&code=${encodeURIComponent(code)}`}
|
||||
>
|
||||
Accept invite in Buzz
|
||||
</a>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
className="bg-black text-white hover:bg-black/90 focus-visible:ring-black disabled:cursor-not-allowed disabled:bg-black/30 disabled:text-white/70"
|
||||
size="lg"
|
||||
disabled={disabled}
|
||||
onClick={openInvite}
|
||||
>
|
||||
Accept invite in Buzz
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{policy && (policy.terms_markdown || policy.privacy_markdown) && (
|
||||
<p className="mt-4 max-w-md text-xs text-black/60">
|
||||
By proceeding you agree to the Buzz{" "}
|
||||
{policy.terms_markdown && (
|
||||
<button
|
||||
className="text-black underline-offset-4 hover:text-black/70 hover:underline focus-visible:underline"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
showDocument(
|
||||
"Terms of Service",
|
||||
policy.terms_markdown ?? "",
|
||||
)
|
||||
}
|
||||
>
|
||||
Terms of Service
|
||||
</button>
|
||||
)}
|
||||
{policy.terms_markdown && policy.privacy_markdown && " and "}
|
||||
{policy.privacy_markdown && (
|
||||
<button
|
||||
className="text-black underline-offset-4 hover:text-black/70 hover:underline focus-visible:underline"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
showDocument(
|
||||
"Privacy Policy",
|
||||
policy.privacy_markdown ?? "",
|
||||
)
|
||||
}
|
||||
>
|
||||
Privacy Policy
|
||||
</button>
|
||||
)}
|
||||
.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<p className="flex h-[3.125rem] items-center justify-center rounded-2xl bg-white text-sm text-black/60">
|
||||
Don't have the app?{" "}
|
||||
<a
|
||||
className="font-medium text-black underline-offset-4 hover:text-black/70 hover:decoration-current hover:underline focus-visible:underline"
|
||||
className="ml-1 font-medium text-black underline-offset-4 hover:text-black/70 hover:underline focus-visible:underline"
|
||||
href={DOWNLOAD_URL}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
@@ -64,6 +171,37 @@ export function InvitePage({ code }: { code: string }) {
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{document && (
|
||||
<div
|
||||
aria-label={document.title}
|
||||
aria-modal="true"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 text-left"
|
||||
role="dialog"
|
||||
onMouseDown={(event) => {
|
||||
if (event.currentTarget === event.target) setDocument(null);
|
||||
}}
|
||||
>
|
||||
<div className="max-h-[85vh] w-full max-w-3xl overflow-y-auto rounded-2xl bg-white p-6 text-black shadow-xl sm:p-8">
|
||||
<div className="mb-6 flex items-start justify-between gap-4">
|
||||
<h2 className="text-xl font-semibold">{document.title}</h2>
|
||||
<button
|
||||
aria-label="Close"
|
||||
className="text-2xl leading-none text-black/60 hover:text-black"
|
||||
type="button"
|
||||
onClick={() => setDocument(null)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="prose prose-sm max-w-none">
|
||||
<Markdown remarkPlugins={[remarkGfm]}>
|
||||
{document.markdown}
|
||||
</Markdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,3 +9,67 @@ test("home page shows repositories section", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page.getByText("Repositories")).toBeVisible();
|
||||
});
|
||||
|
||||
test("invite requires age and legal consent before opening Buzz", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.route("**/api/join-policy", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
policy: {
|
||||
terms_markdown: "# Terms",
|
||||
privacy_markdown: "# Privacy",
|
||||
age_attestation_required: true,
|
||||
version: "policy-v1",
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page.goto("/invite/demo-code");
|
||||
|
||||
const ageConfirmation = page.getByLabel("I am 18 years of age or older.");
|
||||
const agreementConfirmation = page.getByLabel(
|
||||
"I agree to the Buzz Terms of Service and Privacy Policy.",
|
||||
);
|
||||
const acceptInvite = page.getByRole("button", {
|
||||
name: "Accept invite in Buzz",
|
||||
});
|
||||
|
||||
await expect(ageConfirmation).toBeVisible();
|
||||
await expect(agreementConfirmation).toBeVisible();
|
||||
await expect(acceptInvite).toBeDisabled();
|
||||
|
||||
const termsLink = page.getByRole("button", { name: "Terms of Service" });
|
||||
const privacyLink = page.getByRole("button", { name: "Privacy Policy" });
|
||||
await expect(termsLink).toHaveCSS("text-decoration-line", "none");
|
||||
await expect(privacyLink).toHaveCSS("text-decoration-line", "none");
|
||||
await termsLink.hover();
|
||||
await expect(termsLink).toHaveCSS("text-decoration-line", "underline");
|
||||
await page.mouse.move(0, 0);
|
||||
await privacyLink.hover();
|
||||
await expect(privacyLink).toHaveCSS("text-decoration-line", "underline");
|
||||
|
||||
await page
|
||||
.locator("label")
|
||||
.filter({ hasText: "I am 18 years of age or older." })
|
||||
.click();
|
||||
await expect(ageConfirmation).toBeChecked();
|
||||
await expect(acceptInvite).toBeDisabled();
|
||||
await page
|
||||
.locator("label")
|
||||
.filter({
|
||||
hasText: "I agree to the Buzz Terms of Service and Privacy Policy.",
|
||||
})
|
||||
.click({ position: { x: 8, y: 8 } });
|
||||
await expect(agreementConfirmation).toBeChecked();
|
||||
await expect(acceptInvite).toBeEnabled();
|
||||
|
||||
const consentBox = await page
|
||||
.getByTestId("invite-join-policy-notice")
|
||||
.boundingBox();
|
||||
const acceptButtonBox = await acceptInvite.boundingBox();
|
||||
expect(consentBox?.y).toBeLessThan(acceptButtonBox?.y ?? 0);
|
||||
expect(consentBox?.width).toBe(acceptButtonBox?.width);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user