feat(web): topic chat UI over /api/v1/* (v0.2.0)
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:
@@ -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 <name>
|
||||
</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">
|
||||
|
||||
@@ -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()}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user