Skip to content
Open
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
41 changes: 41 additions & 0 deletions src/features/identity/BackupOffer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { Button } from "../../shared/design-system/ui/Button";

/**
* Pause after an imported key, on either lane. Importing must never upload
* silently: the backup is offered, and the key continues either way. The key
* may be used elsewhere, so the copy discloses that Block can restore it there
* too; choosing Back it up is the backup consent.
*/
export function BackupOffer({
onSkip,
onBackUp,
}: {
onSkip: () => void;
onBackUp: () => void;
}) {
return (
<>
<div>
<h1
id="welcome-title"
tabIndex={-1}
className="text-heading text-standard"
>
Back up this key?
</h1>
<p className="mt-2 text-body text-subtle">
Buzz can keep an encrypted copy in your account so you can skip this
step on your next device. Block can use that copy to restore this key,
including anywhere else you use it.
</p>
</div>
{/* Footer order: secondary first, primary last. */}
<div className="flex gap-3">
<Button onClick={onSkip}>Not now</Button>
<Button variant="prominent" onClick={onBackUp}>
Back it up
</Button>
</div>
</>
);
}
47 changes: 47 additions & 0 deletions src/features/identity/CommunityUrlField.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { expect, it } from "vitest";
import { enteredCommunity } from "./CommunityUrlField";

it.each([
["relay.example.com", "https://relay.example.com", ""],
[" Relay.Example.com:8443 ", "https://relay.example.com:8443", ""],
["wss://relay.example.com/", "https://relay.example.com", ""],
["https://relay.example.com", "https://relay.example.com", ""],
[
"https://Relay.Example.com:8443/invite/v2.Invite-code_1",
"https://relay.example.com:8443",
"v2.Invite-code_1",
],
[
"https://relay.example.com/invite/v2.code/",
"https://relay.example.com",
"v2.code",
],
[
"buzz://join?relay=wss%3A%2F%2Frelay.example.com%3A8443&code=v2.code",
"https://relay.example.com:8443",
"v2.code",
],
// Windows hands the app link over with a root path.
[
"buzz://join/?relay=wss%3A%2F%2Frelay.example.com&code=v2.code&policy_receipt=r",
"https://relay.example.com",
"v2.code",
],
])("reads %j as %s with code %j", (input, origin, code) => {
expect(enteredCommunity(input)).toEqual({ origin, code });
});

it.each([
"",
"not a url",
"ws://relay.example.com",
"https://relay.example.com/other",
"relay.example.com/invite/v2.code",
"http://relay.example.com/invite/v2.code",
"https://user@relay.example.com/invite/v2.code",
"https://relay.example.com/invite/v2.code?next=evil",
"buzz://join?relay=wss%3A%2F%2Frelay.example.com",
"buzz://join?relay=ws%3A%2F%2Frelay.example.com&code=v2.code",
])("refuses %j", (input) => {
expect(enteredCommunity(input)).toBeUndefined();
});
88 changes: 88 additions & 0 deletions src/features/identity/CommunityUrlField.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { useId, useState } from "react";
import { enteredRelayOrigin } from "../communities/destination";
import { parsePastedInvite } from "../communities/invite-link";
import { Button } from "../../shared/design-system/ui/Button";
import { Field } from "../../shared/design-system/ui/Field";
import { Input } from "../../shared/design-system/ui/Input";

/**
* A typed address (a bare host is a secure relay) with no code, else an invite
* link — the relay's `https://<host>/invite/<code>` page or the `buzz://join`
* link it opens — with its code. A bare address never resolves an alias.
*/
export function enteredCommunity(url: string) {
try {
return { origin: enteredRelayOrigin(url), code: "" };
} catch {
const invite = parsePastedInvite(url);
return invite?.code
? { origin: invite.community, code: invite.code }
: undefined;
}
}

