Files
claudemesh/apps/web/src/modules/mesh/invite-generator.tsx
Alejandro Gutiérrez c1fa3bcb5c feat: anthropic-style mesh + invite redesign (wave 1 checkpoint)
Ships the user-visible friction fixes and the foundation for the v2
invite protocol. API wiring + CLI client + email UI ship in wave 2.

Meshes — shipped
- Drop global UNIQUE on mesh.slug; mesh.id is canonical everywhere
- Server derives slug from name; create form has no slug field
- Two users can freely name their mesh "platform"; no collision errors
- Migration 0017

Invites v1 — shipped (URL shortener, backward compatible)
- New invite.code column (base62, 8 chars, nullable unique index)
- createMyInvite mints both token + short code; returns shortUrl
- GET /api/public/invite-code/:code resolves short code to token
- New route /i/[code] server-redirects to /join/[token]
- Invite generator UI shows short URL; QR encodes short URL
- Advanced fields (role/maxUses/expiresInDays) collapsed under disclosure
- Migration 0018

Invites v2 — foundation (broker + DB only; API+CLI+Web wiring in wave 2)
- Broker: canonicalInviteV2, verifyInviteV2, sealRootKeyToRecipient
- Broker: POST /invites/:code/claim endpoint (atomic single-use accounting)
- Broker tests: invite-v2.test.ts (signature, expiry, revocation, exhaustion)
- DB: mesh.invite gains version/capabilityV2/claimedByPubkey columns
- DB: new mesh.pending_invite table for email invites
- Migration 0019
- Contract locked in docs/protocol.md §v2 + SPEC.md §14b

Consent landing — shipped
- /join/[token] redesigned: explicit role, inviter, mesh stats, consent
- New server components: invite-card, role-badge, inviter-line, consent-summary
- "Join [mesh] as [Role]" primary action (not just "Join")

Error surfacing — shipped
- handle() now parses {error} responses from hono route catch blocks
- onError fallback includes timestamp so handle() can match apiErrorSchema
- Real error messages reach the UI instead of "Something went wrong"

Docs
- SPEC.md §14b: v2 invite protocol
- docs/protocol.md: v2 claim wire format
- docs/roadmap.md: status
- .artifacts/specs/2026-04-10-anthropic-vision-meshes-invites.md

Deferred to wave 2/3
- API claim route wiring (packages/api)
- createMyInvite v2 capability generation
- Email invite mutation + Postmark delivery
- CLI v2 join flow (x25519 keypair + unseal)
- Web invite-generator email field + v2 display

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 13:41:11 +01:00

