Files
claudemesh/apps/web/src/modules/mesh/invite-generator.tsx
Alejandro Gutiérrez 6acfc252b0
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
feat(web): public /join/[token] page + https invite url
Clickable HTTPS invite URLs replace the raw ic://join/<token> as the
primary share format. Someone receiving a link in Slack now lands on
a friendly page with install instructions, not a dead-end.

Backend:
- createMyInvite returns a new joinUrl field
  (https://claudemesh.com/join/<token>) alongside the existing
  ic://join/<token> inviteLink and raw token. Schema + Hono route
  updated. ic:// scheme stays — CLI parses both.
- New GET /api/public/invite/:token in packages/api/src/modules/public/
  (unauthed). Decodes the base64url payload, verifies ed25519
  signature against owner_pubkey using the same canonicalInvite()
  contract the broker enforces on join, then joins mesh/invite/user
  to return the shape needed by the landing page. Does NOT mutate
  usedCount — this is a read-only preview.
- Error taxonomy: malformed | bad_signature | expired | revoked |
  exhausted | mesh_archived | not_found. Each returned with any
  metadata we CAN surface (meshName, inviterName, expiresAt) so the
  error page can be specific ("ask Jordan for a new one").
- cache-control: public max-age=30 on valid invites, no-store on
  errors (reasons flip as state changes).

Frontend:
- New public route /[locale]/join/[token] (no auth). Server
  Component fetches the preview endpoint, branches on valid/invalid,
  renders a minimal landing-design-language shell (wordmark header,
  clay accents, serif headlines, mono commands).
- Valid-invite view: "You're invited to {meshName}", inviter +
  role + member-count lede, install-toggle component.
- Invalid-invite view: per-reason error copy + inviter name when
  available + link back to /.
- InstallToggle client component: three-way state
  (unknown/yes/no). Asks "first time / already set up?", then shows
  either the 3-step install+init+join path with per-step copy
  buttons, or the single claudemesh join <token> command for users
  who have the CLI. Every code block has copy-to-clipboard.
- Security footer: "ed25519 keypair generated locally, you keep
  your keys, broker sees ciphertext only, leave anytime with
  claudemesh leave <mesh-slug>".

Invite generator (/dashboard/meshes/[id]/invite):
- QR code now encodes the HTTPS joinUrl instead of ic:// (phone
  cameras land on the web page → friendly path).
- Primary CTA copies the HTTPS URL. Secondary "Copy CLI command"
  for fast-path users. Footer explanation updated.

CLI coordination note: dispatched to broker/db lane — claudemesh CLI
needs to accept BOTH ic://join/<token> AND
https://claudemesh.com/join/<token> (extract <token> from pathname).
Server side already returns both.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 16:36:24 +01:00

242 lines
7.3 KiB
TypeScript

"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;
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 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;
expiresAt: string;
}
| { error: string };
if ("error" in res) {
form.setError("root", { message: res.error });
return;
}
// QR encodes the HTTPS join URL now — anyone with a camera can
// scan and land on the friendly /join/[token] page.
const qrDataUrl = await QRCode.toDataURL(res.joinUrl, {
width: 256,
margin: 1,
color: { dark: "#141413", light: "#ffffff" },
});
setResult({
id: res.id,
token: res.token,
inviteLink: res.inviteLink,
joinUrl: res.joinUrl,
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) {
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">
{result.joinUrl}
</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(result.joinUrl, "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">
<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>
)}
/>
{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>
);
};