/**
* The community URL field with its Continue button, shared by the first-run
* landing and Join a community. Enter is handled by the input, not a native
* form submit, so it reports an empty or invalid address whatever the button's
* state; Continue therefore stays enabled (only `disabled` turns it off) and a
* click shows the same error under the field. Continue only starts the lookup;
* the join itself happens on Join Community.
*/
export function CommunityUrlField({
url,
onUrlChange,
onSubmit,
disabled = false,
}: {
url: string;
onUrlChange: (url: string) => void;
/** Receives the canonical secure origin, and an invite link's code ("" for an address). */
onSubmit: (origin: string, code: string) => void;
disabled?: boolean;
}) {
const [invalid, setInvalid] = useState(false);
const errorId = useId();
const entered = enteredCommunity(url);
const submit = () => {
if (entered) onSubmit(entered.origin, entered.code);
else setInvalid(true);
};
return (
<div className="flex flex-col gap-1">
<div className="flex items-end gap-3">
<div className="min-w-0 flex-1">
<Field label="Community URL" invalid={invalid} disabled={disabled}>
<Input
inputMode="url"
autoComplete="off"
spellCheck={false}
placeholder="relay.example.com"
value={url}
disabled={disabled}
aria-describedby={invalid ? errorId : undefined}
onChange={(event) => {
onUrlChange(event.target.value);
setInvalid(false);
}}
onKeyDown={(event) => {
if (event.key !== "Enter" || event.nativeEvent.isComposing)
return;
event.preventDefault();
submit();
}}
/>
</Field>
</div>
<Button variant="prominent" disabled={disabled} onClick={submit}>
Continue
</Button>
</div>
{invalid && (
<p id={errorId} role="alert" className="m-0 text-body-sm text-danger">
Please enter a valid community URL
</p>
)}
</div>
);
}
59 changes: 59 additions & 0 deletions src/features/identity/ConnectChoice.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { Button } from "../../shared/design-system/ui/Button";

/**
* Pause on a found community before any sign-in: the relay cannot sign the
* user in, but Buzz still needs a key. Connect runs
* the build portal's sign-in exactly as the landing's button does (the key it
* creates is linked to that account and backed up there); the import brings a
* key with no account to link it to. Either way setup ends on Join Community
* for the found address.
*/
export function ConnectChoice({
host,
onConnect,
onImport,
onBack,
}: {
/** The found community's host, as the find page shows it. */
host: string;
/** Sign in at the build portal and continue as the landing’s Sign in with email does. */
onConnect: () => void;
/** The user already has a key: open the import page. */
onImport: () => void;
/** Return to the landing with the address kept. */
onBack: () => void;
}) {
return (
<>
<div>
<h1
id="welcome-title"
tabIndex={-1}
className="text-heading text-standard"
>
Connect to your community
</h1>
{/* The host stands apart from the title so a long one wraps on its own
line and the title reads as one phrase to assistive technology. */}
<p className="mt-2 break-all font-mono text-mono-lg text-standard">
{host}
</p>
<p className="mt-2 text-body text-subtle">
Buzz relies on a key for your identity.
</p>
</div>
<div className="flex flex-col gap-3">
<Button variant="prominent" size="lg" onClick={onConnect}>
Sign in with email
</Button>
{/* The secondary choice is deliberately smaller than the primary. */}
<Button size="sm" data-return-focus="import" onClick={onImport}>
I already have a key
</Button>
</div>
<div className="flex gap-3">
<Button onClick={onBack}>Back</Button>
</div>
</>
);
}
73 changes: 73 additions & 0 deletions src/features/identity/InviteCodeField.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { useId, useState } from "react";
import { Button } from "../../shared/design-system/ui/Button";
import { Field } from "../../shared/design-system/ui/Field";
import { Input } from "../../shared/design-system/ui/Input";

