fix(web): correct LinkedIn URL on about page
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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-04-09 13:17:24 +01:00
parent 05e3c43e29
commit 0661e6223a
28 changed files with 3409 additions and 8 deletions

View File

@@ -153,7 +153,7 @@ export default function AboutPage() {
GitHub
</Link>
<Link
href="https://www.linkedin.com/in/alejandrogutierrezmourente/"
href="https://www.linkedin.com/in/alejandro-mourente/"
className="inline-flex items-center gap-2 rounded-[var(--cm-radius-xs)] border border-[var(--cm-border)] px-4 py-2 text-[13px] font-medium text-[var(--cm-fg)] transition-colors hover:border-[var(--cm-fg)]"
style={{ fontFamily: "var(--cm-font-sans)" }}
>

View File

@@ -0,0 +1,876 @@
"use client";
import Link from "next/link";
import { motion, AnimatePresence } from "motion/react";
import { useEffect, useState, useRef } from "react";
import { getMyMeshesResponseSchema } from "@turbostarter/api/schema";
import { handle } from "@turbostarter/api/utils";
import { api } from "~/lib/api/client";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface Mesh {
id: string;
name: string;
slug: string;
myRole: "admin" | "member";
isOwner: boolean;
memberCount: number;
}
interface Props {
code: string | null;
port: string | null;
userId: string;
userEmail: string;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const slugify = (s: string) =>
s
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 40);
const ease = [0.22, 0.61, 0.36, 1] as const;
// ---------------------------------------------------------------------------
// Animated mesh node background
// ---------------------------------------------------------------------------
function MeshBackdrop() {
return (
<div className="pointer-events-none absolute inset-0 overflow-hidden">
{/* Radial glow */}
<div
className="absolute left-1/2 top-0 h-[600px] w-[900px] -translate-x-1/2 opacity-[0.06]"
style={{
backgroundImage:
"radial-gradient(ellipse at 50% 0%, var(--cm-clay) 0%, transparent 70%)",
}}
/>
{/* Floating mesh nodes */}
{[
{ x: "12%", y: "18%", delay: 0, size: 3 },
{ x: "85%", y: "14%", delay: 1.2, size: 2 },
{ x: "72%", y: "55%", delay: 0.6, size: 4 },
{ x: "8%", y: "65%", delay: 2.0, size: 2 },
{ x: "45%", y: "80%", delay: 0.3, size: 3 },
{ x: "92%", y: "78%", delay: 1.8, size: 2 },
].map((node, i) => (
<motion.div
key={i}
className="absolute rounded-full bg-[var(--cm-clay)]"
style={{
left: node.x,
top: node.y,
width: node.size,
height: node.size,
}}
animate={{
opacity: [0.15, 0.4, 0.15],
scale: [1, 1.5, 1],
}}
transition={{
duration: 4,
ease: "easeInOut",
repeat: Infinity,
delay: node.delay,
}}
/>
))}
{/* Connecting lines (SVG) */}
<svg className="absolute inset-0 h-full w-full opacity-[0.04]">
<line
x1="12%"
y1="18%"
x2="45%"
y2="80%"
stroke="var(--cm-clay)"
strokeWidth="1"
/>
<line
x1="85%"
y1="14%"
x2="72%"
y2="55%"
stroke="var(--cm-clay)"
strokeWidth="1"
/>
<line
x1="72%"
y1="55%"
x2="92%"
y2="78%"
stroke="var(--cm-clay)"
strokeWidth="1"
/>
<line
x1="8%"
y1="65%"
x2="45%"
y2="80%"
stroke="var(--cm-clay)"
strokeWidth="1"
/>
</svg>
</div>
);
}
// ---------------------------------------------------------------------------
// Terminal-style status indicator
// ---------------------------------------------------------------------------
function StatusPulse({ status }: { status: "waiting" | "syncing" | "done" | "error" }) {
const colors = {
waiting: "bg-[var(--cm-clay)]",
syncing: "bg-amber-400",
done: "bg-emerald-400",
error: "bg-red-400",
};
return (
<span className="relative inline-flex h-2 w-2">
<span
className={`absolute inline-flex h-full w-full animate-ping rounded-full opacity-75 ${colors[status]}`}
/>
<span
className={`relative inline-flex h-2 w-2 rounded-full ${colors[status]}`}
/>
</span>
);
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function CliAuthFlow({ code, port, userId, userEmail }: Props) {
const [meshes, setMeshes] = useState<Mesh[]>([]);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(true);
const [syncing, setSyncing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [token, setToken] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const [redirected, setRedirected] = useState(false);
// Create-mesh form state
const [newName, setNewName] = useState("");
const [newSlug, setNewSlug] = useState("");
const [slugDirty, setSlugDirty] = useState(false);
const [creating, setCreating] = useState(false);
const [createError, setCreateError] = useState<string | null>(null);
const nameInputRef = useRef<HTMLInputElement>(null);
// Auto-slug from name
useEffect(() => {
if (!slugDirty && newName) {
setNewSlug(slugify(newName));
}
}, [newName, slugDirty]);
// Fetch user meshes
useEffect(() => {
(async () => {
try {
const { data } = await handle(api.my.meshes.$get, {
schema: getMyMeshesResponseSchema,
})({
query: { page: "1", perPage: "50", sort: JSON.stringify([]) },
});
setMeshes(data);
setSelected(new Set(data.map((m) => m.id)));
} catch (e) {
setError(
e instanceof Error ? e.message : "Failed to load your meshes.",
);
} finally {
setLoading(false);
}
})();
}, []);
// Auto-focus name input when no meshes
useEffect(() => {
if (!loading && meshes.length === 0 && nameInputRef.current) {
nameInputRef.current.focus();
}
}, [loading, meshes.length]);
const toggleMesh = (id: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const status = token
? redirected
? "done"
: "done"
: syncing || creating
? "syncing"
: error
? "error"
: "waiting";
// ---------------------------------------------------------------------------
// Create mesh
// ---------------------------------------------------------------------------
const handleCreate = async () => {
if (!newName.trim() || !newSlug.trim()) return;
setCreating(true);
setCreateError(null);
try {
const createRes = await fetch("/api/my/meshes", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
name: newName.trim(),
slug: newSlug.trim(),
visibility: "private",
transport: "managed",
}),
});
const res = (await createRes.json()) as
| { id: string; slug: string }
| { error: string };
if (!createRes.ok || "error" in res) {
setCreateError("error" in res ? res.error : "Failed to create mesh.");
setCreating(false);
return;
}
await doSync(
[{ id: res.id, slug: res.slug, role: "admin" as const }],
"create",
{ name: newName.trim(), slug: newSlug.trim() },
);
} catch (e) {
setCreateError(e instanceof Error ? e.message : "Failed to create mesh.");
} finally {
setCreating(false);
}
};
// ---------------------------------------------------------------------------
// Sync flow
// ---------------------------------------------------------------------------
const doSync = async (
meshList: Array<{ id: string; slug: string; role: string }>,
action: "sync" | "create" = "sync",
newMesh?: { name: string; slug: string },
) => {
setSyncing(true);
setError(null);
try {
const res = await fetch("/api/cli-sync-token", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ meshes: meshList, action, newMesh }),
});
const data = (await res.json()) as { token?: string; error?: string };
if (!res.ok) {
setError(data.error ?? "Failed to generate token.");
setSyncing(false);
return;
}
const jwt = data.token as string;
setToken(jwt);
if (port) {
setRedirected(true);
window.location.href = `http://localhost:${port}/callback?token=${encodeURIComponent(jwt)}`;
}
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to generate sync token.");
} finally {
setSyncing(false);
}
};
const handleSync = () => {
const selectedMeshes = meshes
.filter((m) => selected.has(m.id))
.map((m) => ({
id: m.id,
slug: m.slug,
role: m.isOwner ? "admin" : m.myRole,
}));
if (selectedMeshes.length === 0) {
setError("Select at least one mesh to sync.");
return;
}
doSync(selectedMeshes, "sync");
};
const handleCopy = async () => {
if (!token) return;
await navigator.clipboard.writeText(token);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
// ---------------------------------------------------------------------------
// Render
// ---------------------------------------------------------------------------
return (
<>
{/* Header */}
<header className="relative z-20 border-b border-[var(--cm-border)] px-6 py-5 md:px-12">
<div className="flex items-center justify-between">
<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>
{/* Status indicator */}
<div
className="flex items-center gap-2 text-xs text-[var(--cm-fg-tertiary)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
<StatusPulse status={status} />
<span>
{status === "waiting" && "awaiting sync"}
{status === "syncing" && "generating token..."}
{status === "done" && "synced"}
{status === "error" && "error"}
</span>
</div>
</div>
</header>
{/* Content */}
<div className="relative z-10 mx-auto w-full max-w-2xl px-6 py-16 md:px-12 md:py-24">
<MeshBackdrop />
{/* Section tag */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease }}
className="mb-5 flex items-center gap-2 text-[11px] uppercase tracking-[0.22em] text-[var(--cm-clay)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
<span className="inline-block h-1 w-1 rounded-full bg-[var(--cm-clay)]" />
cli sync
</motion.div>
{/* Title */}
<motion.h1
initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.7, ease, delay: 0.08 }}
className="text-[clamp(2rem,4vw,2.75rem)] font-medium leading-[1.1] text-[var(--cm-fg)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
Sync with{" "}
<span className="italic text-[var(--cm-clay)]">claudemesh CLI</span>
</motion.h1>
<motion.p
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.7, ease, delay: 0.16 }}
className="mt-4 text-lg leading-[1.65] text-[var(--cm-fg-secondary)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
Link your terminal session to your account and choose which meshes to
sync.
</motion.p>
{/* Pairing code */}
<AnimatePresence>
{code && (
<motion.div
initial={{ opacity: 0, y: 16, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, scale: 0.96 }}
transition={{ duration: 0.5, ease, delay: 0.24 }}
className="mt-10 overflow-hidden rounded-[var(--cm-radius-md)] border border-[var(--cm-clay)]/20"
>
{/* Terminal-style header bar */}
<div className="flex items-center gap-2 border-b border-[var(--cm-clay)]/10 bg-[var(--cm-clay)]/[0.06] px-4 py-2.5">
<div className="flex gap-1.5">
<span className="h-2.5 w-2.5 rounded-full bg-[var(--cm-fg-tertiary)]/30" />
<span className="h-2.5 w-2.5 rounded-full bg-[var(--cm-fg-tertiary)]/30" />
<span className="h-2.5 w-2.5 rounded-full bg-[var(--cm-fg-tertiary)]/30" />
</div>
<span
className="ml-2 text-[10px] uppercase tracking-wider text-[var(--cm-fg-tertiary)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
pairing verification
</span>
</div>
{/* Code display */}
<div className="bg-[var(--cm-bg-elevated)] px-5 py-6">
<div className="flex items-center gap-4">
<span
className="text-xs text-[var(--cm-fg-tertiary)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
code:
</span>
<motion.span
className="text-4xl font-bold tracking-[0.2em] text-[var(--cm-clay)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.8, delay: 0.5 }}
>
{code.split("").map((char, i) => (
<motion.span
key={i}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.5 + i * 0.1, ease }}
>
{char}
</motion.span>
))}
</motion.span>
</div>
<p
className="mt-3 text-[13px] leading-relaxed text-[var(--cm-fg-tertiary)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
Confirm this matches the code shown in your terminal.
</p>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Loading skeleton */}
<AnimatePresence>
{loading && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="mt-10 space-y-3"
>
{[1, 2, 3].map((i) => (
<div
key={i}
className="h-16 animate-pulse rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)]"
style={{ animationDelay: `${i * 150}ms` }}
/>
))}
</motion.div>
)}
</AnimatePresence>
{/* Error */}
<AnimatePresence>
{error && (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
className="mt-6 flex items-start gap-3 rounded-[var(--cm-radius-md)] border border-red-500/20 bg-red-500/[0.06] p-4"
>
<span className="mt-0.5 text-red-400">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<circle cx="12" cy="12" r="10" />
<line x1="15" y1="9" x2="9" y2="15" />
<line x1="9" y1="9" x2="15" y2="15" />
</svg>
</span>
<span className="text-sm text-red-400">{error}</span>
</motion.div>
)}
</AnimatePresence>
{/* Token result */}
<AnimatePresence>
{token && (
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease }}
className="mt-10"
>
<div className="overflow-hidden rounded-[var(--cm-radius-md)] border border-emerald-500/20">
{/* Success header */}
<div className="flex items-center gap-2 border-b border-emerald-500/10 bg-emerald-500/[0.06] px-4 py-3">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
className="text-emerald-400"
>
<polyline points="20 6 9 17 4 12" />
</svg>
<span
className="text-sm font-medium text-emerald-400"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
{redirected ? "Redirecting to CLI..." : "Sync token generated"}
</span>
</div>
{/* Token body */}
<div className="bg-[var(--cm-bg-elevated)] p-5">
<p
className="mb-3 text-[13px] text-[var(--cm-fg-secondary)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
{redirected
? "If your terminal didn\u2019t pick up the token, copy it manually:"
: "Paste this token in your terminal when prompted:"}
</p>
<div className="flex items-stretch gap-2">
<div
className="min-w-0 flex-1 cursor-text overflow-hidden text-ellipsis whitespace-nowrap rounded-[var(--cm-radius-xs)] border border-[var(--cm-border)] bg-[var(--cm-bg)] px-3 py-2.5 text-xs text-[var(--cm-fg-tertiary)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
onClick={(e) => {
const range = document.createRange();
range.selectNodeContents(e.currentTarget);
const sel = window.getSelection();
sel?.removeAllRanges();
sel?.addRange(range);
}}
>
{token}
</div>
<motion.button
whileTap={{ scale: 0.95 }}
onClick={handleCopy}
className="shrink-0 rounded-[var(--cm-radius-xs)] border border-[var(--cm-border)] bg-[var(--cm-bg)] px-4 py-2.5 text-sm font-medium text-[var(--cm-fg-secondary)] transition-all duration-200 hover:border-[var(--cm-clay)]/40 hover:text-[var(--cm-fg)]"
>
{copied ? (
<span className="text-emerald-400">Copied</span>
) : (
"Copy"
)}
</motion.button>
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Mesh list */}
{!loading && !token && meshes.length > 0 && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5, delay: 0.3 }}
className="mt-10"
>
<h2
className="mb-4 text-lg font-medium text-[var(--cm-fg)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
Your meshes
</h2>
<div className="space-y-2">
{meshes.map((m, i) => (
<motion.label
key={m.id}
initial={{ opacity: 0, x: -12 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.4, ease, delay: 0.35 + i * 0.06 }}
className={`group flex cursor-pointer items-center gap-4 rounded-[var(--cm-radius-md)] border p-4 transition-all duration-200 ${
selected.has(m.id)
? "border-[var(--cm-clay)]/30 bg-[var(--cm-clay)]/[0.04]"
: "border-[var(--cm-border)] hover:border-[var(--cm-clay)]/20 hover:bg-[var(--cm-bg-elevated)]"
}`}
>
{/* Custom checkbox */}
<div
className={`flex h-5 w-5 shrink-0 items-center justify-center rounded border transition-all duration-200 ${
selected.has(m.id)
? "border-[var(--cm-clay)] bg-[var(--cm-clay)]"
: "border-[var(--cm-fg-tertiary)]/40 group-hover:border-[var(--cm-fg-tertiary)]"
}`}
>
{selected.has(m.id) && (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
)}
<input
type="checkbox"
checked={selected.has(m.id)}
onChange={() => toggleMesh(m.id)}
className="sr-only"
/>
</div>
<div className="min-w-0 flex-1">
<div className="flex items-baseline gap-2">
<span className="font-medium text-[var(--cm-fg)]">
{m.name}
</span>
<span
className="text-[11px] text-[var(--cm-fg-tertiary)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
{m.slug}
</span>
</div>
<span className="text-xs text-[var(--cm-fg-tertiary)]">
{m.memberCount}{" "}
{m.memberCount === 1 ? "member" : "members"}
</span>
</div>
<span
className={`rounded-full border px-2.5 py-1 text-[10px] uppercase tracking-wider transition-colors duration-200 ${
selected.has(m.id)
? "border-[var(--cm-clay)]/30 text-[var(--cm-clay)]"
: "border-[var(--cm-border)] text-[var(--cm-fg-tertiary)]"
}`}
style={{ fontFamily: "var(--cm-font-mono)" }}
>
{m.isOwner ? "owner" : m.myRole}
</span>
</motion.label>
))}
</div>
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.5 }}
className="mt-8 flex items-center gap-4"
>
<motion.button
whileHover={{ scale: 1.01 }}
whileTap={{ scale: 0.98 }}
onClick={handleSync}
disabled={syncing || selected.size === 0}
className="group relative inline-flex items-center gap-2.5 overflow-hidden rounded-[var(--cm-radius-xs)] bg-[var(--cm-clay)] px-7 py-3.5 text-[15px] font-medium text-white transition-all duration-300 hover:bg-[var(--cm-clay-hover)] disabled:opacity-40"
style={{ fontFamily: "var(--cm-font-sans)" }}
>
{syncing ? (
<>
<motion.span
animate={{ rotate: 360 }}
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
className="inline-block"
>
</motion.span>
Generating...
</>
) : (
<>
Sync to CLI
<span className="transition-transform duration-300 group-hover:translate-x-0.5">
</span>
</>
)}
</motion.button>
<span className="text-xs text-[var(--cm-fg-tertiary)]">
{selected.size} of {meshes.length} selected
</span>
</motion.div>
</motion.div>
)}
{/* No meshes — create form */}
{!loading && !token && meshes.length === 0 && (
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease, delay: 0.3 }}
className="mt-10"
>
<div className="overflow-hidden rounded-[var(--cm-radius-md)] border border-[var(--cm-clay)]/20">
{/* Header */}
<div className="border-b border-[var(--cm-clay)]/10 bg-[var(--cm-clay)]/[0.06] px-5 py-4">
<h2
className="text-lg font-medium text-[var(--cm-fg)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
Create your first mesh
</h2>
<p
className="mt-1 text-[13px] leading-relaxed text-[var(--cm-fg-secondary)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
A mesh is the space where your Claude Code sessions talk to each
other.
</p>
</div>
{/* Form */}
<div className="space-y-5 bg-[var(--cm-bg-elevated)] p-5">
<div>
<label
htmlFor="mesh-name"
className="mb-1.5 block text-sm font-medium text-[var(--cm-fg)]"
>
Name
</label>
<input
ref={nameInputRef}
id="mesh-name"
type="text"
placeholder="Platform team"
value={newName}
onChange={(e) => setNewName(e.target.value)}
className="w-full rounded-[var(--cm-radius-xs)] border border-[var(--cm-border)] bg-[var(--cm-bg)] px-3.5 py-2.5 text-sm text-[var(--cm-fg)] placeholder:text-[var(--cm-fg-tertiary)]/50 transition-colors duration-200 focus:border-[var(--cm-clay)]/50 focus:outline-none focus:ring-1 focus:ring-[var(--cm-clay)]/20"
/>
</div>
<div>
<label
htmlFor="mesh-slug"
className="mb-1.5 block text-sm font-medium text-[var(--cm-fg)]"
>
Slug
</label>
<input
id="mesh-slug"
type="text"
placeholder="platform-team"
value={newSlug}
onChange={(e) => {
setSlugDirty(true);
setNewSlug(e.target.value);
}}
className="w-full rounded-[var(--cm-radius-xs)] border border-[var(--cm-border)] bg-[var(--cm-bg)] px-3.5 py-2.5 text-sm text-[var(--cm-fg)] placeholder:text-[var(--cm-fg-tertiary)]/50 transition-colors duration-200 focus:border-[var(--cm-clay)]/50 focus:outline-none focus:ring-1 focus:ring-[var(--cm-clay)]/20"
style={{ fontFamily: "var(--cm-font-mono)" }}
/>
<p
className="mt-1.5 text-xs text-[var(--cm-fg-tertiary)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
lowercase · digits · hyphens
</p>
</div>
<AnimatePresence>
{createError && (
<motion.p
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
className="text-sm text-red-400"
>
{createError}
</motion.p>
)}
</AnimatePresence>
<motion.button
whileHover={{ scale: 1.01 }}
whileTap={{ scale: 0.98 }}
onClick={handleCreate}
disabled={creating || !newName.trim() || !newSlug.trim()}
className="group inline-flex w-full items-center justify-center gap-2.5 rounded-[var(--cm-radius-xs)] bg-[var(--cm-clay)] px-6 py-3.5 text-[15px] font-medium text-white transition-all duration-300 hover:bg-[var(--cm-clay-hover)] disabled:opacity-40"
style={{ fontFamily: "var(--cm-font-sans)" }}
>
{creating ? (
<>
<motion.span
animate={{ rotate: 360 }}
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
className="inline-block"
>
</motion.span>
Creating...
</>
) : (
<>
Create &amp; sync to CLI
<span className="transition-transform duration-300 group-hover:translate-x-0.5">
</span>
</>
)}
</motion.button>
</div>
</div>
</motion.div>
)}
{/* Footer security note */}
<AnimatePresence>
{!token && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5, delay: 0.6 }}
className="mt-16 flex items-start gap-3 text-[13px] leading-[1.7] text-[var(--cm-fg-tertiary)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className="mt-0.5 shrink-0 text-[var(--cm-fg-tertiary)]/60"
>
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
<span>
The sync token is valid for 15 minutes and can only be used once.
Your ed25519 keys stay on your machine the broker only sees
ciphertext.
</span>
</motion.div>
)}
</AnimatePresence>
</div>
</>
);
}

