Refine community invite limits (#3529)

## Summary

- Simplify the community invite dialog around link sharing.
- Add matching expiry and use-limit dropdowns, with sensible preset use
caps.
- Cover the default unlimited and selected-limit invite payloads.

## Validation

- `pnpm -C desktop run build:e2e`
- `pnpm -C desktop exec playwright test
tests/e2e/invite-link-copy.spec.ts
tests/e2e/invites-settings-screenshots.spec.ts --project=smoke`

Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
klopez4212
2026-07-29 15:42:17 +01:00
committed by GitHub
parent ce01e930ed
commit 24d90d1280
4 changed files with 134 additions and 96 deletions
@@ -3,6 +3,7 @@ import * as React from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/shared/ui/dialog";
@@ -29,12 +30,14 @@ export function CommunityInviteDialog({
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent
aria-describedby={undefined}
className="max-h-[85vh] max-w-xl overflow-y-auto"
data-testid="community-invite-dialog"
>
<DialogHeader className="space-y-0">
<DialogHeader>
<DialogTitle>Invite to community</DialogTitle>
<DialogDescription>
Anyone with this link can join this community.
</DialogDescription>
</DialogHeader>
<InviteLinkSection onTtlSecsChange={setTtlSecs} ttlSecs={ttlSecs} />
@@ -8,16 +8,12 @@ import { Button } from "@/shared/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
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 },
@@ -26,6 +22,15 @@ const TTL_OPTIONS: { label: string; value: number }[] = [
{ label: "30 days", value: 30 * 24 * 60 * 60 },
];
const MAX_USE_OPTIONS: { label: string; value: number | null }[] = [
{ label: "No limit", value: null },
{ label: "1 use", value: 1 },
{ label: "3 uses", value: 3 },
{ label: "5 uses", value: 5 },
{ label: "10 uses", value: 10 },
{ label: "25 uses", value: 25 },
];
export const DEFAULT_INVITE_TTL_SECS = TTL_OPTIONS[1].value;
type CopyStatus = "idle" | "copying" | "copied";
@@ -45,16 +50,12 @@ 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 [maxUses, setMaxUses] = React.useState<number | null>(null);
const ttlLabel =
TTL_OPTIONS.find((option) => option.value === ttlSecs)?.label ?? "3 days";
const maxUsesLabel =
MAX_USE_OPTIONS.find((option) => option.value === maxUses)?.label ??
"No limit";
const copyLabel =
copyStatus === "copying"
? "Copying…"
@@ -69,13 +70,10 @@ export function InviteLinkSection({
}, [copyStatus]);
async function handleCopy() {
if (copyStatus === "copying" || !maxUsesValid) return;
if (copyStatus === "copying") return;
setCopyStatus("copying");
try {
const invite = await mintInvite({
ttlSecs,
maxUses: maxUsesEnabled ? parsedMaxUses : null,
});
const invite = await mintInvite({ ttlSecs, maxUses });
await writeTextToClipboard(invite.url);
setCopyStatus("copied");
toast.success("Invite link copied");
@@ -87,82 +85,79 @@ export function InviteLinkSection({
return (
<section className="pt-2" data-testid="community-invite-link-section">
<div className="flex items-center gap-3">
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
<Link2 aria-hidden="true" className="h-4 w-4" />
</span>
<div className="min-w-0 flex-1">
<h3 className="text-sm font-medium">Share with a link</h3>
<p className="text-xs text-secondary-foreground/75">
Anyone with the link can join this community.
</p>
<div className="space-y-5">
<div className="flex items-center justify-between gap-4">
<span className="text-sm font-medium">Expires after</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label="Choose invite expiry"
className="h-8 shrink-0 gap-1.5 px-2 text-sm text-muted-foreground"
data-testid="invite-link-ttl-trigger"
disabled={copyStatus === "copying"}
size="sm"
type="button"
variant="ghost"
>
{ttlLabel}
<ChevronDown aria-hidden="true" className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuRadioGroup
onValueChange={(value) => onTtlSecsChange(Number(value))}
value={String(ttlSecs)}
>
{TTL_OPTIONS.map((option) => (
<DropdownMenuRadioItem
data-testid={`invite-link-ttl-${option.value}`}
key={option.value}
value={String(option.value)}
>
{option.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="flex items-center justify-between gap-4">
<span className="text-sm font-medium">Limit number of uses</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label="Choose maximum invite uses"
className="h-8 shrink-0 gap-1.5 px-2 text-sm text-muted-foreground"
data-testid="invite-link-max-uses-trigger"
disabled={copyStatus === "copying"}
size="sm"
type="button"
variant="ghost"
>
{maxUsesLabel}
<ChevronDown aria-hidden="true" className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuRadioGroup
onValueChange={(value) =>
setMaxUses(value === "no-limit" ? null : Number(value))
}
value={String(maxUses ?? "no-limit")}
>
{MAX_USE_OPTIONS.map((option) => (
<DropdownMenuRadioItem
data-testid={`invite-link-max-uses-${option.value ?? "no-limit"}`}
key={option.value ?? "no-limit"}
value={String(option.value ?? "no-limit")}
>
{option.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label="Choose invite expiry"
className="h-8 shrink-0 gap-1.5 px-2 text-muted-foreground"
data-testid="invite-link-ttl-trigger"
disabled={copyStatus === "copying"}
size="sm"
type="button"
variant="ghost"
>
{ttlLabel}
<ChevronDown aria-hidden="true" className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuLabel>Expires after</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup
onValueChange={(value) => onTtlSecsChange(Number(value))}
value={String(ttlSecs)}
>
{TTL_OPTIONS.map((option) => (
<DropdownMenuRadioItem
data-testid={`invite-link-ttl-${option.value}`}
key={option.value}
value={String(option.value)}
>
{option.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</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">
@@ -170,7 +165,7 @@ export function InviteLinkSection({
className="shrink-0 border-border shadow-none"
data-copy-status={copyStatus}
data-testid="copy-invite-link"
disabled={copyStatus === "copying" || !maxUsesValid}
disabled={copyStatus === "copying"}
onClick={() => void handleCopy()}
size="sm"
type="button"
@@ -3,7 +3,10 @@ import { expect, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
import { openSettings } from "../helpers/settings";
let invitePayloads: Record<string, unknown>[];
test.beforeEach(async ({ page }) => {
invitePayloads = [];
await page.context().grantPermissions(["clipboard-read", "clipboard-write"], {
origin: "http://127.0.0.1:4173",
});
@@ -11,6 +14,7 @@ test.beforeEach(async ({ page }) => {
relayRequiresMembership: true,
});
await page.route("**/api/invites", async (route) => {
invitePayloads.push(route.request().postDataJSON());
await route.fulfill({
contentType: "application/json",
json: {
@@ -33,8 +37,12 @@ test("copies a freshly minted invite link without showing a URL or QR code", asy
await page.getByTestId("community-invite-dialog-trigger").click();
await expect(page.getByTestId("invite-link-url")).toHaveCount(0);
await expect(page.getByTestId("invite-link-qr-code")).toHaveCount(0);
await expect(page.getByTestId("invite-link-max-uses-trigger")).toHaveText(
"No limit",
);
await page.getByTestId("copy-invite-link").click();
await expect(page.getByTestId("copy-invite-link")).toContainText("Copied");
expect(invitePayloads).toEqual([{ ttl_secs: 3 * 24 * 60 * 60 }]);
const payload = await page.evaluate(() => {
const log = (
@@ -53,3 +61,25 @@ test("copies a freshly minted invite link without showing a URL or QR code", asy
text: "buzz://join?relay=wss%3A%2F%2Frelay.example.com&code=qr-download-test",
});
});
test("sets a selected invite-use limit", async ({ page }) => {
await page.goto("/");
await openSettings(page, "community-members");
await page.getByTestId("community-invite-dialog-trigger").click();
const maxUsesTrigger = page.getByTestId("invite-link-max-uses-trigger");
await maxUsesTrigger.click();
await expect(
page.getByRole("menuitemradio", { name: "No limit" }),
).toBeVisible();
await expect(
page.getByRole("menuitemradio", { name: "25 uses" }),
).toBeVisible();
await page.getByTestId("invite-link-max-uses-10").click();
await expect(maxUsesTrigger).toHaveText("10 uses");
await page.getByTestId("copy-invite-link").click();
await expect(page.getByTestId("copy-invite-link")).toContainText("Copied");
expect(invitePayloads).toEqual([
{ max_uses: 10, ttl_secs: 3 * 24 * 60 * 60 },
]);
});
@@ -78,15 +78,25 @@ test("capture: share-style community invite dialog", async ({ page }) => {
await expect(page.getByTestId("community-invite-email-field")).toHaveCount(0);
await expect(page.getByPlaceholder("Type an email address")).toHaveCount(0);
await expect(
dialog.getByRole("heading", { name: "Share with a link" }),
dialog.getByText("Anyone with this link can join this community."),
).toBeVisible();
await expect(dialog.getByText("Expires after")).toBeVisible();
await expect(dialog.getByText("Limit number of uses")).toBeVisible();
await expect(page.getByTestId("invite-link-max-uses-trigger")).toHaveText(
"No limit",
);
await expect(page.getByTestId("copy-invite-link")).toHaveText("Copy link");
await expect(page.getByTestId("invite-link-qr-code")).toHaveCount(0);
await expect(page.getByTestId("invite-link-url")).toHaveCount(0);
const expiryTrigger = page.getByTestId("invite-link-ttl-trigger");
await expect(expiryTrigger).toHaveText("3 days");
await expect(expiryTrigger).toHaveCSS("font-size", "14px");
await expect(
dialog.getByText("Limit number of uses", { exact: true }),
).toHaveCSS("font-size", "14px");
await expiryTrigger.click();
await expect(page.getByRole("menu")).not.toContainText("Expires after");
await expect(
page.getByRole("menuitemradio", { name: "1 day" }),
).toBeVisible();