/**
* The invite page's code field with its Join button. A refusal shows under
* the field until the code is edited; the typed code is kept either way.
*/
export function InviteCodeField({
error,
busy,
pending,
onSubmit,
}: {
/** Why the relay refused the last code. */
error?: string | undefined;
/** Something on the page is in flight: nothing can be submitted. */
busy: boolean;
/** This field's own claim is the one in flight. */
pending: boolean;
onSubmit: (code: string) => void;
}) {
const [code, setCode] = useState("");
const [edited, setEdited] = useState(false);
const errorId = useId();
const shown = edited ? undefined : error;
const value = code.trim();
const submit = () => {
if (!value || busy) return;
setEdited(false);
onSubmit(value);
};
// `CommunityUrlField`'s recipe: the error sits below the row, so Join stays
// aligned with the input.
return (
<div className="flex flex-col gap-1">
<div className="flex items-end gap-3">
<div className="min-w-0 flex-1">
<Field label="Invite code" invalid={!!shown} disabled={busy}>
<Input
autoComplete="off"
autoCapitalize="none"
spellCheck={false}
maxLength={256}
value={code}
disabled={busy}
aria-describedby={shown ? errorId : undefined}
onChange={(event) => {
setCode(event.target.value);
setEdited(true);
}}
onKeyDown={(event) => {
if (event.key !== "Enter" || event.nativeEvent.isComposing)
return;
event.preventDefault();
submit();
}}
/>
</Field>
</div>
<Button disabled={busy || !value} onClick={submit}>
{pending ? "Joining…" : "Join"}
</Button>
</div>
{shown && (
<p id={errorId} role="alert" className="m-0 text-body-sm text-danger">
{shown}
</p>
)}
</div>
);
}
82 changes: 82 additions & 0 deletions src/features/identity/JoinCommunity.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { Avatar } from "../../shared/design-system/ui/Avatar";
import { Button } from "../../shared/design-system/ui/Button";
import type { OwnedCommunity } from "./builderlab-account";
import { CommunityUrlField } from "./CommunityUrlField";
import { OnboardingScreen } from "./OnboardingScreen";

/**
* First-run landing after setup. The community URL is the live action; a
* linked account's owned communities are offered as rows that take the same
* path with their address.
*/
export function JoinCommunity({
url,
onUrlChange,
onFind,
owned = [],
}: {
url: string;
onUrlChange: (url: string) => void;
/** Receives the canonical secure origin, and an invite link's code when one was pasted. */
onFind: (origin: string, code?: string) => void;
owned?: readonly OwnedCommunity[] | undefined;
}) {
// The in-app Join a community landing's layout (CommunityLanding).
return (
<OnboardingScreen
labelledBy="welcome-title"
intro={
<>
<h1
id="welcome-title"
tabIndex={-1}
className="text-heading text-standard"
>
Join a community
</h1>
<div className="mt-4 grid w-full max-w-md gap-4 text-left">
{owned.length > 0 && (
// min-w-0 keeps a long hostname truncating rather than widening the grid.
<div className="flex min-w-0 flex-col gap-2">
<h2 className="m-0 text-body text-subtle">
Communities you own
</h2>
<ul
aria-label="Communities you own"
className="m-0 flex list-none flex-col gap-3 p-0"
>
{owned.map(({ host, url: origin }) => (
<li key={origin} className="flex items-center gap-3">
<Avatar alt="" fallback={host} />
<p className="m-0 min-w-0 flex-1 truncate text-label text-standard">
{host}
</p>
<Button
aria-label={`Join ${host}`}
onClick={() => onFind(origin)}
>
Join
</Button>
</li>
))}
</ul>
</div>
)}
<CommunityUrlField
url={url}
onUrlChange={onUrlChange}
onSubmit={onFind}
/>
</div>
</>
}
>
<Button size="lg" variant="ghost" disabled>
Scan a QR code — Coming soon
</Button>
<Button size="lg" variant="ghost" disabled>
Create a community — Coming soon
</Button>
</OnboardingScreen>
);
}
Loading
Loading