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:
@@ -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