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>
219 lines
7.7 KiB
TypeScript
219 lines
7.7 KiB
TypeScript
import Link from "next/link";
|
|
|
|
import {
|
|
publicInviteResponseSchema,
|
|
type PublicInviteResponse,
|
|
} from "@turbostarter/api/schema";
|
|
import { handle } from "@turbostarter/api/utils";
|
|
|
|
import { api } from "~/lib/api/server";
|
|
import { getMetadata } from "~/lib/metadata";
|
|
import { InstallToggle } from "~/modules/join/install-toggle";
|
|
|
|
export const generateMetadata = getMetadata({
|
|
title: "Join a mesh",
|
|
description: "You've been invited to a claudemesh mesh.",
|
|
});
|
|
|
|
const ERROR_COPY: Record<
|
|
Extract<PublicInviteResponse, { valid: false }>["reason"],
|
|
{ title: string; body: (inviter: string | null) => string }
|
|
> = {
|
|
expired: {
|
|
title: "This invite expired",
|
|
body: (inviter) =>
|
|
`The invite is no longer valid. Ask ${inviter ?? "the person who sent it"} for a fresh link.`,
|
|
},
|
|
revoked: {
|
|
title: "This invite was revoked",
|
|
body: (inviter) =>
|
|
`${inviter ?? "The mesh owner"} revoked this invite. Ask for a new one if you still need access.`,
|
|
},
|
|
exhausted: {
|
|
title: "This invite has no uses left",
|
|
body: (inviter) =>
|
|
`Every allowed use has been redeemed. Ask ${inviter ?? "the person who sent it"} for a new link.`,
|
|
},
|
|
mesh_archived: {
|
|
title: "This mesh is no longer active",
|
|
body: () => "The mesh was archived. There is nothing to join.",
|
|
},
|
|
bad_signature: {
|
|
title: "This invite is invalid",
|
|
body: () =>
|
|
"The signature does not verify. The link was modified or forged — ask for a fresh one through a trusted channel.",
|
|
},
|
|
malformed: {
|
|
title: "This invite is unreadable",
|
|
body: () =>
|
|
"The token could not be decoded. Check the link you received — it may be truncated.",
|
|
},
|
|
not_found: {
|
|
title: "This invite does not exist",
|
|
body: () =>
|
|
"Nothing matches this token. It may have been deleted, or the link was mis-pasted.",
|
|
},
|
|
};
|
|
|
|
export default async function JoinPage({
|
|
params,
|
|
}: {
|
|
params: Promise<{ token: string }>;
|
|
}) {
|
|
const { token } = await params;
|
|
const invite = await handle(api.public.invite[":token"].$get, {
|
|
schema: publicInviteResponseSchema,
|
|
})({ param: { token } }).catch(
|
|
() =>
|
|
({
|
|
valid: false,
|
|
reason: "malformed",
|
|
meshName: null,
|
|
inviterName: null,
|
|
expiresAt: null,
|
|
}) as const,
|
|
);
|
|
|
|
return (
|
|
<main
|
|
className="min-h-screen bg-[var(--cm-bg)] text-[var(--cm-fg)] antialiased"
|
|
style={{ fontFamily: "var(--cm-font-sans)" }}
|
|
>
|
|
<header className="border-b border-[var(--cm-border)] px-6 py-5 md:px-12">
|
|
<Link
|
|
href="/"
|
|
aria-label="claudemesh home"
|
|
className="group flex w-fit items-center gap-2.5"
|
|
>
|
|
<svg
|
|
width="22"
|
|
height="22"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
className="text-[var(--cm-clay)] transition-transform duration-300 group-hover:rotate-180"
|
|
>
|
|
<circle cx="12" cy="4" r="2" fill="currentColor" />
|
|
<circle cx="4" cy="12" r="2" fill="currentColor" />
|
|
<circle cx="20" cy="12" r="2" fill="currentColor" />
|
|
<circle cx="12" cy="20" r="2" fill="currentColor" />
|
|
<path
|
|
d="M12 4L4 12M12 4L20 12M4 12L12 20M20 12L12 20M4 12L20 12M12 4L12 20"
|
|
stroke="currentColor"
|
|
strokeWidth="1.2"
|
|
opacity="0.45"
|
|
/>
|
|
</svg>
|
|
<span
|
|
className="text-[17px] font-medium tracking-tight"
|
|
style={{ fontFamily: "var(--cm-font-serif)" }}
|
|
>
|
|
claudemesh
|
|
</span>
|
|
</Link>
|
|
</header>
|
|
|
|
<div className="mx-auto w-full max-w-2xl px-6 py-16 md:px-12 md:py-24">
|
|
{invite.valid ? (
|
|
<>
|
|
<div
|
|
className="mb-5 text-[11px] uppercase tracking-[0.22em] text-[var(--cm-clay)]"
|
|
style={{ fontFamily: "var(--cm-font-mono)" }}
|
|
>
|
|
— invitation
|
|
</div>
|
|
<h1
|
|
className="text-[clamp(2rem,4vw,2.75rem)] font-medium leading-[1.1] text-[var(--cm-fg)]"
|
|
style={{ fontFamily: "var(--cm-font-serif)" }}
|
|
>
|
|
You're invited to{" "}
|
|
<span className="italic text-[var(--cm-clay)]">
|
|
{invite.meshName}
|
|
</span>
|
|
</h1>
|
|
<p
|
|
className="mt-4 text-lg leading-[1.6] text-[var(--cm-fg-secondary)]"
|
|
style={{ fontFamily: "var(--cm-font-serif)" }}
|
|
>
|
|
{invite.inviterName
|
|
? `${invite.inviterName} added you as a ${invite.role}.`
|
|
: `You've been added as a ${invite.role}.`}{" "}
|
|
{invite.memberCount} other{" "}
|
|
{invite.memberCount === 1 ? "peer is" : "peers are"} already on
|
|
the mesh.
|
|
</p>
|
|
|
|
<div className="mt-12">
|
|
<InstallToggle token={invite.token} />
|
|
</div>
|
|
|
|
<div
|
|
className="mt-14 rounded-[var(--cm-radius-md)] border border-dashed border-[var(--cm-border)] p-5 text-[13px] leading-[1.65] text-[var(--cm-fg-tertiary)]"
|
|
style={{ fontFamily: "var(--cm-font-serif)" }}
|
|
>
|
|
By joining, you'll be known as a peer with an ed25519
|
|
keypair generated locally. You keep your keys. claudemesh sees
|
|
ciphertext only. Leave anytime with{" "}
|
|
<code
|
|
className="rounded bg-[var(--cm-bg-elevated)] px-1.5 py-0.5 text-[12px] text-[var(--cm-fg-secondary)]"
|
|
style={{ fontFamily: "var(--cm-font-mono)" }}
|
|
>
|
|
claudemesh leave {invite.meshSlug}
|
|
</code>
|
|
.
|
|
</div>
|
|
|
|
<p
|
|
className="mt-8 text-xs text-[var(--cm-fg-tertiary)]"
|
|
style={{ fontFamily: "var(--cm-font-mono)" }}
|
|
>
|
|
expires {new Date(invite.expiresAt).toLocaleDateString()} ·{" "}
|
|
{invite.maxUses - invite.usedCount} of {invite.maxUses} uses
|
|
remaining
|
|
</p>
|
|
</>
|
|
) : (
|
|
<>
|
|
<div
|
|
className="mb-5 text-[11px] uppercase tracking-[0.22em] text-[#c46686]"
|
|
style={{ fontFamily: "var(--cm-font-mono)" }}
|
|
>
|
|
— invitation unavailable
|
|
</div>
|
|
<h1
|
|
className="text-[clamp(1.75rem,3.5vw,2.25rem)] font-medium leading-[1.15] text-[var(--cm-fg)]"
|
|
style={{ fontFamily: "var(--cm-font-serif)" }}
|
|
>
|
|
{ERROR_COPY[invite.reason].title}
|
|
</h1>
|
|
<p
|
|
className="mt-4 text-base leading-[1.6] text-[var(--cm-fg-secondary)]"
|
|
style={{ fontFamily: "var(--cm-font-serif)" }}
|
|
>
|
|
{ERROR_COPY[invite.reason].body(invite.inviterName)}
|
|
</p>
|
|
{invite.meshName && (
|
|
<p
|
|
className="mt-2 text-sm text-[var(--cm-fg-tertiary)]"
|
|
style={{ fontFamily: "var(--cm-font-mono)" }}
|
|
>
|
|
mesh: {invite.meshName}
|
|
{invite.expiresAt &&
|
|
` · expired ${new Date(invite.expiresAt).toLocaleDateString()}`}
|
|
</p>
|
|
)}
|
|
<div className="mt-10">
|
|
<Link
|
|
href="/"
|
|
className="inline-flex items-center gap-2 rounded-[var(--cm-radius-xs)] border border-[var(--cm-fg-tertiary)] px-5 py-3 text-sm font-medium text-[var(--cm-fg)] transition-colors hover:border-[var(--cm-fg)] hover:bg-[var(--cm-bg-elevated)]"
|
|
style={{ fontFamily: "var(--cm-font-sans)" }}
|
|
>
|
|
← claudemesh.com
|
|
</Link>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</main>
|
|
);
|
|
}
|