feat: Phase 3b — proper Web UI for memories, projects, settings

Replaces the debug /me + /connect pages with a real authed app shell.

Pages
- /                       — anonymous landing; redirects to /dashboard once signed in
- /dashboard              — recent memories + top projects, quick "new memory" action
- /memories               — searchable list with hybrid (vector+FTS+tags) scoring;
                            per-result rank breakdown shown inline
- /memories/[id]          — view + inline edit toggle + delete
- /memories/new           — create form with project autocomplete
- /projects               — list with memory counts and last-activity
- /projects/[key]         — that project's memories
- /settings               — read-only Authentik profile + link to tokens
- /settings/tokens        — list / create / revoke CLI tokens

Old URLs preserved as redirects:
- /me      → /dashboard
- /connect → /settings/tokens

Stack additions
- Tailwind v4 with CSS-first @theme tokens (dark only for now)
- App shell in app/(authed)/ — auth guard + top nav with global search box
- Lightweight UI primitives in app/_components/ui/ (Button, Input, Card,
  Badge, EmptyState, Container, PageHeader)
- Search logic extracted from MCP tool into lib/memories.ts so Web UI and
  MCP both call the same RRF code path
- Memory CRUD via Server Actions in lib/memory-actions.ts; audit_log
  rows are tagged actor='web' to distinguish from MCP writes

Per-token revoke
- New cli_tokens table (id, user_id, jti unique, name, created_at,
  last_used_at, expires_at, revoked_at) — migration 0001_cli_tokens.sql
- mintCliToken now records jti + name; verifyCliToken enforces revocation
  for tracked tokens. Legacy tokens minted before this change (no jti)
  are accepted on signature alone until they expire naturally.
- /settings/tokens lists active + revoked tokens with one-click revoke

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-15 10:57:17 -07:00
co-authored by Claude Opus 4.7
parent 609039f098
commit ff6baab393
33 changed files with 2475 additions and 395 deletions
-61
View File
@@ -1,61 +0,0 @@
"use client";
import { useActionState } from "react";
interface State {
token: string | null;
error: string | null;
}
interface Props {
action: (prev: State) => Promise<State>;
ttlDays: number;
}
const initial: State = { token: null, error: null };
export default function ConnectForm({ action, ttlDays }: Props) {
const [state, formAction, pending] = useActionState(action, initial);
return (
<section style={{ marginTop: "2rem" }}>
{state.token ? (
<>
<h2 style={{ color: "#7ee787" }}>
New token (copy now won&apos;t be shown again)
</h2>
<pre
style={{
whiteSpace: "pre-wrap",
wordBreak: "break-all",
userSelect: "all",
}}
>
{state.token}
</pre>
<h3>Add to Claude Code</h3>
<pre>{`claude mcp add --transport http \\
--header "Authorization: Bearer ${state.token}" \\
shared-memory https://memory.dnspegasus.net/api/mcp`}</pre>
<p className="muted">
Valid for {ttlDays} days. To revoke all outstanding CLI tokens at
once, rotate <code>CLI_TOKEN_SECRET</code> on the server.
</p>
</>
) : (
<form action={formAction}>
<button type="submit" disabled={pending}>
{pending ? "Generating…" : "Generate token"}
</button>
{state.error ? (
<p style={{ color: "#ff6b6b" }}>error: {state.error}</p>
) : null}
<p className="muted" style={{ marginTop: "0.75rem" }}>
Tokens carry your full Authentik identity. Valid for {ttlDays}{" "}
days. Treat them like a password.
</p>
</form>
)}
</section>
);
}
+4 -68
View File
@@ -1,73 +1,9 @@
import { redirect } from "next/navigation";
import { eq } from "drizzle-orm";
import { auth } from "@/auth";
import { db } from "@/lib/db/client";
import { users } from "@/lib/db/schema";
import { mintCliToken, CLI_TOKEN_TTL_SECONDS } from "@/lib/auth/cli-token";
import ConnectForm from "./connect-form";
export const dynamic = "force-dynamic";
/**
* Server action — mints a fresh CLI token for the currently signed-in user.
*
* Returned via useActionState to the client; the token only ever exists in
* React state, never in the URL or a persisted cookie.
*/
async function generateToken(_prev: { token: string | null; error: string | null }) {
"use server";
try {
const session = await auth();
if (!session?.user?.id) return { token: null, error: "not authenticated" };
const row = await db
.select({
oidcIss: users.oidcIss,
oidcSub: users.oidcSub,
email: users.email,
name: users.name,
})
.from(users)
.where(eq(users.id, session.user.id))
.limit(1);
const u = row[0];
if (!u) return { token: null, error: "user row not found" };
const token = await mintCliToken({
oidcIss: u.oidcIss,
oidcSub: u.oidcSub,
email: u.email,
name: u.name,
});
return { token, error: null };
} catch (e) {
return { token: null, error: e instanceof Error ? e.message : "unknown error" };
}
}
export default async function ConnectPage() {
const session = await auth();
if (!session?.user) {
redirect("/api/auth/signin?callbackUrl=/connect");
}
const ttlDays = Math.floor(CLI_TOKEN_TTL_SECONDS / 86400);
const userLabel = session.user.email ?? session.user.name ?? session.user.id;
return (
<main className="container">
<h1>Connect an MCP client</h1>
<p className="muted">
Generate a bearer token for pasting into Claude Code (or any MCP
client) when an OAuth loopback callback isn&apos;t practical for
example, a Claude Code instance running inside a container.
</p>
<p>
Signed in as <strong>{userLabel}</strong>.
</p>
<ConnectForm action={generateToken} ttlDays={ttlDays} />
</main>
);
// Legacy URL — moved to /settings/tokens in Phase 3b. Preserve old
// bookmarks and the existing instructions printed by older clients.
export default function ConnectRedirect() {
redirect("/settings/tokens");
}