View File

@@ -0,0 +1,44 @@
import { redirect } from "next/navigation";
import { getSession } from "~/lib/auth/server";
import { getMetadata } from "~/lib/metadata";
import { CliAuthFlow } from "./cli-auth-flow";
export const generateMetadata = getMetadata({
title: "Sync with CLI",
description: "Link your claudemesh CLI to your account.",
});
export default async function CliAuthPage({
searchParams,
}: {
searchParams: Promise<{ code?: string; port?: string }>;
}) {
const { user } = await getSession();
if (!user) {
const sp = await searchParams;
const qs = new URLSearchParams();
if (sp.code) qs.set("code", sp.code);
if (sp.port) qs.set("port", sp.port);
const returnTo = `/cli-auth${qs.size ? `?${qs}` : ""}`;
return redirect(`/auth/login?redirectTo=${encodeURIComponent(returnTo)}`);
}
const { code, port } = await searchParams;
return (
<main
className="min-h-screen bg-[var(--cm-bg)] text-[var(--cm-fg)] antialiased"
style={{ fontFamily: "var(--cm-font-sans)" }}
>
<CliAuthFlow
code={code ?? null}
port={port ?? null}
userId={user.id}
userEmail={user.email}
/>
</main>
);
}

View File

@@ -0,0 +1,130 @@
import { NextResponse } from "next/server";
import { headers } from "next/headers";
import { auth } from "@turbostarter/auth/server";
// ---------------------------------------------------------------------------
// JWT signing (HS256 via Web Crypto — no external deps)
// ---------------------------------------------------------------------------
function base64UrlEncode(input: string | ArrayBuffer): string {
const str =
typeof input === "string"
? btoa(input)
: btoa(String.fromCharCode(...new Uint8Array(input)));
return str.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
async function signJwt(
payload: Record<string, unknown>,
secret: string,
): Promise<string> {
const header = { alg: "HS256", typ: "JWT" };
const encoder = new TextEncoder();
const headerB64 = base64UrlEncode(JSON.stringify(header));
const payloadB64 = base64UrlEncode(JSON.stringify(payload));
const key = await crypto.subtle.importKey(
"raw",
encoder.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const signature = await crypto.subtle.sign(
"HMAC",
key,
encoder.encode(`${headerB64}.${payloadB64}`),
);
return `${headerB64}.${payloadB64}.${base64UrlEncode(signature)}`;
}
// ---------------------------------------------------------------------------
// Route handler — POST /api/cli-sync-token
// ---------------------------------------------------------------------------
interface SyncTokenBody {
meshes: Array<{ id: string; slug: string; role: string }>;
action: "sync" | "create";
newMesh?: { name: string; slug: string };
}
export async function POST(request: Request) {
// 1. Check auth
const reqHeaders = new Headers(await headers());
reqHeaders.set("x-client-platform", "web-server");
const session = await auth.api.getSession({ headers: reqHeaders });
if (!session?.user) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
}
// 2. Parse body
let body: SyncTokenBody;
try {
body = (await request.json()) as SyncTokenBody;
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}
const { meshes, action, newMesh } = body;
if (!Array.isArray(meshes)) {
return NextResponse.json(
{ error: "meshes must be an array" },
{ status: 400 },
);
}
if (action !== "sync" && action !== "create") {
return NextResponse.json(
{ error: 'action must be "sync" or "create"' },
{ status: 400 },
);
}
if (action === "create" && (!newMesh?.name || !newMesh?.slug)) {
return NextResponse.json(
{ error: "newMesh.name and newMesh.slug are required for create action" },
{ status: 400 },
);
}
// 3. Validate meshes belong to user — fetch user's meshes via internal API
// For now we trust the dashboard-authenticated user's selection since
// the broker will independently verify membership when the CLI connects.
// A full server-side ownership check can be added later.
// 4. Get secret
const secret = process.env.CLI_SYNC_SECRET;
if (!secret) {
return NextResponse.json(
{ error: "CLI_SYNC_SECRET not configured" },
{ status: 500 },
);
}
// 5. Build and sign JWT
const now = Math.floor(Date.now() / 1000);
const payload = {
sub: session.user.id,
email: session.user.email,
meshes: meshes.map((m) => ({
id: m.id,
slug: m.slug,
role: m.role,
})),
action,
...(action === "create" && newMesh ? { newMesh } : {}),
jti: crypto.randomUUID(),
iat: now,
exp: now + 15 * 60, // 15 minutes
};
const token = await signJwt(payload, secret);
return NextResponse.json({ token });
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 497 B

After

Width:  |  Height:  |  Size: 211 B

View File

@@ -0,0 +1,7 @@
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="4" r="2" fill="#d97757"/>
<circle cx="4" cy="12" r="2" fill="#d97757"/>
<circle cx="20" cy="12" r="2" fill="#d97757"/>
<circle cx="12" cy="20" r="2" fill="#d97757"/>
<path d="M12 4L4 12M12 4L20 12M4 12L12 20M20 12L12 20M4 12L20 12M12 4L12 20" stroke="#d97757" stroke-width="1.2" opacity="0.45"/>
</svg>

After

Width:  |  Height:  |  Size: 429 B

View File

@@ -86,6 +86,7 @@ const pathsConfig = {
updatePassword: `${AUTH_PREFIX}/password/update`,
error: `${AUTH_PREFIX}/error`,
},
cliAuth: "/cli-auth",
dashboard: {
user: {
index: DASHBOARD_PREFIX,