feat(web): topic chat UI over /api/v1/* (v0.2.0)
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

New dashboard route at /dashboard/meshes/[id]/topics/[name] gives signed-in
users a thin chat client over the v0.2.0 REST surface. The mesh detail page
now lists topics with one-click links into the chat. Backend layout:

- packages/api/src/modules/mesh/api-key-auth.ts — exports
  createDashboardApiKey() that mints a 24h read+send key scoped to a single
  topic for the caller's member id. The page server component calls this on
  every render and embeds the secret in the props of the client component;
  the secret never touches sessionStorage so a tab close = key effectively
  abandoned (the row remains until expiresAt).
- apps/web/.../topics/[name]/page.tsx — server component, NextAuth gate,
  resolves the user's meshMember.id, mints the key, renders the shell.
- apps/web/src/modules/mesh/topic-chat-panel.tsx — client component, polls
  GET /v1/topics/:name/messages every 5s, sends via POST /v1/messages.
  Encoding wraps base64(plaintext) into the ciphertext field — matches the
  current broker contract until per-topic HKDF lands in v0.3.0.

The mesh detail page gains a Topics section with empty-state copy that
points users at the CLI verb (claudemesh topic create) for now; topic
creation from the web UI is a follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-05-02 16:19:38 +01:00
parent 7d35c779f4
commit b60daff886
6 changed files with 468 additions and 2 deletions

View File

@@ -3,8 +3,11 @@ import { notFound } from "next/navigation";
import { getMyMeshResponseSchema } from "@turbostarter/api/schema";
import { handle } from "@turbostarter/api/utils";
import { db } from "@turbostarter/db/server";
import { meshTopic } from "@turbostarter/db/schema/mesh";
import { Badge } from "@turbostarter/ui-web/badge";
import { buttonVariants } from "@turbostarter/ui-web/button";
import { and, asc, eq, isNull } from "drizzle-orm";
import { pathsConfig } from "~/config/paths";
import { api } from "~/lib/api/server";
@@ -37,6 +40,17 @@ export default async function MeshPage({
(i) => !i.revokedAt && new Date(i.expiresAt) > new Date(),
);
const topics = await db
.select({
id: meshTopic.id,
name: meshTopic.name,
description: meshTopic.description,
visibility: meshTopic.visibility,
})
.from(meshTopic)
.where(and(eq(meshTopic.meshId, id), isNull(meshTopic.archivedAt)))
.orderBy(asc(meshTopic.name));
return (
<>
<DashboardHeader>
@@ -129,6 +143,49 @@ export default async function MeshPage({
)}
</section>
<section className="rounded-lg border">
<header className="flex items-center justify-between border-b px-4 py-3">
<h2 className="font-medium">
Topics{" "}
<span className="text-muted-foreground">({topics.length})</span>
</h2>
</header>
{topics.length === 0 ? (
<p className="text-muted-foreground px-4 py-8 text-center text-sm">
No topics yet. Run{" "}
<code className="bg-muted rounded px-1.5 py-0.5 text-xs">
claudemesh topic create &lt;name&gt;
</code>{" "}
from the CLI.
</p>
) : (
<div className="divide-y">
{topics.map((t) => (
<Link
key={t.id}
href={pathsConfig.dashboard.user.meshes.topic(mesh.id, t.name)}
className="hover:bg-muted/50 flex flex-col gap-1.5 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:gap-3"
>
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
<span className="font-medium">
<span className="text-muted-foreground">#</span>
{t.name}
</span>
<Badge variant="outline" className="text-xs">
{t.visibility}
</Badge>
</div>
{t.description ? (
<span className="text-muted-foreground truncate text-xs">
{t.description}
</span>
) : null}
</Link>
))}
</div>
)}
</section>
<section className="rounded-lg border">
<header className="flex items-center justify-between border-b px-4 py-3">
<h2 className="font-medium">

View File

@@ -0,0 +1,136 @@
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import { createDashboardApiKey } from "@turbostarter/api/modules/mesh/api-key-auth";
import { getMyMeshResponseSchema } from "@turbostarter/api/schema";
import { handle } from "@turbostarter/api/utils";
import { db } from "@turbostarter/db/server";
import { mesh, meshMember, meshTopic } from "@turbostarter/db/schema/mesh";
import { Badge } from "@turbostarter/ui-web/badge";
import { buttonVariants } from "@turbostarter/ui-web/button";
import { and, asc, eq, isNull } from "drizzle-orm";
import { pathsConfig } from "~/config/paths";
import { api } from "~/lib/api/server";
import { getSession } from "~/lib/auth/server";
import { getMetadata } from "~/lib/metadata";
import {
DashboardHeader,
DashboardHeaderDescription,
DashboardHeaderTitle,
} from "~/modules/common/layout/dashboard/header";
import { TopicChatPanel } from "~/modules/mesh/topic-chat-panel";
export const generateMetadata = getMetadata({
title: "Topic",
description: "Chat in a topic.",
});
export default async function TopicChatPage({
params,
}: {
params: Promise<{ id: string; name: string }>;
}) {
const { id, name: rawName } = await params;
const name = decodeURIComponent(rawName);
const session = await getSession();
if (!session?.user?.id) redirect(pathsConfig.auth.login);
const data = await handle(api.my.meshes[":id"].$get, {
schema: getMyMeshResponseSchema,
})({ param: { id } }).catch(() => null);
if (!data?.mesh) notFound();
// Resolve the caller's member id — owner gets the oldest member row in the
// mesh as their identity, otherwise pick the explicit membership.
let memberId: string | null = null;
if (data.mesh.isOwner) {
const [m] = await db
.select({ id: meshMember.id })
.from(meshMember)
.where(and(eq(meshMember.meshId, id), isNull(meshMember.revokedAt)))
.orderBy(asc(meshMember.joinedAt))
.limit(1);
memberId = m?.id ?? null;
} else {
const [m] = await db
.select({ id: meshMember.id })
.from(meshMember)
.where(
and(
eq(meshMember.meshId, id),
eq(meshMember.userId, session.user.id),
isNull(meshMember.revokedAt),
),
)
.limit(1);
memberId = m?.id ?? null;
}
if (!memberId) notFound();
const [topic] = await db
.select({
id: meshTopic.id,
name: meshTopic.name,
description: meshTopic.description,
visibility: meshTopic.visibility,
})
.from(meshTopic)
.where(
and(
eq(meshTopic.meshId, id),
eq(meshTopic.name, name),
isNull(meshTopic.archivedAt),
),
)
.limit(1);
if (!topic) notFound();
// Mint a fresh dashboard apikey for this user, scoped to read+send on
// this single topic. Lives 24h and is shown ONCE in the page HTML.
const key = await createDashboardApiKey({
meshId: id,
memberId,
label: `dashboard:${session.user.id.slice(0, 8)}:${name}`,
capabilities: ["read", "send"],
topicScopes: [name],
});
return (
<>
<DashboardHeader>
<div className="flex w-full items-start justify-between gap-4">
<div>
<DashboardHeaderTitle>
<span className="flex items-center gap-3">
<span className="text-muted-foreground">#</span>
{topic.name}
<Badge variant="outline" className="font-mono text-xs">
{topic.visibility}
</Badge>
</span>
</DashboardHeaderTitle>
<DashboardHeaderDescription>
{topic.description ?? `Topic in ${data.mesh.name}.`}
</DashboardHeaderDescription>
</div>
<Link
href={pathsConfig.dashboard.user.meshes.mesh(id)}
className={buttonVariants({ variant: "outline" })}
>
Mesh
</Link>
</div>
</DashboardHeader>
<TopicChatPanel
topicName={topic.name}
topicId={topic.id}
meshSlug={data.mesh.slug}
apiKeySecret={key.secret}
apiKeyExpiresAt={key.expiresAt.toISOString()}
/>
</>
);
}