feat: Phase 11 — CRUD (create/update/delete) + auth + web UI
- Core: createDocument/updateDocument/deleteDocument (file + DB + revision + chunks) - Parser: stringifyFile (serialize markdown with frontmatter to disk) - API: tRPC CRUD routers (auth-gated via requireWriteAuth middleware) - API: createContext now passes expectedSecret from deps (request-scoped auth) - MCP: 3 new write tools (create_document, update_document, delete_document) - Web: /create page + ?edit=1 form, /api/auth/login (cookie-based), /api/docs REST CRUD - Web: Edit buttons on homepage + doc pages (auth-gated) - Tests: 8 new tests (5 tRPC CRUD + 3 MCP CRUD auth), 40 total all green
This commit is contained in:
@@ -2,6 +2,7 @@ import { test, expect, beforeEach, mock } from "bun:test";
|
||||
import { createApp } from "../src/app";
|
||||
import type { ApiDeps } from "../src/app";
|
||||
import { DASHBOARD_HTML } from "../src/dashboard";
|
||||
import { Hono } from "hono";
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// A fake queue that records calls and returns canned counts. Injected into
|
||||
@@ -39,6 +40,37 @@ mock.module("@mcpedia/queue", () => ({
|
||||
INDEX_QUEUE: "mcpedia-index",
|
||||
}));
|
||||
|
||||
// Mock @mcpedia/core so tRPC CRUD procedures don't hit Postgres.
|
||||
// Only the functions used by the router are faked; assertions record calls.
|
||||
const coreCalls = {
|
||||
createDocument: [] as Array<any[]>,
|
||||
updateDocument: [] as Array<any[]>,
|
||||
deleteDocument: [] as Array<any[]>,
|
||||
};
|
||||
mock.module("@mcpedia/core", () => ({
|
||||
keywordSearch: () => Promise.resolve([]),
|
||||
getDocument: () => Promise.resolve(null),
|
||||
getRelated: () => Promise.resolve([]),
|
||||
hybridSearch: () => Promise.resolve([]),
|
||||
semanticSearch: () => Promise.resolve([]),
|
||||
listDocuments: () => Promise.resolve([]),
|
||||
listRevisions: () => Promise.resolve([]),
|
||||
getRevision: () => Promise.resolve(null),
|
||||
restoreRevision: () => Promise.resolve(null),
|
||||
createDocument: (...args: any[]) => {
|
||||
coreCalls.createDocument.push(args);
|
||||
return Promise.resolve({ slug: "docs/test", title: "Test" });
|
||||
},
|
||||
updateDocument: (...args: any[]) => {
|
||||
coreCalls.updateDocument.push(args);
|
||||
return Promise.resolve({ slug: args[0], title: "Updated" });
|
||||
},
|
||||
deleteDocument: (...args: any[]) => {
|
||||
coreCalls.deleteDocument.push(args);
|
||||
return Promise.resolve({ deleted: true });
|
||||
},
|
||||
}));
|
||||
|
||||
let SECRET: string;
|
||||
beforeEach(() => {
|
||||
SECRET = "test-secret-" + Math.random().toString(36).slice(2);
|
||||
@@ -131,3 +163,66 @@ test("GET /dashboard returns the self-contained HTML", async () => {
|
||||
expect(html).toContain("Index Queue (BullMQ)");
|
||||
expect(html).toContain(DASHBOARD_HTML.slice(0, 100));
|
||||
});
|
||||
|
||||
// --- Phase 11: CRUD tRPC procedures (auth-gated) ---
|
||||
|
||||
function trpcRequest(app: Hono, procedure: string, input: unknown, secret?: string) {
|
||||
const headers: Record<string, string> = {
|
||||
"content-type": "application/json",
|
||||
};
|
||||
if (secret) headers["x-webhook-secret"] = secret;
|
||||
// tRPC fetch adapter expects the procedure input directly as the JSON body
|
||||
// (not wrapped in a JSON-RPC envelope). The procedure name comes from the URL.
|
||||
return app.request("/trpc/" + procedure, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
test("tRPC createDocument without secret -> rejects (tRPC error)", async () => {
|
||||
const app = await createApp(makeDeps(SECRET, fakeQueue({})));
|
||||
const res = await trpcRequest(app, "createDocument", {
|
||||
slug: "docs/test", title: "Test", section: "docs", body: "# Hi"
|
||||
});
|
||||
// tRPC middleware throws Error -> JSON-RPC error (httpStatus 500, not 401)
|
||||
expect(res.status).toBe(500);
|
||||
expect(coreCalls.createDocument).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("tRPC createDocument with secret -> 200 + calls core", async () => {
|
||||
const app = await createApp(makeDeps(SECRET, fakeQueue({})));
|
||||
const res = await trpcRequest(app, "createDocument", {
|
||||
slug: "docs/test", title: "Test", section: "docs", body: "# Hi"
|
||||
}, SECRET);
|
||||
expect(res.status).toBe(200);
|
||||
expect(coreCalls.createDocument).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("tRPC updateDocument with secret -> 200 + calls core", async () => {
|
||||
const app = await createApp(makeDeps(SECRET, fakeQueue({})));
|
||||
const res = await trpcRequest(app, "updateDocument", {
|
||||
slug: "docs/test", body: "# Updated"
|
||||
}, SECRET);
|
||||
expect(res.status).toBe(200);
|
||||
expect(coreCalls.updateDocument).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("tRPC deleteDocument without secret -> rejects (tRPC error)", async () => {
|
||||
const app = await createApp(makeDeps(SECRET, fakeQueue({})));
|
||||
const res = await trpcRequest(app, "deleteDocument", {
|
||||
slug: "docs/test"
|
||||
});
|
||||
// tRPC middleware throws Error -> JSON-RPC error (httpStatus 500, not 401)
|
||||
expect(res.status).toBe(500);
|
||||
expect(coreCalls.deleteDocument).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("tRPC deleteDocument with secret -> 200 + calls core", async () => {
|
||||
const app = await createApp(makeDeps(SECRET, fakeQueue({})));
|
||||
const res = await trpcRequest(app, "deleteDocument", {
|
||||
slug: "docs/test"
|
||||
}, SECRET);
|
||||
expect(res.status).toBe(200);
|
||||
expect(coreCalls.deleteDocument).toHaveLength(1);
|
||||
});
|
||||
|
||||
@@ -140,6 +140,7 @@ export async function createApp(deps?: ApiDeps): Promise<Hono> {
|
||||
createContext: (): Context => ({
|
||||
db, // real Postgres connection (read procedures use it via @mcpedia/db).
|
||||
webhookSecret: c.req.raw.headers.get("x-webhook-secret") ?? undefined,
|
||||
expectedSecret: d.webhookSecret,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
+45
-3
@@ -10,20 +10,22 @@ import {
|
||||
listRevisions,
|
||||
getRevision,
|
||||
restoreRevision,
|
||||
createDocument,
|
||||
updateDocument,
|
||||
deleteDocument,
|
||||
} from "@mcpedia/core";
|
||||
import { getQueue, INDEX_QUEUE } from "@mcpedia/queue";
|
||||
import { getConnection, BULLMQ_PREFIX } from "@mcpedia/queue/client";
|
||||
import { WEBHOOK_SECRET } from "@mcpedia/config";
|
||||
|
||||
// restoreRevision is a state-changing action (it rewrites the live document row
|
||||
// + rebuilds its chunks). It must NOT be callable anonymously over the network —
|
||||
// only the Web UI (which calls @mcpedia/core directly) and an operator with the
|
||||
// webhook secret may use it. Anything else is rejected.
|
||||
const requireWriteAuth = t.middleware(({ ctx, next }) => {
|
||||
if (!WEBHOOK_SECRET) {
|
||||
if (!ctx.expectedSecret) {
|
||||
throw new Error("WEBHOOK_SECRET is not configured; writes are disabled");
|
||||
}
|
||||
if (ctx.webhookSecret !== WEBHOOK_SECRET) {
|
||||
if (ctx.webhookSecret !== ctx.expectedSecret) {
|
||||
throw new Error("unauthorized: missing or invalid x-webhook-secret");
|
||||
}
|
||||
return next();
|
||||
@@ -68,6 +70,46 @@ export const appRouter = router({
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ input }) => restoreRevision(input.id)),
|
||||
|
||||
// --- Phase 11: CRUD (gated by x-webhook-secret / admin auth) ---
|
||||
createDocument: publicProcedure
|
||||
.use(requireWriteAuth)
|
||||
.input(
|
||||
z.object({
|
||||
slug: z.string().min(1),
|
||||
title: z.string().min(1),
|
||||
section: z.enum(["docs", "writeups", "research", "notes"]),
|
||||
body: z.string(),
|
||||
type: z.enum(["documentation", "writeup", "research", "note"]).optional(),
|
||||
status: z.enum(["published", "draft"]).optional(),
|
||||
author: z.string().optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input }) => createDocument(input)),
|
||||
|
||||
updateDocument: publicProcedure
|
||||
.use(requireWriteAuth)
|
||||
.input(
|
||||
z.object({
|
||||
slug: z.string().min(1),
|
||||
title: z.string().min(1).optional(),
|
||||
body: z.string().optional(),
|
||||
type: z.enum(["documentation", "writeup", "research", "note"]).optional(),
|
||||
status: z.enum(["published", "draft"]).optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
author: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
const { slug, ...rest } = input;
|
||||
return updateDocument(slug, rest);
|
||||
}),
|
||||
|
||||
deleteDocument: publicProcedure
|
||||
.use(requireWriteAuth)
|
||||
.input(z.object({ slug: z.string().min(1) }))
|
||||
.mutation(async ({ input }) => deleteDocument(input.slug)),
|
||||
|
||||
// --- Phase 3: async job status ---
|
||||
jobStatus: publicProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
|
||||
@@ -4,9 +4,12 @@ import { db } from "@mcpedia/db";
|
||||
export interface Context {
|
||||
db: typeof db;
|
||||
// Raw `x-webhook-secret` header from the incoming request, if present.
|
||||
// State-changing tRPC mutations (restoreRevision) require it to match
|
||||
// WEBHOOK_SECRET; read-only procedures ignore it.
|
||||
// State-changing tRPC mutations (restoreRevision, CRUD) require it to match
|
||||
// the configured secret; read-only procedures ignore it.
|
||||
webhookSecret?: string;
|
||||
// The configured expected secret (from deps). Used by requireWriteAuth
|
||||
// to validate the header — request-scoped so tests can inject a fake.
|
||||
expectedSecret: string;
|
||||
}
|
||||
|
||||
export const t = initTRPC.context<Context>().create();
|
||||
|
||||
@@ -46,6 +46,9 @@ mock.module("@mcpedia/core", () => ({
|
||||
return Promise.resolve({ slug: "docs/test", documentId: "d1" });
|
||||
},
|
||||
readContentFile: () => "",
|
||||
createDocument: (_input: unknown) => Promise.resolve({ slug: "docs/test", title: "Test" }),
|
||||
updateDocument: (_slug: string, _input: unknown) => Promise.resolve({ slug: "docs/test", title: "Test" }),
|
||||
deleteDocument: (_slug: string) => Promise.resolve({ deleted: true }),
|
||||
}));
|
||||
|
||||
// Import AFTER mocking so the modules resolve to our fakes.
|
||||
@@ -150,6 +153,55 @@ test("queue_status is public (no auth needed)", async () => {
|
||||
await server.close();
|
||||
});
|
||||
|
||||
// --- Phase 11: CRUD write tools auth gates ---
|
||||
|
||||
test("create_document without auth secret -> tool returns isError", async () => {
|
||||
const { client, server } = await connect(); // no secret
|
||||
const res = await client.callTool({
|
||||
name: "create_document",
|
||||
arguments: { slug: "docs/test", title: "Test", section: "docs", body: "# Hi" },
|
||||
});
|
||||
expect(res.isError).toBe(true);
|
||||
await client.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("create_document with auth secret -> success", async () => {
|
||||
const { client, server } = await connect("secret");
|
||||
const res = await client.callTool({
|
||||
name: "create_document",
|
||||
arguments: { slug: "docs/test", title: "Test", section: "docs", body: "# Hi" },
|
||||
});
|
||||
expect(res.isError).toBeFalsy();
|
||||
const text = (res.content as any[])[0].text;
|
||||
const parsed = JSON.parse(text);
|
||||
expect(parsed.ok).toBe(true);
|
||||
await client.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("update_document with auth secret -> success", async () => {
|
||||
const { client, server } = await connect("secret");
|
||||
const res = await client.callTool({
|
||||
name: "update_document",
|
||||
arguments: { slug: "docs/test", body: "# Updated" },
|
||||
});
|
||||
expect(res.isError).toBeFalsy();
|
||||
await client.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("delete_document without auth secret -> tool returns isError", async () => {
|
||||
const { client, server } = await connect(); // no secret
|
||||
const res = await client.callTool({
|
||||
name: "delete_document",
|
||||
arguments: { slug: "docs/test" },
|
||||
});
|
||||
expect(res.isError).toBe(true);
|
||||
await client.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("tool discovery works without auth (read tools present)", async () => {
|
||||
const { client, server } = await connect();
|
||||
const tools = await client.listTools();
|
||||
@@ -166,6 +218,10 @@ test("tool discovery works without auth (read tools present)", async () => {
|
||||
expect(names).toContain("reindex_all");
|
||||
expect(names).toContain("restore_revision");
|
||||
expect(names).toContain("queue_status");
|
||||
// Phase 11: CRUD write tools also present in discovery.
|
||||
expect(names).toContain("create_document");
|
||||
expect(names).toContain("update_document");
|
||||
expect(names).toContain("delete_document");
|
||||
await client.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
@@ -13,6 +13,9 @@ import {
|
||||
getRevision,
|
||||
restoreRevision,
|
||||
readContentFile,
|
||||
createDocument,
|
||||
updateDocument,
|
||||
deleteDocument,
|
||||
} from "@mcpedia/core";
|
||||
import { enqueueIndexDoc, enqueueFullIndex, getQueue, INDEX_QUEUE } from "@mcpedia/queue";
|
||||
import { CONTENT_ROOT } from "@mcpedia/config";
|
||||
@@ -195,6 +198,90 @@ export function createMcpServer(authSecret?: string): McpServer {
|
||||
},
|
||||
);
|
||||
|
||||
// --- Phase 11: CRUD write tools (require x-webhook-secret) ---
|
||||
server.registerTool(
|
||||
"create_document",
|
||||
{
|
||||
description:
|
||||
"Create a new document (writes markdown file + DB row + revision + chunks). Requires the x-webhook-secret header.",
|
||||
inputSchema: z.object({
|
||||
slug: z.string().describe("URL-safe slug (e.g. 'docs/my-new-doc')"),
|
||||
title: z.string().describe("Document title"),
|
||||
section: z.enum(["docs", "writeups", "research", "notes"]).describe("Content section"),
|
||||
body: z.string().describe("Markdown body"),
|
||||
type: z.enum(["documentation", "writeup", "research", "note"]).optional(),
|
||||
status: z.enum(["published", "draft"]).optional(),
|
||||
author: z.string().optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
}),
|
||||
},
|
||||
async ({ slug, title, section, body, type, status, author, tags }) => {
|
||||
requireMcpAuth(authSecret);
|
||||
const doc = await createDocument({
|
||||
slug,
|
||||
title,
|
||||
section,
|
||||
body,
|
||||
type,
|
||||
status,
|
||||
author,
|
||||
tags,
|
||||
});
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify({ ok: true, slug: doc.slug }) }],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"update_document",
|
||||
{
|
||||
description:
|
||||
"Update an existing document (title, body, tags, status, etc.). Requires the x-webhook-secret header.",
|
||||
inputSchema: z.object({
|
||||
slug: z.string().describe("Document slug to update"),
|
||||
title: z.string().optional(),
|
||||
body: z.string().optional(),
|
||||
type: z.enum(["documentation", "writeup", "research", "note"]).optional(),
|
||||
status: z.enum(["published", "draft"]).optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
author: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
async ({ slug, title, body, type, status, tags, author }) => {
|
||||
requireMcpAuth(authSecret);
|
||||
const doc = await updateDocument(slug, {
|
||||
title,
|
||||
body,
|
||||
type,
|
||||
status,
|
||||
tags,
|
||||
author,
|
||||
});
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify({ ok: true, slug: doc.slug }) }],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"delete_document",
|
||||
{
|
||||
description:
|
||||
"Delete a document (removes file + DB rows + chunks + revisions). Requires the x-webhook-secret header.",
|
||||
inputSchema: z.object({
|
||||
slug: z.string().describe("Document slug to delete"),
|
||||
}),
|
||||
},
|
||||
async ({ slug }) => {
|
||||
requireMcpAuth(authSecret);
|
||||
const result = await deleteDocument(slug);
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify({ ok: true, deleted: result.deleted }) }],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"queue_status",
|
||||
{
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getDocument, listDocuments, getRelated, listRevisions } from "@mcpedia/core";
|
||||
import { cookies } from "next/headers";
|
||||
import { getDocument, getRelated, listRevisions } from "@mcpedia/core";
|
||||
import { WEBHOOK_SECRET } from "@mcpedia/config";
|
||||
import Markdown from "@/components/Markdown";
|
||||
import DocForm from "@/components/DocForm";
|
||||
|
||||
// Render at request time. The content lives in Postgres (populated by the
|
||||
// indexer/worker), which is not available at build time (CI has no DB), so we
|
||||
@@ -9,16 +12,45 @@ import Markdown from "@/components/Markdown";
|
||||
// instant.
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function DocPage({
|
||||
params,
|
||||
}: {
|
||||
interface DocPageProps {
|
||||
params: Promise<{ section: string; slug: string[] }>;
|
||||
}) {
|
||||
searchParams: Promise<{ edit?: string }>;
|
||||
}
|
||||
|
||||
export default async function DocPage({ params, searchParams }: DocPageProps) {
|
||||
const { section, slug } = await params;
|
||||
const { edit } = await searchParams;
|
||||
const fullSlug = `${section}/${slug.join("/")}`;
|
||||
const doc = await getDocument(fullSlug);
|
||||
if (!doc) notFound();
|
||||
|
||||
// Check auth for edit mode.
|
||||
const cookieStore = await cookies();
|
||||
const canEdit = cookieStore.get("mcpedia_admin")?.value != null;
|
||||
|
||||
// If ?edit=1 and authenticated → show the edit form.
|
||||
if (edit === "1" && canEdit) {
|
||||
return (
|
||||
<div className="p-8">
|
||||
<h1 className="text-2xl font-bold mb-6">Edit: {doc.title}</h1>
|
||||
<DocForm
|
||||
mode="edit"
|
||||
slug={fullSlug}
|
||||
secret={WEBHOOK_SECRET}
|
||||
initial={{
|
||||
title: doc.title,
|
||||
body: doc.body,
|
||||
section: doc.section,
|
||||
type: doc.type,
|
||||
status: doc.status,
|
||||
tags: doc.tags,
|
||||
author: doc.author,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const related = await getRelated(fullSlug, 5);
|
||||
const revisions = await listRevisions(fullSlug, 10);
|
||||
|
||||
@@ -34,6 +66,14 @@ export default async function DocPage({
|
||||
<div className="text-xs text-zinc-500 mt-1">
|
||||
{doc.tags.map((t) => `#${t}`).join(" ")} · {doc.author || "unknown"}
|
||||
</div>
|
||||
{canEdit && (
|
||||
<Link
|
||||
href={`/${doc.slug}?edit=1`}
|
||||
className="inline-block mt-2 px-3 py-1 text-xs border border-zinc-300 dark:border-zinc-700 rounded hover:bg-zinc-100 dark:hover:bg-zinc-800"
|
||||
>
|
||||
Edit
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Markdown source={doc.body} />
|
||||
@@ -69,7 +109,7 @@ export default async function DocPage({
|
||||
#{rev.revisionNo} · {rev.reason} ·{" "}
|
||||
{new Date(rev.createdAt).toLocaleString()} · {rev.bodyLength} chars
|
||||
</span>
|
||||
<form action={`/api/revisions/restore/`} method="post">
|
||||
<form action="/api/revisions/restore" method="post">
|
||||
<input type="hidden" name="id" value={rev.id} />
|
||||
<button
|
||||
type="submit"
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { ADMIN_PASSWORD } from "@mcpedia/config";
|
||||
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||
|
||||
// POST /api/auth/login — verify admin password, set a signed cookie.
|
||||
// Uses a simple HMAC cookie (no JWT library) — sufficient for a single-admin KB.
|
||||
const COOKIE_NAME = "mcpedia_admin";
|
||||
const COOKIE_MAX_AGE = 60 * 60 * 24 * 7; // 7 days
|
||||
|
||||
function signCookie(value: string): string {
|
||||
const secret = ADMIN_PASSWORD || "fallback";
|
||||
const sig = createHmac("sha256", secret).update(value).digest("hex");
|
||||
return `${value}.${sig}`;
|
||||
}
|
||||
|
||||
function verifyCookie(cookieValue: string | undefined): boolean {
|
||||
if (!cookieValue) return false;
|
||||
const [value, sig] = cookieValue.split(".");
|
||||
if (!value || !sig) return false;
|
||||
const expected = signCookie(value);
|
||||
return timingSafeEqual(
|
||||
Buffer.from(cookieValue),
|
||||
Buffer.from(expected),
|
||||
);
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const { password } = body;
|
||||
|
||||
if (!ADMIN_PASSWORD || typeof password !== "string") {
|
||||
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const ok = timingSafeEqual(
|
||||
Buffer.from(password),
|
||||
Buffer.from(ADMIN_PASSWORD),
|
||||
);
|
||||
|
||||
if (!ok) {
|
||||
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const cookie = signCookie("admin");
|
||||
const res = NextResponse.json({ ok: true });
|
||||
res.cookies.set(COOKIE_NAME, cookie, {
|
||||
maxAge: COOKIE_MAX_AGE,
|
||||
httpOnly: true,
|
||||
path: "/",
|
||||
sameSite: "lax",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
});
|
||||
return res;
|
||||
}
|
||||
|
||||
// GET /api/auth/login — returns 200 if currently authenticated, 401 otherwise.
|
||||
export async function GET(req: NextRequest) {
|
||||
const cookie = req.cookies.get(COOKIE_NAME)?.value;
|
||||
if (verifyCookie(cookie)) return NextResponse.json({ ok: true });
|
||||
return NextResponse.json({ ok: false }, { status: 401 });
|
||||
}
|
||||
|
||||
// DELETE /api/auth/login — clear the cookie (logout).
|
||||
export async function DELETE() {
|
||||
const res = NextResponse.json({ ok: true });
|
||||
res.cookies.delete({ name: COOKIE_NAME, path: "/" });
|
||||
return res;
|
||||
}
|
||||
|
||||
// Exported for server components to call directly.
|
||||
export function isAuthenticated(req: NextRequest): boolean {
|
||||
const cookie = req.cookies.get(COOKIE_NAME)?.value;
|
||||
return verifyCookie(cookie);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
createDocument,
|
||||
updateDocument,
|
||||
deleteDocument,
|
||||
listDocuments,
|
||||
} from "@mcpedia/core";
|
||||
import { WEBHOOK_SECRET } from "@mcpedia/config";
|
||||
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||
|
||||
// POST /api/docs
|
||||
// Create a new document.
|
||||
// Body: { slug, title, section, body, type?, status?, author?, tags? }
|
||||
// Auth: x-webhook-secret header matching WEBHOOK_SECRET.
|
||||
export async function POST(req: NextRequest) {
|
||||
if (!WEBHOOK_SECRET) {
|
||||
return NextResponse.json({ ok: false, error: "WEBHOOK_SECRET not configured" }, { status: 500 });
|
||||
}
|
||||
const provided = req.headers.get("x-webhook-secret");
|
||||
if (!provided || !timingSafeEqual(Buffer.from(provided), Buffer.from(WEBHOOK_SECRET))) {
|
||||
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await req.json().catch(() => null);
|
||||
if (!body) {
|
||||
return NextResponse.json({ ok: false, error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const doc = await createDocument(body);
|
||||
return NextResponse.json({ ok: true, slug: doc.slug, doc });
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return NextResponse.json({ ok: false, error: msg }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
// PUT /api/docs/{slug}
|
||||
// Update an existing document.
|
||||
// Body: { title?, body?, type?, status?, tags?, author? }
|
||||
export async function PUT(req: NextRequest) {
|
||||
if (!WEBHOOK_SECRET) {
|
||||
return NextResponse.json({ ok: false, error: "WEBHOOK_SECRET not configured" }, { status: 500 });
|
||||
}
|
||||
const provided = req.headers.get("x-webhook-secret");
|
||||
if (!provided || !timingSafeEqual(Buffer.from(provided), Buffer.from(WEBHOOK_SECRET))) {
|
||||
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Extract slug from URL path: /api/docs/{slug}
|
||||
const url = new URL(req.url);
|
||||
const parts = url.pathname.split("/").filter(Boolean);
|
||||
const slug = parts[2]; // ["api", "docs", "...slugParts"]
|
||||
if (!slug || slug === "docs") {
|
||||
return NextResponse.json({ ok: false, error: "slug required in path" }, { status: 400 });
|
||||
}
|
||||
|
||||
const body = await req.json().catch(() => null);
|
||||
if (!body) {
|
||||
return NextResponse.json({ ok: false, error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
// Reconstruct the full slug from path segments
|
||||
const fullSlug = parts.slice(2).join("/");
|
||||
const doc = await updateDocument(fullSlug, body);
|
||||
return NextResponse.json({ ok: true, slug: doc.slug, doc });
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return NextResponse.json({ ok: false, error: msg }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/docs/{slug}
|
||||
export async function DELETE(req: NextRequest) {
|
||||
if (!WEBHOOK_SECRET) {
|
||||
return NextResponse.json({ ok: false, error: "WEBHOOK_SECRET not configured" }, { status: 500 });
|
||||
}
|
||||
const provided = req.headers.get("x-webhook-secret");
|
||||
if (!provided || !timingSafeEqual(Buffer.from(provided), Buffer.from(WEBHOOK_SECRET))) {
|
||||
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const url = new URL(req.url);
|
||||
const parts = url.pathname.split("/").filter(Boolean);
|
||||
const slug = parts.slice(2).join("/");
|
||||
if (!slug) {
|
||||
return NextResponse.json({ ok: false, error: "slug required in path" }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = await deleteDocument(slug);
|
||||
return NextResponse.json({ ok: true, deleted: result.deleted });
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
interface DocFormProps {
|
||||
mode: "create" | "edit";
|
||||
slug?: string;
|
||||
secret: string; // injected server-side (never visible to the client JS bundle)
|
||||
initial?: {
|
||||
title?: string;
|
||||
body?: string;
|
||||
section?: "docs" | "writeups" | "research" | "notes";
|
||||
type?: "documentation" | "writeup" | "research" | "note";
|
||||
status?: "published" | "draft";
|
||||
tags?: string[];
|
||||
author?: string;
|
||||
};
|
||||
}
|
||||
|
||||
const SECTION_OPTIONS = ["docs", "writeups", "research", "notes"] as const;
|
||||
const TYPE_OPTIONS = ["documentation", "writeup", "research", "note"] as const;
|
||||
|
||||
export default function DocForm({ mode, slug, secret, initial }: DocFormProps) {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [title, setTitle] = useState(initial?.title ?? "");
|
||||
const [body, setBody] = useState(initial?.body ?? "");
|
||||
const [section, setSection] = useState(initial?.section ?? "docs");
|
||||
const [type, setType] = useState(initial?.type ?? "documentation");
|
||||
const [status, setStatus] = useState(initial?.status ?? "published");
|
||||
const [tags, setTags] = useState(initial?.tags?.join(", ") ?? "");
|
||||
const [author, setAuthor] = useState(initial?.author ?? "");
|
||||
|
||||
const tagList = tags
|
||||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const payload = {
|
||||
title,
|
||||
body,
|
||||
section,
|
||||
type,
|
||||
status,
|
||||
author,
|
||||
tags: tagList,
|
||||
};
|
||||
|
||||
try {
|
||||
let res: Response;
|
||||
if (mode === "create") {
|
||||
const slugVal = slug ?? title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
||||
res = await fetch("/api/docs", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "x-webhook-secret": secret },
|
||||
body: JSON.stringify({ slug: slugVal, ...payload }),
|
||||
});
|
||||
} else {
|
||||
const editSlug = slug ?? "";
|
||||
res = await fetch(`/api/docs/${editSlug}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json", "x-webhook-secret": secret },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error ?? "Save failed");
|
||||
}
|
||||
|
||||
const result = await res.json();
|
||||
router.push(result.doc?.slug ? `/${result.doc.slug}` : "/");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6 max-w-4xl mx-auto">
|
||||
{error && (
|
||||
<div className="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded text-red-800 dark:text-red-200">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Title</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-zinc-300 dark:border-zinc-700 rounded bg-white dark:bg-zinc-900"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Slug</label>
|
||||
<input
|
||||
type="text"
|
||||
value={slug ?? ""}
|
||||
onChange={(e) => router.replace(`/docs/${section}/edit?slug=${e.target.value}`)}
|
||||
className="w-full px-3 py-2 border border-zinc-300 dark:border-zinc-700 rounded bg-zinc-50 dark:bg-zinc-800"
|
||||
placeholder={section}
|
||||
/>
|
||||
<p className="text-xs text-zinc-500 mt-1">URL-safe path under the section (e.g. "websocket/contract").</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Section</label>
|
||||
<select
|
||||
value={section}
|
||||
onChange={(e) => setSection(e.target.value as typeof section)}
|
||||
className="w-full px-3 py-2 border border-zinc-300 dark:border-zinc-700 rounded bg-white dark:bg-zinc-900"
|
||||
>
|
||||
{SECTION_OPTIONS.map((s) => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Type</label>
|
||||
<select
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value as typeof type)}
|
||||
className="w-full px-3 py-2 border border-zinc-300 dark:border-zinc-700 rounded bg-white dark:bg-zinc-900"
|
||||
>
|
||||
{TYPE_OPTIONS.map((t) => (
|
||||
<option key={t} value={t}>{t}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Status</label>
|
||||
<select
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value as typeof status)}
|
||||
className="w-full px-3 py-2 border border-zinc-300 dark:border-zinc-700 rounded bg-white dark:bg-zinc-900"
|
||||
>
|
||||
<option value="published">Published</option>
|
||||
<option value="draft">Draft</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Tags</label>
|
||||
<input
|
||||
type="text"
|
||||
value={tags}
|
||||
onChange={(e) => setTags(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-zinc-300 dark:border-zinc-700 rounded bg-white dark:bg-zinc-900"
|
||||
placeholder="comma, separated, tags"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Author</label>
|
||||
<input
|
||||
type="text"
|
||||
value={author}
|
||||
onChange={(e) => setAuthor(e.target.value)}
|
||||
className="w-fill px-3 py-2 border border-zinc-300 dark:border-zinc-700 rounded bg-white dark:bg-zinc-900"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Body (Markdown)</label>
|
||||
<textarea
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
className="w-full h-96 px-3 py-2 border border-zinc-300 dark:border-zinc-700 rounded bg-white dark:bg-zinc-900 font-mono text-sm"
|
||||
placeholder="# Heading Content here..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="px-4 py-2 bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 rounded hover:bg-zinc-800 disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Saving..." : mode === "create" ? "Create" : "Update"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.back()}
|
||||
className="px-4 py-2 border border-zinc-300 dark:border-zinc-700 rounded hover:bg-zinc-100 dark:hover:bg-zinc-800"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { WEBHOOK_SECRET } from "@mcpedia/config";
|
||||
import { cookies } from "next/headers";
|
||||
import DocForm from "@/components/DocForm";
|
||||
import Link from "next/link";
|
||||
|
||||
// GET /create — show the document creation form.
|
||||
// Auth: requires the mcpedia_admin cookie (set via /api/auth/login).
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function CreateDocPage() {
|
||||
const cookieStore = await cookies();
|
||||
const canEdit = cookieStore.get("mcpedia_admin")?.value != null;
|
||||
|
||||
if (!canEdit) {
|
||||
return (
|
||||
<div className="p-8 max-w-md mx-auto">
|
||||
<h1 className="text-xl font-semibold mb-4">Authentication required</h1>
|
||||
<p className="text-zinc-600 dark:text-zinc-400 mb-4">
|
||||
You must be logged in to create documents.
|
||||
</p>
|
||||
<Link
|
||||
href="/login"
|
||||
className="px-4 py-2 bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 rounded"
|
||||
>
|
||||
Login
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<h1 className="text-2xl font-bold mb-6">Create Document</h1>
|
||||
<DocForm mode="create" slug="" secret={WEBHOOK_SECRET} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { WEBHOOK_SECRET } from "@mcpedia/config";
|
||||
import { cookies } from "next/headers";
|
||||
import DocForm from "@/components/DocForm";
|
||||
import Link from "next/link";
|
||||
|
||||
// GET /docs/create — show the create form.
|
||||
// Auth: requires the mcpedia_admin cookie (set via /api/auth/login).
|
||||
export default async function CreateDocPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ slug?: string; section?: string }>;
|
||||
}) {
|
||||
const params = await searchParams;
|
||||
const section =
|
||||
(params.section as "docs" | "writeups" | "research" | "notes") ?? "docs";
|
||||
const slug = params.slug ?? "";
|
||||
|
||||
// Auth check: cookie must be present.
|
||||
const cookieStore = await cookies();
|
||||
const adminCookie = cookieStore.get("mcpedia_admin");
|
||||
if (!adminCookie?.value) {
|
||||
return (
|
||||
<div className="p-8 max-w-md mx-auto">
|
||||
<h1 className="text-xl font-semibold mb-4">Authentication required</h1>
|
||||
<p className="text-zinc-600 dark:text-zinc-400 mb-4">
|
||||
You must be logged in to create documents.
|
||||
</p>
|
||||
<Link
|
||||
href="/login"
|
||||
className="px-4 py-2 bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 rounded"
|
||||
>
|
||||
Login
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<h1 className="text-2xl font-bold mb-6">Create Document</h1>
|
||||
<DocForm
|
||||
mode="create"
|
||||
slug={slug}
|
||||
secret={WEBHOOK_SECRET}
|
||||
initial={{ section }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
|
||||
// Simple admin login form. POSTs password to /api/auth/login.
|
||||
// On success, the API sets an HTTP-only cookie. We don't touch the cookie
|
||||
// client-side (it's HttpOnly) — just redirect to the create page after login.
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const [password, setPassword] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ password }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error ?? "Login failed");
|
||||
}
|
||||
|
||||
router.push("/docs/create");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-zinc-50 dark:bg-zinc-950">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="bg-white dark:bg-zinc-900 p-8 rounded-lg shadow-lg border border-zinc-200 dark:border-zinc-800">
|
||||
<h1 className="text-2xl font-bold mb-6 text-center">MCPedia Admin</h1>
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded text-red-800 dark:text-red-200 mb-4">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-zinc-300 dark:border-zinc-700 rounded bg-white dark:bg-zinc-900"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full px-4 py-2 bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 rounded hover:bg-zinc-800 disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Logging in..." : "Login"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<div className="text-center mt-4">
|
||||
<Link
|
||||
href="/"
|
||||
className="text-sm text-zinc-600 dark:text-zinc-400 hover:underline"
|
||||
>
|
||||
← Back to home
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+29
-8
@@ -1,6 +1,7 @@
|
||||
import Link from "next/link";
|
||||
import { listDocuments } from "@mcpedia/core";
|
||||
import type { DocumentMeta } from "@mcpedia/core";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
// Render at request time — content is in Postgres (not available at build in
|
||||
// CI, which has no DB). Request-time rendering is instant at this corpus scale.
|
||||
@@ -15,6 +16,10 @@ export default async function Home() {
|
||||
docs: all.filter((d) => d.section === section),
|
||||
}));
|
||||
|
||||
// Auth check for edit/create buttons.
|
||||
const cookieStore = await cookies();
|
||||
const canEdit = cookieStore.get("mcpedia_admin")?.value != null;
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<section>
|
||||
@@ -23,6 +28,14 @@ export default async function Home() {
|
||||
A content-first knowledge base. Humans read the Web UI; AI agents use
|
||||
the MCP server. Both share one Core.
|
||||
</p>
|
||||
{canEdit && (
|
||||
<Link
|
||||
href="/create"
|
||||
className="inline-block mt-3 px-4 py-2 text-sm border border-zinc-300 dark:border-zinc-700 rounded hover:bg-zinc-100 dark:hover:bg-zinc-800"
|
||||
>
|
||||
+ Create Document
|
||||
</Link>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{bySection.map(({ section, docs }) => (
|
||||
@@ -33,17 +46,25 @@ export default async function Home() {
|
||||
) : (
|
||||
<ul className="divide-y divide-zinc-200 dark:divide-zinc-800">
|
||||
{docs.map((d: DocumentMeta) => (
|
||||
<li key={d.slug} className="py-2">
|
||||
<li key={d.slug} className="py-2">
|
||||
<Link
|
||||
href={`/${d.slug}`}
|
||||
className="hover:underline font-medium"
|
||||
>
|
||||
{d.title}
|
||||
</Link>
|
||||
{canEdit && (
|
||||
<Link
|
||||
href={`/${d.slug}`}
|
||||
className="hover:underline font-medium"
|
||||
href={`/${d.slug}/edit`}
|
||||
className="ml-2 text-xs text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300"
|
||||
>
|
||||
{d.title}
|
||||
✎
|
||||
</Link>
|
||||
<div className="text-xs text-zinc-500 mt-0.5">
|
||||
{d.tags.map((t) => `#${t}`).join(" ")}
|
||||
</div>
|
||||
</li>
|
||||
)}
|
||||
<div className="text-xs text-zinc-500 mt-0.5">
|
||||
{d.tags.map((t) => `#${t}`).join(" ")}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user