Admin UI Plan
Two-phase plan for building a simple admin interface with password auth now and passkey upgrade later.
April 3, 2026Goal
Stop manually editing the DB to publish docs. Have a simple web interface for toggling draft/published, editing metadata, and eventually moderating comments.
Auth: Two Phases
Phase 1 — Simple password (build this now)
ADMIN_PASSWORDenv var in.env.local/admin/login— single password input, Server Action verifies it, signs a JWT withjose, sets anhttpOnlySecureSameSite=Laxcookiesrc/middleware.ts— reads cookie on every/admin/*request, verifies JWT, redirects to/admin/loginif missing or invalid- Only new dependency:
jose(JWT signing/verification, small, no external service)
Good enough for toggling publish status from your laptop. Not ideal for mobile.
Phase 2 — Passkeys (upgrade when comments land)
When comments need moderation from mobile, swap the password for a passkey via passwordless.dev (Bitwarden-backed WebAuthn). The admin UI, middleware, and JWT cookie all stay identical — only the login page changes. Full plan already documented in admin-auth-plan.md.
The reason to wait: passkeys require a one-time browser registration step and an external API key. Not worth the setup friction just to toggle a status field from your laptop.
Admin Routes
/admin → redirect to /admin/content
/admin/login → password form (client component)
/admin/content → content list with status toggles
/admin/content/[slug] → full metadata editor (phase 2)
Files to Create
src/middleware.ts ← JWT check on /admin/*
src/app/admin/layout.tsx ← shared admin shell + signout button
src/app/admin/login/page.tsx ← password form
src/app/admin/content/page.tsx ← content list + status toggles
src/actions/content.ts ← Server Actions (toggleStatus, updateMetadata)
src/app/api/auth/signout/route.ts ← clears session cookie
Content List Page (/admin/content)
A table of all content rows regardless of status. Columns:
| Title | Type | Status | Created |
|---|---|---|---|
| Block Model Architecture | doc | draft | Apr 2 |
| Content Pipeline Deep Dive | doc | published | Mar 28 |
- Status cell is a clickable badge — cycles
draft → published → archived - Click triggers a Server Action:
UPDATE content SET status = ? WHERE id = ? - Server Action calls
revalidatePath('/admin/content')+revalidatePath('/docs')+revalidatePath('/posts') - No page reload, no separate edit page needed for the basic flow
Server Actions (src/actions/content.ts)
"use server"
export async function toggleStatus(id: string, current: string) {
const next = current === 'draft' ? 'published'
: current === 'published' ? 'archived'
: 'draft'
await db.execute({
sql: "UPDATE content SET status = ?, updated_at = ? WHERE id = ?",
args: [next, new Date().toISOString(), id],
})
revalidatePath('/admin/content')
revalidatePath('/docs')
revalidatePath('/posts')
}
Middleware (src/middleware.ts)
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { jwtVerify } from 'jose'
const secret = new TextEncoder().encode(process.env.ADMIN_JWT_SECRET)
export async function middleware(req: NextRequest) {
const token = req.cookies.get('admin_session')?.value
if (!token) return NextResponse.redirect(new URL('/admin/login', req.url))
try {
await jwtVerify(token, secret)
return NextResponse.next()
} catch {
return NextResponse.redirect(new URL('/admin/login', req.url))
}
}
export const config = {
matcher: ['/admin/:path*'],
}
Env Vars to Add
ADMIN_PASSWORD=your-password-here
ADMIN_JWT_SECRET=a-long-random-secret-string
ADMIN_JWT_SECRET should be a long random string (32+ chars). Generate with:
openssl rand -base64 32
Future: Comments Moderation
When comments land (post block model), the admin shell gets a second tab:
/admin/content ← content list (existing)
/admin/comments ← pending comment queue, approve/reject
A comment is a comments table row with approved: boolean. Pending comments show in a queue — click approve, it becomes visible on the page. Click reject, it's deleted.
This is also when Phase 2 auth (passkeys) becomes worth implementing — mobile moderation from Bitwarden is a much better experience than typing a password on your phone.
Implementation Order
- Install
jose - Add
ADMIN_PASSWORD+ADMIN_JWT_SECRETto.env.local - Build login page + login Server Action
- Build signout route
- Build middleware
- Build
/admin/contentlist page with status toggle - Test end-to-end: login → toggle a doc to published → verify it appears on
/docs