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>
This commit is contained in:
183
apps/web/src/modules/join/install-toggle.tsx
Normal file
183
apps/web/src/modules/join/install-toggle.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
|
||||
interface Props {
|
||||
token: string;
|
||||
}
|
||||
|
||||
const JOIN_CMD = (token: string) => `claudemesh join ${token}`;
|
||||
const INSTALL_CMD = "npx claudemesh@latest init";
|
||||
|
||||
export const InstallToggle = ({ token }: Props) => {
|
||||
const [hasCli, setHasCli] = useState<"unknown" | "yes" | "no">("unknown");
|
||||
const [copiedKey, setCopiedKey] = useState<string | null>(null);
|
||||
|
||||
const copy = async (text: string, key: string) => {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopiedKey(key);
|
||||
setTimeout(() => setCopiedKey(null), 2000);
|
||||
};
|
||||
|
||||
if (hasCli === "unknown") {
|
||||
return (
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
<button
|
||||
onClick={() => setHasCli("no")}
|
||||
className="flex-1 rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] p-5 text-left transition-colors hover:border-[var(--cm-clay)] hover:bg-[var(--cm-bg-hover)]"
|
||||
>
|
||||
<div
|
||||
className="mb-1.5 text-[11px] uppercase tracking-[0.18em] text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
first time
|
||||
</div>
|
||||
<div
|
||||
className="text-lg font-medium text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Install claudemesh →
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setHasCli("yes")}
|
||||
className="flex-1 rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] p-5 text-left transition-colors hover:border-[var(--cm-clay)] hover:bg-[var(--cm-bg-hover)]"
|
||||
>
|
||||
<div
|
||||
className="mb-1.5 text-[11px] uppercase tracking-[0.18em] text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
already set up
|
||||
</div>
|
||||
<div
|
||||
className="text-lg font-medium text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Join with CLI →
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (hasCli === "yes") {
|
||||
const cmd = JOIN_CMD(token);
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-[var(--cm-radius-md)] border border-[var(--cm-clay)]/40 bg-[var(--cm-bg-elevated)] p-5">
|
||||
<div
|
||||
className="mb-2 text-[11px] uppercase tracking-[0.18em] text-[var(--cm-clay)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
run this in your terminal
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<code
|
||||
className="flex-1 overflow-x-auto rounded-[var(--cm-radius-xs)] bg-[var(--cm-bg)] p-3 text-sm text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
{cmd}
|
||||
</code>
|
||||
<button
|
||||
onClick={() => copy(cmd, "join")}
|
||||
className="rounded-[var(--cm-radius-xs)] bg-[var(--cm-clay)] px-4 py-3 text-sm font-medium text-[var(--cm-fg)] transition-colors hover:bg-[var(--cm-clay-hover)]"
|
||||
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||
>
|
||||
{copiedKey === "join" ? "Copied ✓" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setHasCli("unknown")}
|
||||
className="text-xs text-[var(--cm-fg-tertiary)] underline underline-offset-4 hover:text-[var(--cm-fg)]"
|
||||
>
|
||||
← Need to install first?
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const joinCmd = JOIN_CMD(token);
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<ol className="space-y-3">
|
||||
<li className="rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] p-5">
|
||||
<div
|
||||
className="mb-2 flex items-center gap-2 text-[11px] uppercase tracking-[0.18em] text-[var(--cm-clay)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
<span className="rounded-full bg-[var(--cm-clay)]/20 px-1.5">1</span>
|
||||
install + init
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<code
|
||||
className="flex-1 overflow-x-auto rounded-[var(--cm-radius-xs)] bg-[var(--cm-bg)] p-3 text-sm text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
{INSTALL_CMD}
|
||||
</code>
|
||||
<button
|
||||
onClick={() => copy(INSTALL_CMD, "install")}
|
||||
className="rounded-[var(--cm-radius-xs)] border border-[var(--cm-border)] px-3 py-3 text-sm text-[var(--cm-fg-secondary)] transition-colors hover:border-[var(--cm-fg)] hover:text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||
>
|
||||
{copiedKey === "install" ? "Copied ✓" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
className="mt-2 text-xs text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Generates your ed25519 keypair locally and wires claudemesh into
|
||||
your Claude Code config. You own the keys.
|
||||
</p>
|
||||
</li>
|
||||
<li className="rounded-[var(--cm-radius-md)] border border-[var(--cm-clay)]/40 bg-[var(--cm-bg-elevated)] p-5">
|
||||
<div
|
||||
className="mb-2 flex items-center gap-2 text-[11px] uppercase tracking-[0.18em] text-[var(--cm-clay)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
<span className="rounded-full bg-[var(--cm-clay)]/20 px-1.5">2</span>
|
||||
join the mesh
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<code
|
||||
className="flex-1 overflow-x-auto rounded-[var(--cm-radius-xs)] bg-[var(--cm-bg)] p-3 text-sm text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
{joinCmd}
|
||||
</code>
|
||||
<button
|
||||
onClick={() => copy(joinCmd, "join")}
|
||||
className="rounded-[var(--cm-radius-xs)] bg-[var(--cm-clay)] px-3 py-3 text-sm font-medium text-[var(--cm-fg)] transition-colors hover:bg-[var(--cm-clay-hover)]"
|
||||
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||
>
|
||||
{copiedKey === "join" ? "Copied ✓" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
<li className="rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] p-5">
|
||||
<div
|
||||
className="mb-2 flex items-center gap-2 text-[11px] uppercase tracking-[0.18em] text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
<span className="rounded-full bg-[var(--cm-border)] px-1.5">3</span>
|
||||
verify
|
||||
</div>
|
||||
<p
|
||||
className="text-sm text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Your Claude Code session will announce itself to the mesh. Other
|
||||
peers see you appear as a green dot in their dashboard.
|
||||
</p>
|
||||
</li>
|
||||
</ol>
|
||||
<button
|
||||
onClick={() => setHasCli("unknown")}
|
||||
className="text-xs text-[var(--cm-fg-tertiary)] underline underline-offset-4 hover:text-[var(--cm-fg)]"
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user