From ff6baab393aac00ba2aeba349e60d14cb13c592f Mon Sep 17 00:00:00 2001 From: jknapp Date: Fri, 15 May 2026 10:57:17 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=203b=20=E2=80=94=20proper=20Web?= =?UTF-8?q?=20UI=20for=20memories,=20projects,=20settings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/web/app/(authed)/_nav.tsx | 44 ++ apps/web/app/(authed)/_search-box.tsx | 19 + apps/web/app/(authed)/_user-menu.tsx | 28 ++ apps/web/app/(authed)/dashboard/page.tsx | 154 ++++++ apps/web/app/(authed)/layout.tsx | 19 + apps/web/app/(authed)/memories/[id]/page.tsx | 142 ++++++ apps/web/app/(authed)/memories/new/page.tsx | 108 ++++ apps/web/app/(authed)/memories/page.tsx | 228 +++++++++ apps/web/app/(authed)/projects/[key]/page.tsx | 121 +++++ apps/web/app/(authed)/projects/page.tsx | 77 +++ apps/web/app/(authed)/settings/page.tsx | 78 +++ .../web/app/(authed)/settings/tokens/page.tsx | 162 ++++++ .../settings/tokens/tokens-manager.tsx | 67 +++ apps/web/app/_components/ui/badge.tsx | 28 ++ apps/web/app/_components/ui/button.tsx | 46 ++ apps/web/app/_components/ui/card.tsx | 32 ++ apps/web/app/_components/ui/container.tsx | 32 ++ apps/web/app/_components/ui/empty-state.tsx | 21 + apps/web/app/_components/ui/input.tsx | 45 ++ apps/web/app/connect/connect-form.tsx | 61 --- apps/web/app/connect/page.tsx | 72 +-- apps/web/app/globals.css | 121 +++-- apps/web/app/me/page.tsx | 29 +- apps/web/app/page.tsx | 65 ++- apps/web/drizzle/0001_cli_tokens.sql | 24 + apps/web/lib/auth/cli-token.ts | 100 +++- apps/web/lib/db/schema.ts | 21 + apps/web/lib/mcp/tools.ts | 124 +---- apps/web/lib/memories.ts | 148 ++++++ apps/web/lib/memory-actions.ts | 184 +++++++ apps/web/package.json | 2 + apps/web/postcss.config.mjs | 5 + pnpm-lock.yaml | 463 ++++++++++++++++-- 33 files changed, 2475 insertions(+), 395 deletions(-) create mode 100644 apps/web/app/(authed)/_nav.tsx create mode 100644 apps/web/app/(authed)/_search-box.tsx create mode 100644 apps/web/app/(authed)/_user-menu.tsx create mode 100644 apps/web/app/(authed)/dashboard/page.tsx create mode 100644 apps/web/app/(authed)/layout.tsx create mode 100644 apps/web/app/(authed)/memories/[id]/page.tsx create mode 100644 apps/web/app/(authed)/memories/new/page.tsx create mode 100644 apps/web/app/(authed)/memories/page.tsx create mode 100644 apps/web/app/(authed)/projects/[key]/page.tsx create mode 100644 apps/web/app/(authed)/projects/page.tsx create mode 100644 apps/web/app/(authed)/settings/page.tsx create mode 100644 apps/web/app/(authed)/settings/tokens/page.tsx create mode 100644 apps/web/app/(authed)/settings/tokens/tokens-manager.tsx create mode 100644 apps/web/app/_components/ui/badge.tsx create mode 100644 apps/web/app/_components/ui/button.tsx create mode 100644 apps/web/app/_components/ui/card.tsx create mode 100644 apps/web/app/_components/ui/container.tsx create mode 100644 apps/web/app/_components/ui/empty-state.tsx create mode 100644 apps/web/app/_components/ui/input.tsx delete mode 100644 apps/web/app/connect/connect-form.tsx create mode 100644 apps/web/drizzle/0001_cli_tokens.sql create mode 100644 apps/web/lib/memories.ts create mode 100644 apps/web/lib/memory-actions.ts create mode 100644 apps/web/postcss.config.mjs diff --git a/apps/web/app/(authed)/_nav.tsx b/apps/web/app/(authed)/_nav.tsx new file mode 100644 index 0000000..5168782 --- /dev/null +++ b/apps/web/app/(authed)/_nav.tsx @@ -0,0 +1,44 @@ +import Link from "next/link"; +import { Container } from "@/app/_components/ui/container"; +import { UserMenu } from "./_user-menu"; +import { SearchBox } from "./_search-box"; +import type { Session } from "next-auth"; + +export function Nav({ user }: { user: Session["user"] }) { + return ( +
+ + + + shared-memory + + + + +
+ +
+ + +
+
+ ); +} + +function NavLink({ href, children }: { href: string; children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/apps/web/app/(authed)/_search-box.tsx b/apps/web/app/(authed)/_search-box.tsx new file mode 100644 index 0000000..bb72d03 --- /dev/null +++ b/apps/web/app/(authed)/_search-box.tsx @@ -0,0 +1,19 @@ +import { Input } from "@/app/_components/ui/input"; + +/** + * Global search — submits a GET to /memories with `?q=`. Server-rendered + * results page handles the actual memory.search call. + */ +export function SearchBox() { + return ( +
+ +
+ ); +} diff --git a/apps/web/app/(authed)/_user-menu.tsx b/apps/web/app/(authed)/_user-menu.tsx new file mode 100644 index 0000000..f42e1d2 --- /dev/null +++ b/apps/web/app/(authed)/_user-menu.tsx @@ -0,0 +1,28 @@ +import { signOut } from "@/auth"; +import { Button } from "@/app/_components/ui/button"; +import type { Session } from "next-auth"; + +async function signOutAction() { + "use server"; + await signOut({ redirectTo: "/" }); +} + +export function UserMenu({ user }: { user: Session["user"] }) { + const label = user.email ?? user.name ?? user.id; + // Compact, single-line label; truncate on small screens via Tailwind. + return ( +
+ + {label} + +
+ +
+
+ ); +} diff --git a/apps/web/app/(authed)/dashboard/page.tsx b/apps/web/app/(authed)/dashboard/page.tsx new file mode 100644 index 0000000..f6b5f15 --- /dev/null +++ b/apps/web/app/(authed)/dashboard/page.tsx @@ -0,0 +1,154 @@ +import Link from "next/link"; +import { and, desc, eq, isNull, sql, count } from "drizzle-orm"; +import { auth } from "@/auth"; +import { db } from "@/lib/db/client"; +import { memories, projects } from "@/lib/db/schema"; +import { Container, PageHeader } from "@/app/_components/ui/container"; +import { Card, CardBody, CardHeader } from "@/app/_components/ui/card"; +import { Badge } from "@/app/_components/ui/badge"; +import { Button } from "@/app/_components/ui/button"; +import { EmptyState } from "@/app/_components/ui/empty-state"; + +export const dynamic = "force-dynamic"; + +export default async function DashboardPage() { + const session = await auth(); + const userId = session!.user.id; + + const [counts, recent, topProjects] = await Promise.all([ + db + .select({ + total: count(memories.id), + }) + .from(memories) + .where(and(eq(memories.userId, userId), isNull(memories.deletedAt))), + db + .select({ + id: memories.id, + content: memories.content, + scope: memories.scope, + tags: memories.tags, + createdAt: memories.createdAt, + projectKey: projects.key, + }) + .from(memories) + .leftJoin(projects, eq(memories.projectId, projects.id)) + .where(and(eq(memories.userId, userId), isNull(memories.deletedAt))) + .orderBy(desc(memories.createdAt)) + .limit(5), + db + .select({ + id: projects.id, + key: projects.key, + displayName: projects.displayName, + memoryCount: sql`count(${memories.id})::int`, + }) + .from(projects) + .leftJoin( + memories, + and(eq(memories.projectId, projects.id), isNull(memories.deletedAt)), + ) + .where(eq(projects.userId, userId)) + .groupBy(projects.id) + .orderBy(desc(sql`count(${memories.id})`)) + .limit(4), + ]); + + const memoryTotal = counts[0]?.total ?? 0; + + return ( + + + + + } + /> + +
+
+

Recent

+ {recent.length === 0 ? ( + + + + } + /> + ) : ( + recent.map((m) => ( + + + +
+ + {m.scope} + + {m.projectKey ? · {m.projectKey} : null} + + {new Date(m.createdAt).toLocaleDateString()} + +
+

{m.content}

+ {m.tags.length ? ( +
+ {m.tags.slice(0, 6).map((t) => ( + {t} + ))} +
+ ) : null} +
+
+ + )) + )} +
+ +
+

Projects

+ {topProjects.length === 0 ? ( +

No projects yet.

+ ) : ( + + {topProjects.map((p, i) => ( + 0 ? "border-t border-border" : ""}`} + > +
+ + {p.key} + + {p.memoryCount} +
+ {p.displayName && p.displayName !== p.key ? ( + + {p.displayName} + + ) : null} + + ))} + + All projects → + +
+ )} +
+
+
+ ); +} diff --git a/apps/web/app/(authed)/layout.tsx b/apps/web/app/(authed)/layout.tsx new file mode 100644 index 0000000..94df073 --- /dev/null +++ b/apps/web/app/(authed)/layout.tsx @@ -0,0 +1,19 @@ +import { redirect } from "next/navigation"; +import type { ReactNode } from "react"; +import { auth } from "@/auth"; +import { Nav } from "./_nav"; + +export const dynamic = "force-dynamic"; + +export default async function AuthedLayout({ children }: { children: ReactNode }) { + const session = await auth(); + if (!session?.user) { + redirect("/api/auth/signin?callbackUrl=/memories"); + } + return ( + <> +