mirror of
https://github.com/block/buzz.git
synced 2026-10-30 05:51:46 +00:00
feat(invites): add use-limited invite links (#3141)
## Summary - add database-backed v2 invite links with optional maximum-use limits and atomic final-slot redemption - preserve v1 invite compatibility while adding exhausted/expired/invalid client handling across desktop, web, and mobile - emit structured claim-outcome logs with community, invite ID, outcome, maximum uses, and post-claim count ## Verification - `cargo fmt --all -- --check` - `cargo test -p buzz-db` (85 passed, 134 Postgres-dependent ignored) - `cargo clippy -p buzz-db --all-targets -- -D warnings` - desktop `npm run typecheck` - push hook: desktop checks/tests, desktop Tauri tests, Rust tests, and branch-skew passed - Postgres integration tests were previously reviewed green at the pre-rebase tree; local rerun on this session was unavailable because Postgres/Docker were not running - mobile push-hook check could not start because Flutter is unavailable locally --------- Signed-off-by: Kalvin Chau <kalvin@block.xyz> Signed-off-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz> Co-authored-by: npub1c4alndp82zyt9veaklm5d965quss79vlhk9awv7qu5erwhmf42qqlvc25c <c57bf9b4275088b2b33db7f746975407210f159fbd8bd733c0e532375f69aa80@buzz.block.builderlab.xyz> Co-authored-by: npub122y0pqkertljmedu303rl0aqrj3w8pvu43t6jxm6875lzg6f2pwqegc3xc <5288f082d91aff2de5bc8be23fbfa01ca2e3859cac57a91b7a3fa9f12349505c@buzz.block.builderlab.xyz> Co-authored-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz>
This commit is contained in:
@@ -14,8 +14,10 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/ui/dropdown-menu";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { Separator } from "@/shared/ui/separator";
|
||||
import { Spinner } from "@/shared/ui/spinner";
|
||||
import { Switch } from "@/shared/ui/switch";
|
||||
|
||||
const TTL_OPTIONS: { label: string; value: number }[] = [
|
||||
{ label: "1 day", value: 24 * 60 * 60 },
|
||||
@@ -31,8 +33,9 @@ type CopyStatus = "idle" | "copying" | "copied";
|
||||
/**
|
||||
* Share-with-link footer for the community invite dialog.
|
||||
*
|
||||
* Each copy action mints a fresh stateless invite code and places its
|
||||
* shareable landing-page URL on the clipboard.
|
||||
* Each copy action mints a fresh database-backed invite code and places its
|
||||
* shareable landing-page URL on the clipboard. Invites may be unlimited or
|
||||
* capped to a caller-selected number of successful joins.
|
||||
*/
|
||||
export function InviteLinkSection({
|
||||
onTtlSecsChange,
|
||||
@@ -42,6 +45,14 @@ export function InviteLinkSection({
|
||||
ttlSecs: number;
|
||||
}) {
|
||||
const [copyStatus, setCopyStatus] = React.useState<CopyStatus>("idle");
|
||||
const [maxUsesEnabled, setMaxUsesEnabled] = React.useState(true);
|
||||
const [maxUsesInput, setMaxUsesInput] = React.useState("3");
|
||||
const parsedMaxUses = Number(maxUsesInput);
|
||||
const maxUsesValid =
|
||||
!maxUsesEnabled ||
|
||||
(Number.isInteger(parsedMaxUses) &&
|
||||
parsedMaxUses >= 1 &&
|
||||
parsedMaxUses <= 10000);
|
||||
const ttlLabel =
|
||||
TTL_OPTIONS.find((option) => option.value === ttlSecs)?.label ?? "3 days";
|
||||
const copyLabel =
|
||||
@@ -58,10 +69,13 @@ export function InviteLinkSection({
|
||||
}, [copyStatus]);
|
||||
|
||||
async function handleCopy() {
|
||||
if (copyStatus === "copying") return;
|
||||
if (copyStatus === "copying" || !maxUsesValid) return;
|
||||
setCopyStatus("copying");
|
||||
try {
|
||||
const invite = await mintInvite(ttlSecs);
|
||||
const invite = await mintInvite({
|
||||
ttlSecs,
|
||||
maxUses: maxUsesEnabled ? parsedMaxUses : null,
|
||||
});
|
||||
await writeTextToClipboard(invite.url);
|
||||
setCopyStatus("copied");
|
||||
toast.success("Invite link copied");
|
||||
@@ -118,13 +132,45 @@ export function InviteLinkSection({
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center gap-2 text-xs">
|
||||
<Switch
|
||||
checked={maxUsesEnabled}
|
||||
data-testid="invite-link-max-uses-switch"
|
||||
id="invite-max-uses"
|
||||
onCheckedChange={setMaxUsesEnabled}
|
||||
/>
|
||||
<label className="text-muted-foreground" htmlFor="invite-max-uses">
|
||||
Limit uses
|
||||
</label>
|
||||
{maxUsesEnabled ? (
|
||||
<Input
|
||||
className="h-7 w-20"
|
||||
data-testid="invite-link-max-uses-input"
|
||||
inputMode="numeric"
|
||||
max={10000}
|
||||
min={1}
|
||||
onChange={(event) => setMaxUsesInput(event.target.value)}
|
||||
placeholder="3"
|
||||
type="number"
|
||||
value={maxUsesInput}
|
||||
/>
|
||||
) : null}
|
||||
{maxUsesEnabled && !maxUsesValid ? (
|
||||
<span
|
||||
className="text-destructive"
|
||||
data-testid="invite-link-max-uses-error"
|
||||
>
|
||||
Enter a whole number from 1 to 10,000
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<Separator className="my-4 bg-input/40" />
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
className="shrink-0 border-border shadow-none"
|
||||
data-copy-status={copyStatus}
|
||||
data-testid="copy-invite-link"
|
||||
disabled={copyStatus === "copying"}
|
||||
disabled={copyStatus === "copying" || !maxUsesValid}
|
||||
onClick={() => void handleCopy()}
|
||||
size="sm"
|
||||
type="button"
|
||||
|
||||
@@ -3,6 +3,7 @@ import * as React from "react";
|
||||
import { useCommunityOnboarding } from "@/features/onboarding/communityOnboarding";
|
||||
import {
|
||||
inviteErrorMessage,
|
||||
isInviteExhaustedError,
|
||||
isInviteExpiredError,
|
||||
} from "@/shared/api/inviteHelpers";
|
||||
import { claimInvite } from "@/shared/api/invites";
|
||||
@@ -39,7 +40,9 @@ export function useClaimInvite() {
|
||||
{
|
||||
error: isInviteExpiredError(error)
|
||||
? "This invite code has expired — ask for a new one."
|
||||
: inviteErrorMessage(error),
|
||||
: isInviteExhaustedError(error)
|
||||
? "This invite has reached its use limit. Ask for a new invite."
|
||||
: inviteErrorMessage(error),
|
||||
},
|
||||
transaction.id,
|
||||
),
|
||||
|
||||
@@ -3,6 +3,7 @@ import test from "node:test";
|
||||
|
||||
import {
|
||||
inviteErrorMessage,
|
||||
isInviteExhaustedError,
|
||||
isInviteExpiredError,
|
||||
relayHttpFromWs,
|
||||
} from "./inviteHelpers.ts";
|
||||
@@ -34,3 +35,9 @@ test("invite expiry sentinel is recognized without hiding other errors", () => {
|
||||
"network unavailable",
|
||||
);
|
||||
});
|
||||
|
||||
test("invite exhaustion sentinel is recognized distinctly from expiry", () => {
|
||||
assert.equal(isInviteExhaustedError(new Error("invite_exhausted")), true);
|
||||
assert.equal(isInviteExhaustedError(new Error("invite_expired")), false);
|
||||
assert.equal(isInviteExhaustedError(new Error("invite_invalid")), false);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export const INVITE_EXPIRED_ERROR = "invite_expired";
|
||||
export const INVITE_EXHAUSTED_ERROR = "invite_exhausted";
|
||||
|
||||
/**
|
||||
* Parsed invite — either a full (relay + code) or bare-code form.
|
||||
@@ -89,3 +90,7 @@ export function inviteErrorMessage(error: unknown): string {
|
||||
export function isInviteExpiredError(error: unknown): boolean {
|
||||
return inviteErrorMessage(error) === INVITE_EXPIRED_ERROR;
|
||||
}
|
||||
|
||||
export function isInviteExhaustedError(error: unknown): boolean {
|
||||
return inviteErrorMessage(error) === INVITE_EXHAUSTED_ERROR;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { getJoinPolicy } from "./invites.ts";
|
||||
import { getJoinPolicy, mintInvite } from "./invites.ts";
|
||||
|
||||
function withFetch(response, run) {
|
||||
const originalFetch = globalThis.fetch;
|
||||
@@ -81,3 +81,131 @@ test("getJoinPolicy maps the native command response", async () => {
|
||||
globalThis.window = previousWindow;
|
||||
}
|
||||
});
|
||||
|
||||
// --- mintInvite serialization ---
|
||||
|
||||
// The test-loader transpiles TS imports. tauri.ts imports `invoke` from
|
||||
// @tauri-apps/api/core, which calls `window.__TAURI_INTERNALS__.invoke`.
|
||||
// We stub that here so getRelayHttpUrl() and signRelayEvent() work in node.
|
||||
|
||||
function setupTauriStubs(
|
||||
httpBase,
|
||||
authEvent = {
|
||||
id: "x",
|
||||
sig: "y",
|
||||
pubkey: "z",
|
||||
kind: 27235,
|
||||
created_at: 1,
|
||||
tags: [],
|
||||
},
|
||||
) {
|
||||
const calls = { invokeArgs: [] };
|
||||
globalThis.window = globalThis.window ?? {};
|
||||
globalThis.window.__TAURI_INTERNALS__ = {
|
||||
invoke: async (command, args) => {
|
||||
calls.invokeArgs.push({ command, args });
|
||||
if (command === "get_relay_http_url") return httpBase;
|
||||
if (command === "sign_event") return JSON.stringify(authEvent);
|
||||
throw new Error(`Unexpected Tauri command: ${command}`);
|
||||
},
|
||||
};
|
||||
return calls;
|
||||
}
|
||||
|
||||
function teardownTauriStubs() {
|
||||
delete globalThis.window.__TAURI_INTERNALS__;
|
||||
}
|
||||
|
||||
test("mintInvite serializes bounded max_uses in the request body", async () => {
|
||||
setupTauriStubs("https://relay.example");
|
||||
try {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let capturedBody;
|
||||
globalThis.fetch = async (_url, init) => {
|
||||
capturedBody = JSON.parse(init.body);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
code: "v2.abc123",
|
||||
expires_at: 1785100000,
|
||||
url: "https://relay.example/invite/v2.abc123",
|
||||
max_uses: 10,
|
||||
uses_remaining: 10,
|
||||
}),
|
||||
);
|
||||
};
|
||||
try {
|
||||
const result = await mintInvite({ ttlSecs: 259200, maxUses: 10 });
|
||||
assert.equal(capturedBody.ttl_secs, 259200);
|
||||
assert.equal(capturedBody.max_uses, 10);
|
||||
assert.equal(result.code, "v2.abc123");
|
||||
assert.equal(result.maxUses, 10);
|
||||
assert.equal(result.usesRemaining, 10);
|
||||
assert.equal(result.expiresAt, 1785100000);
|
||||
assert.equal(result.url, "https://relay.example/invite/v2.abc123");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
} finally {
|
||||
teardownTauriStubs();
|
||||
}
|
||||
});
|
||||
|
||||
test("mintInvite omits max_uses when null (unlimited)", async () => {
|
||||
setupTauriStubs("https://relay.example");
|
||||
try {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let capturedBody;
|
||||
globalThis.fetch = async (_url, init) => {
|
||||
capturedBody = JSON.parse(init.body);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
code: "v2.abc123",
|
||||
expires_at: 1785100000,
|
||||
url: "https://relay.example/invite/v2.abc123",
|
||||
max_uses: null,
|
||||
uses_remaining: null,
|
||||
}),
|
||||
);
|
||||
};
|
||||
try {
|
||||
const result = await mintInvite({ ttlSecs: 259200, maxUses: null });
|
||||
assert.equal(capturedBody.ttl_secs, 259200);
|
||||
assert.equal(Object.hasOwn(capturedBody, "max_uses"), false);
|
||||
assert.equal(result.maxUses, null);
|
||||
assert.equal(result.usesRemaining, null);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
} finally {
|
||||
teardownTauriStubs();
|
||||
}
|
||||
});
|
||||
|
||||
test("mintInvite omits max_uses when not provided (unlimited default)", async () => {
|
||||
setupTauriStubs("https://relay.example");
|
||||
try {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let capturedBody;
|
||||
globalThis.fetch = async (_url, init) => {
|
||||
capturedBody = JSON.parse(init.body);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
code: "v2.abc123",
|
||||
expires_at: 1785100000,
|
||||
url: "https://relay.example/invite/v2.abc123",
|
||||
max_uses: null,
|
||||
uses_remaining: null,
|
||||
}),
|
||||
);
|
||||
};
|
||||
try {
|
||||
await mintInvite({ ttlSecs: 86400 });
|
||||
assert.equal(capturedBody.ttl_secs, 86400);
|
||||
assert.equal(Object.hasOwn(capturedBody, "max_uses"), false);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
} finally {
|
||||
teardownTauriStubs();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -25,6 +25,8 @@ export type MintedInvite = {
|
||||
code: string;
|
||||
expiresAt: number;
|
||||
url: string;
|
||||
maxUses: number | null;
|
||||
usesRemaining: number | null;
|
||||
};
|
||||
|
||||
export type JoinPolicy = {
|
||||
@@ -189,15 +191,29 @@ export async function acceptJoinPolicy(
|
||||
}
|
||||
|
||||
/** Mint an invite code on the active community's relay (owner/admin only). */
|
||||
export async function mintInvite(ttlSecs?: number): Promise<MintedInvite> {
|
||||
export async function mintInvite(options?: {
|
||||
ttlSecs?: number;
|
||||
maxUses?: number | null;
|
||||
}): Promise<MintedInvite> {
|
||||
const base = await getRelayHttpUrl();
|
||||
const body = JSON.stringify(ttlSecs ? { ttl_secs: ttlSecs } : {});
|
||||
const payload: Record<string, unknown> = {};
|
||||
if (options?.ttlSecs != null) payload.ttl_secs = options.ttlSecs;
|
||||
if (options?.maxUses != null) payload.max_uses = options.maxUses;
|
||||
const body = JSON.stringify(payload);
|
||||
const raw = await invitePost<{
|
||||
code: string;
|
||||
expires_at: number;
|
||||
url: string;
|
||||
max_uses: number | null;
|
||||
uses_remaining: number | null;
|
||||
}>(base, "/api/invites", body);
|
||||
return { code: raw.code, expiresAt: raw.expires_at, url: raw.url };
|
||||
return {
|
||||
code: raw.code,
|
||||
expiresAt: raw.expires_at,
|
||||
url: raw.url,
|
||||
maxUses: raw.max_uses,
|
||||
usesRemaining: raw.uses_remaining,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user