270 lines
9.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import QRCode from "qrcode";
import {
createMyInviteInputSchema,
type CreateMyInviteInput,
} from "@turbostarter/api/schema";
import { handle } from "@turbostarter/api/utils";
import { Badge } from "@turbostarter/ui-web/badge";
import { Button } from "@turbostarter/ui-web/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@turbostarter/ui-web/form";
import { Input } from "@turbostarter/ui-web/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@turbostarter/ui-web/select";
import { api } from "~/lib/api/client";
interface GeneratedInvite {
id: string;
token: string;
inviteLink: string;
joinUrl: string;
/** Short human-friendly URL, preferred for sharing. Null if the backend didn't mint one. */
shortUrl: string | null;
expiresAt: Date;
qrDataUrl: string;
}
export const InviteGenerator = ({ meshId }: { meshId: string }) => {
const [result, setResult] = useState<GeneratedInvite | null>(null);
const [copied, setCopied] = useState<"url" | "cli" | null>(null);
const [showAdvanced, setShowAdvanced] = useState(false);
const form = useForm<CreateMyInviteInput>({
resolver: zodResolver(createMyInviteInputSchema),
defaultValues: { role: "member", maxUses: 1, expiresInDays: 7 },
});
const onSubmit = async (values: CreateMyInviteInput) => {
try {
const res = (await handle(api.my.meshes[":id"].invites.$post)({
param: { id: meshId },
json: values,
})) as {
id: string;
token: string;
inviteLink: string;
joinUrl: string;
shortUrl: string | null;
expiresAt: string;
};
// QR encodes the SHORT URL when available — scannable at camera distance
// and short enough for the QR to stay low-density. Falls back to the
// long token URL for legacy invites minted before the shortener shipped.
const qrTarget = res.shortUrl ?? res.joinUrl;
const qrDataUrl = await QRCode.toDataURL(qrTarget, {
width: 256,
margin: 1,
color: { dark: "#141413", light: "#ffffff" },
});
setResult({
id: res.id,
token: res.token,
inviteLink: res.inviteLink,
joinUrl: res.joinUrl,
shortUrl: res.shortUrl,
expiresAt: new Date(res.expiresAt),
qrDataUrl,
});
} catch (e) {
form.setError("root", {
message: e instanceof Error ? e.message : "Failed to generate invite.",
});
}
};
const copy = async (text: string, key: "url" | "cli") => {
await navigator.clipboard.writeText(text);
setCopied(key);
setTimeout(() => setCopied(null), 2000);
};
if (result) {
// Prefer the short URL everywhere it exists. CLI command still uses the
// long token because the broker resolves by token — swapping CLI to short
// codes is part of the v2 protocol, not this URL-shortener change.
const primaryUrl = result.shortUrl ?? result.joinUrl;
const cliCmd = `claudemesh join ${result.token}`;
return (
<div className="space-y-6">
<div className="rounded-lg border p-6">
<div className="grid gap-6 md:grid-cols-[220px_1fr]">
<div className="flex items-start justify-center">
<img
src={result.qrDataUrl}
alt="Invite QR code"
className="h-[220px] w-[220px] rounded border"
/>
</div>
<div className="space-y-4">
<div>
<div className="text-muted-foreground mb-1 text-xs uppercase tracking-wider">
Share this link
</div>
<code className="bg-muted block break-all rounded p-3 font-mono text-xs">
{primaryUrl}
</code>
</div>
<div className="flex flex-wrap items-center gap-3 text-xs">
<Badge variant="outline">
expires {result.expiresAt.toLocaleDateString()}
</Badge>
</div>
<div className="flex flex-wrap gap-2">
<Button onClick={() => copy(primaryUrl, "url")} size="sm">
{copied === "url" ? "Copied ✓" : "Copy link"}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => copy(cliCmd, "cli")}
>
{copied === "cli" ? "Copied ✓" : "Copy CLI command"}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
setResult(null);
form.reset();
}}
>
Generate another
</Button>
</div>
</div>
</div>
</div>
<div className="text-muted-foreground rounded border border-dashed p-4 text-xs">
<p className="mb-2 font-medium">How your teammate joins:</p>
<p className="mb-2">
Paste the link in Slack / Telegram / email. They land on a page
with step-by-step install, or run the CLI directly if they already
have it:
</p>
<code className="bg-muted block rounded p-2 font-mono text-xs">
{cliCmd}
</code>
</div>
</div>
);
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="max-w-md space-y-5">
<p className="text-muted-foreground text-sm">
One-time invite for a new member. Valid for 7 days.
</p>
{/* Advanced options — hidden by default. Defaults ship 90% of users. */}
<div className="rounded-md border border-dashed">
<button
type="button"
onClick={() => setShowAdvanced((s) => !s)}
className="text-muted-foreground hover:text-foreground flex w-full items-center justify-between px-3 py-2 text-xs uppercase tracking-wider"
aria-expanded={showAdvanced}
>
<span>Advanced</span>
<span aria-hidden="true">{showAdvanced ? "" : "+"}</span>
</button>
{showAdvanced && (
<div className="space-y-4 border-t px-3 py-4">
<FormField
control={form.control}
name="role"
render={({ field }) => (
<FormItem>
<FormLabel>Role</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="member">Member</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="maxUses"
render={({ field }) => (
<FormItem>
<FormLabel>Max uses</FormLabel>
<FormControl>
<Input
type="number"
min={1}
max={1000}
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="expiresInDays"
render={({ field }) => (
<FormItem>
<FormLabel>Expires in (days)</FormLabel>
<FormControl>
<Input
type="number"
min={1}
max={365}
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
</div>
{form.formState.errors.root && (
<p className="text-destructive text-sm">
{form.formState.errors.root.message}
</p>
)}
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? "Generating…" : "Generate invite"}
</Button>
</form>
</Form>
);
};