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:
Kalvin C
2026-07-27 15:19:39 -07:00
committed by GitHub
co-authored by npub1c4alndp82zyt9veaklm5d965quss79vlhk9awv7qu5erwhmf42qqlvc25c npub122y0pqkertljmedu303rl0aqrj3w8pvu43t6jxm6875lzg6f2pwqegc3xc npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7
parent 98a7b13348
commit d500c2d5cf
24 changed files with 1732 additions and 35 deletions
@@ -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,
),