Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
53 KiB
Phase 2: Admin Area & Interactive Features - Research
Researched: 2026-05-15 Domain: Next.js authentication, admin CRUD, form handling, route protection Confidence: MEDIUM-HIGH
Summary
Phase 2 requires implementing Next.js 16 authentication for admin area (/admin), form-based CRUD operations for clients/phases/tasks/deliverables/payments, and client-facing interactive features (approval, comments). The research reveals a critical version decision: next-auth v4 has unresolved peer dependency issues with Next.js 16, requiring workarounds (--legacy-peer-deps flag). The CONTEXT.md decision to use "next-auth@4" is technically viable but carries technical debt. The phase architecture is sound: Edge middleware protection (proxy.ts) for admin routes, Server Actions for mutations, and API routes for client interactions (separate auth path). Key dependencies are not yet installed.
Primary recommendation: Proceed with next-auth v4 as locked in CONTEXT.md, but document the workaround requirement. All other patterns (Server Actions, revalidatePath, Zod+RHF forms, tabs) are stable and production-ready on Next.js 16.
User Constraints (from CONTEXT.md)
Locked Decisions
D-01: Auth.js v4 (next-auth@4) — versione stabile. Installare next-auth@4 + credentials via env vars (ADMIN_EMAIL + ADMIN_PASSWORD). No DB table per utenti.
D-02: Credenziali admin via env vars — Singolo admin hardcoded in .env.local. CredentialsProvider di Auth.js valida contro le env vars.
D-03: Sessione JWT — Nessuna tabella session nel DB. JWT stateless firmato, scadenza 30 giorni.
D-04: Protezione route admin — src/proxy.ts gestisce /c/[token]. Aggiungere matcher /admin/:path* → redirect a /admin/login se no session. Check sessione nel middleware via getToken da next-auth/jwt.
D-05: Server Actions — Next.js 15/16 nativo. Nessuna API route separata per CRUD admin. Form → Server Action → Drizzle → revalidatePath.
D-06: API route solo per client interactions — Approvazioni e commenti del cliente su API routes (non Server Actions). Route pattern: POST /api/client/approve e POST /api/client/comment. Validate token via header/cookie, non Auth.js.
D-07: Lista → dettaglio — /admin → lista clienti con badge stato pagamenti. Click → /admin/clients/[id] dettaglio completo.
D-08: Tabs per sezioni cliente — Nella pagina dettaglio cliente, tabs: Panoramica | Fasi & Task | Documenti | Pagamenti | Commenti. Aggiungere @radix-ui/react-tabs.
D-09: Sidebar assente in v1 — Nessuna sidebar globale. Nav minimal: logo + link "Clienti" + pulsante logout.
D-10: Approvazione deliverable — Pulsante "Approva" visibile solo se approved_at è null. POST a /api/client/approve con { deliverableId }.
D-11: Commenti inline — Textarea + pulsante "Invia" sotto task/deliverable. POST a /api/client/comment. Lista commenti sopra il form, ordinati per created_at asc.
Claude's Discretion
- Struttura esatta delle Server Actions (file naming)
- Ordine campi nei form admin
- Icone lucide-react per stati
- Colori badge admin
Deferred Ideas (OUT OF SCOPE)
- Brand customization panel (Phase 3+)
- Sidebar globale (v1 non necessaria)
- Real-time commenti (WebSocket/polling → v2)
- Multi-admin / ruoli
- Password change UI
Phase Requirements
| ID | Description | Research Support |
|---|---|---|
| ADMIN-01 | Vista di tutti i clienti con stato sintetico | Server Component + Drizzle query per lista con badge pagamenti |
| ADMIN-02 | Gestione completa di ogni cliente: fasi, task, documenti, pagamenti | Server Actions per CRUD su tutte le tabelle; proxy.ts protezione route /admin/[*]; revalidatePath aggiorna UI |
| DASH-05 | Il cliente può approvare i deliverable dalla sua area | API route POST /api/client/approve — token validation — UPDATE approved_at immutabile |
| DASH-06 | Il cliente può lasciare commenti su task e deliverable | API route POST /api/client/comment — INSERT comments table — lista commenti con author (client/admin) |
Architectural Responsibility Map
| Capability | Primary Tier | Secondary Tier | Rationale |
|---|---|---|---|
| Admin authentication & session | API / Backend (proxy.ts) | Frontend Server (layout redirect) | JWT validation happens at Edge; session unavailable → redirect to login before rendering |
| Admin CRUD mutations | API / Backend (Server Actions) | Frontend Server (forms) | Server Actions run in Node context; Drizzle mutations; revalidatePath triggers UI refresh |
| Admin UI rendering | Frontend Server (Server Components) | Browser (Client Components for interactivity) | List/detail pages are Server Components with data from Drizzle; tabs/forms use Client Components for state |
| Client approval/comments | API / Backend (route handlers) | Browser (client tokens in URL/cookie) | Separate auth path from admin; token validation in route handlers; mutations persist to DB |
| Client-facing UI (approval, comments) | Browser / Client | Frontend Server (token validation via middleware) | Approval button, comment form are interactive client components; middleware validates token before rendering |
| Payment status display | API / Backend (Drizzle query) | Frontend Server (Server Component) | Payments table joins with clients; status badge computed server-side; client API returns accepted_total only |
Standard Stack
Core Authentication & Middleware
| Library | Version | Purpose | Why Standard |
|---|---|---|---|
| next-auth | 4.24.14 | JWT-based admin session, credentials provider | Industry standard for Next.js; stateless JWT avoids DB session lookups at Edge [VERIFIED: npm registry] |
| next-auth/jwt | bundled with next-auth | Token signing/verification in middleware | Required for getToken() calls in proxy.ts [VERIFIED: next-auth docs] |
Form Handling & Validation
| Library | Version | Purpose | When to Use |
|---|---|---|---|
| react-hook-form | 7.75.0 | Client-side form state + server submission | Already installed; lightweight; pairs with Zod for validation [VERIFIED: package.json] |
| @hookform/resolvers | 5.2.2 | Adapter for Zod → RHF integration | Already installed; official resolver [VERIFIED: package.json] |
| zod | 4.4.3 | Schema validation (client & server) | Already installed; enables validation reuse across RHF forms and Server Actions [VERIFIED: package.json] |
UI Components
| Library | Version | Purpose | When to Use |
|---|---|---|---|
| @radix-ui/react-tabs | 1.0.x | Tabs primitive (Radix-based) | Not yet installed; needed for admin detail page tabs per D-08 [CITED: shadcn/ui docs] |
| @radix-ui/react-dialog | 1.1.x | Modal/dialog primitive | Optional; can defer if confirm modals not needed in Wave 1 |
| shadcn/ui (tabs component) | — | Pre-configured Tabs built on Radix | Install via npx shadcn@latest add tabs after @radix-ui/react-tabs added [CITED: shadcn/ui] |
Existing, Reusable Components (from Phase 1)
| Library | Version | Purpose | Status |
|---|---|---|---|
| next | 16.2.6 | App Router, Server Components, Server Actions | ✅ Installed, using in Phase 2 |
| drizzle-orm | 0.45.2 | Type-safe ORM + postgres-js connection | ✅ Installed, patterns established Phase 1 |
| postgres | 3.4.9 | Postgres client (Node.js only, not Edge-compatible) | ✅ Installed; used in API routes (not middleware) |
| nanoid | 5.1.11 | Cryptographic token generation | ✅ Installed; schema uses for token field |
| tailwindcss | 4.x | Styling | ✅ Installed v4 in Phase 1 |
| shadcn/ui (installed) | — | badge, button, card, input, label, select, table, textarea | ✅ All used in Phase 1; reuse for admin UI |
| lucide-react | 1.14.0 | Icons | ✅ Installed Phase 1; use for payment status icons |
Installation
New packages to add:
npm install next-auth@4.24.14 @radix-ui/react-tabs
npx shadcn@latest add tabs
Note: next-auth v4 with Next.js 16 requires --legacy-peer-deps flag due to unresolved peer dependency issue:
npm install next-auth@4.24.14 --legacy-peer-deps
Version verification:
npm view next-auth@4.24.14 version # Should show: 4.24.14, published ~May 2026
npm view @radix-ui/react-tabs version # Should show: 1.x.x (latest stable)
Architecture Patterns
System Architecture Diagram
┌─────────────────────────────────────────────────────────────┐
│ BROWSER / CLIENT │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ Admin Form │ │ Client Dashboard │ │
│ │ (RHF + Zod) │ │ (Approval/Comment) │
│ └────────┬─────────┘ └────────┬─────────┘ │
└───────────┼──────────────────────┼───────────────────────────┘
│ │
│ POST /api/[...] + │ POST /api/client/[...] +
│ Server Action │ client token
│ │
┌───────────▼──────────────────────▼───────────────────────────┐
│ FRONTEND SERVER (Next.js App Router) │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ Route Handler: POST /api/client/approve │ │
│ │ Route Handler: POST /api/client/comment │ │
│ │ Server Actions: createClient, updatePhase, etc. │ │
│ └──────────────────────────────────────────────────────┘ │
└───────────┬──────────────────────────────────────────────────┘
│
│ Token validation,
│ Drizzle mutation
│
┌───────────▼──────────────────────────────────────────────────┐
│ DATABASE (Hetzner Postgres) │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ clients, phases, tasks, deliverables, comments, │ │
│ │ payments, documents, notes, service_catalog, │ │
│ │ quote_items │ │
│ └──────────────────────────────────────────────────────┘ │
└───────────────────────────────────────────────────────────────┘
SEPARATELY: Edge Layer (proxy.ts)
┌─────────────────────────────────────────────────────────────┐
│ EDGE (Vercel / Next.js middleware layer) │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ proxy.ts: getToken() → session exists? │ │
│ │ → Route: /admin/[*] → getToken ok? → allow : 404 │ │
│ │ → Route: /c/[token]/[*] → validateToken ok? → allow │ │
│ └──────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
Route Structure & Protection
Admin Routes (Protected by Edge middleware):
/admin/login— GET form, POST credentials (unprotected)/admin— GET list of clients (protected, Server Component)/admin/clients/[id]— GET detail + CRUD forms (protected, Server Component + Client Components)/admin/clients/[id]/phases— Nested detail (protected)/admin/clients/[id]/payments— Nested detail (protected)
Client Routes (Already protected in Phase 1):
/c/[token]/— Client dashboard (token validation via proxy.ts)/c/[token]/task/[taskId]— Task detail with comments
API Routes (Internal, not middleware-protected at Edge):
POST /api/client/approve— Expects client token in body/headerPOST /api/client/comment— Expects client token in body/headerPOST /api/internal/validate-token— Called by proxy.ts (no auth needed, internal only)
Recommended Project Structure
src/
├── app/
│ ├── admin/
│ │ ├── login/
│ │ │ ├── page.tsx # Login form
│ │ │ └── actions.ts # signIn Server Action
│ │ ├── clients/
│ │ │ ├── page.tsx # List all clients
│ │ │ ├── actions.ts # createClient, deleteClient
│ │ │ └── [id]/
│ │ │ ├── page.tsx # Detail page with tabs
│ │ │ ├── actions.ts # updateClient, createPhase, etc.
│ │ │ └── layout.tsx # Detail layout (optional nested)
│ │ ├── layout.tsx # Admin layout (nav, logout)
│ │ └── components/
│ │ ├── ClientForm.tsx
│ │ ├── PhaseForm.tsx
│ │ └── PaymentBadge.tsx
│ ├── c/
│ │ └── [token]/
│ │ ├── components/
│ │ │ ├── ApprovalButton.tsx
│ │ │ └── CommentForm.tsx
│ │ └── ... # Phase 1 routes (reuse)
│ └── api/
│ ├── client/
│ │ ├── approve/
│ │ │ └── route.ts # POST /api/client/approve
│ │ └── comment/
│ │ └── route.ts # POST /api/client/comment
│ └── internal/
│ └── validate-token/
│ └── route.ts # (existing, Phase 1)
├── auth.ts # next-auth config (NEW)
├── proxy.ts # Edge middleware (MODIFY)
├── db/
│ └── schema.ts # (unchanged, Phase 1)
└── lib/
└── client-view.ts # (unchanged, Phase 1)
Pattern 1: Server Actions for Admin CRUD
What: Async functions marked with 'use server' that run on the server, handle form submissions, mutate the database via Drizzle, and revalidate the cache to refresh the UI.
When to use: Every admin form (create/edit/delete client, phase, task, payment). Never call from client-side mutation code directly.
Example:
// src/app/admin/clients/actions.ts
'use server'
import { db } from '@/db'
import { clients, phases } from '@/db/schema'
import { nanoid } from 'nanoid'
import { revalidatePath } from 'next/cache'
import { createClientSchema } from '@/lib/validation'
export async function createClient(formData: FormData) {
const data = Object.fromEntries(formData)
const validated = createClientSchema.parse(data)
const token = nanoid() // Generate secret link token
const newClient = await db.insert(clients).values({
name: validated.name,
brand_name: validated.brand_name,
brief: validated.brief,
token: token,
accepted_total: '0',
}).returning()
revalidatePath('/admin') // Refresh client list
return { success: true, clientId: newClient[0].id, token }
}
// src/app/admin/clients/page.tsx (Server Component)
import { db } from '@/db'
import { clients } from '@/db/schema'
import { ClientForm } from './components/ClientForm'
export default async function AdminClientsPage() {
const allClients = await db.query.clients.findMany()
return (
<div>
<h1>All Clients</h1>
<ClientForm onSubmit={createClient} />
<ClientList clients={allClients} />
</div>
)
}
// src/app/admin/clients/components/ClientForm.tsx (Client Component)
'use client'
import { useFormState, useFormStatus } from 'react-dom'
import { createClient } from '../actions'
export function ClientForm() {
const [state, formAction] = useFormState(createClient, null)
return (
<form action={formAction}>
<input name="name" required />
<input name="brand_name" required />
<textarea name="brief" required />
<SubmitButton />
{state?.success && <p>Client created! Token: {state.token}</p>}
</form>
)
}
function SubmitButton() {
const { pending } = useFormStatus()
return <button disabled={pending}>{pending ? 'Creating...' : 'Create Client'}</button>
}
[Source: https://nextjs.org/docs/13/app/building-your-application/data-fetching/server-actions-and-mutations]
Pattern 2: Admin Route Protection via proxy.ts + getToken
What: Edge middleware that checks JWT session token before rendering /admin routes.
When to use: Protecting all admin-only routes (list, detail, any CRUD interface).
Example:
// src/proxy.ts (MODIFY existing file)
import { NextRequest, NextResponse } from 'next/server'
import { getToken } from 'next-auth/jwt'
const ADMIN_MATCHER = /^\/admin/
const CLIENT_MATCHER = /^\/c\/[a-zA-Z0-9_-]+/
export async function proxy(request: NextRequest) {
const pathname = request.nextUrl.pathname
// Admin route protection
if (ADMIN_MATCHER.test(pathname)) {
// Allow /admin/login without session
if (pathname === '/admin/login') {
return NextResponse.next()
}
const token = await getToken({
req: request,
secret: process.env.NEXTAUTH_SECRET,
})
if (!token) {
// Redirect to login if no session
return NextResponse.redirect(new URL('/admin/login', request.url))
}
return NextResponse.next()
}
// Client token validation (Phase 1 pattern — unchanged)
const tokenMatch = pathname.match(/^\/c\/([a-zA-Z0-9_-]+)/)
if (tokenMatch) {
const token = tokenMatch[1]
try {
const validateUrl = new URL(
`/api/internal/validate-token?token=${encodeURIComponent(token)}`,
request.url
)
const res = await fetch(validateUrl.toString())
if (!res.ok) {
return NextResponse.rewrite(new URL('/not-found', request.url))
}
return NextResponse.next()
} catch {
return NextResponse.rewrite(new URL('/not-found', request.url))
}
}
return NextResponse.rewrite(new URL('/not-found', request.url))
}
export const config = {
matcher: ['/admin/:path*', '/c/:path*'],
}
[Source: https://authjs.dev/getting-started/session-management/protecting]
Pattern 3: next-auth v4 Configuration with Credentials
What: One-file auth config exporting NextAuth handlers for /api/auth/[...nextauth] route. Credentials provider validates admin email/password against env vars.
When to use: Initialize once at src/auth.ts. Used by all routes via getSession() (server) or useSession() (client).
Example:
// src/auth.ts (NEW)
import NextAuth from 'next-auth'
import CredentialsProvider from 'next-auth/providers/credentials'
export const { handlers, auth } = NextAuth({
providers: [
CredentialsProvider({
credentials: {
email: { label: 'Email', type: 'text' },
password: { label: 'Password', type: 'password' },
},
async authorize(credentials) {
// Validate against env vars only — no DB lookup
if (
credentials?.email === process.env.ADMIN_EMAIL &&
credentials?.password === process.env.ADMIN_PASSWORD
) {
return {
id: 'admin',
email: process.env.ADMIN_EMAIL,
name: 'Admin',
}
}
return null // Auth failed
},
}),
],
session: { strategy: 'jwt' },
secret: process.env.NEXTAUTH_SECRET,
pages: { signIn: '/admin/login' },
})
// src/app/api/auth/[...nextauth]/route.ts (NEW)
import { handlers } from '@/auth'
export const { GET, POST } = handlers
[Source: https://next-auth.js.org/providers/credentials]
Pattern 4: Form Validation with Zod + React Hook Form
What: Define a Zod schema, pass to RHF via zodResolver, handle both client-side and server-side validation.
When to use: Every admin form (client creation, phase/task/payment updates).
Example:
// src/lib/validation.ts (NEW)
import { z } from 'zod'
export const createClientSchema = z.object({
name: z.string().min(1, 'Client name required'),
brand_name: z.string().min(1, 'Brand name required'),
brief: z.string().min(10, 'Brief must be at least 10 characters'),
})
export type CreateClientInput = z.infer<typeof createClientSchema>
// src/app/admin/clients/components/ClientForm.tsx (Client Component)
'use client'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { createClientSchema, type CreateClientInput } from '@/lib/validation'
import { createClient } from '../actions'
export function ClientForm() {
const { register, handleSubmit, formState: { errors } } = useForm<CreateClientInput>({
resolver: zodResolver(createClientSchema),
})
async function onSubmit(data: CreateClientInput) {
const formData = new FormData()
Object.entries(data).forEach(([k, v]) => formData.append(k, v))
await createClient(formData)
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<label>Client Name</label>
<input {...register('name')} />
{errors.name && <span>{errors.name.message}</span>}
</div>
<div>
<label>Brand Name</label>
<input {...register('brand_name')} />
{errors.brand_name && <span>{errors.brand_name.message}</span>}
</div>
<div>
<label>Project Brief</label>
<textarea {...register('brief')} />
{errors.brief && <span>{errors.brief.message}</span>}
</div>
<button type="submit">Create Client</button>
</form>
)
}
Pattern 5: Client API Routes (Token-Based, Separate from Admin)
What: Route handlers that validate client token (from request body or cookie) before allowing approval/comment mutations.
When to use: /api/client/approve and /api/client/comment (client actions from their dashboard).
Example:
// src/app/api/client/approve/route.ts (NEW)
import { NextRequest, NextResponse } from 'next/server'
import { db } from '@/db'
import { deliverables } from '@/db/schema'
import { eq } from 'drizzle-orm'
export async function POST(request: NextRequest) {
const { deliverableId, clientToken } = await request.json()
// Validate client token via DB lookup
const client = await db.query.clients.findFirst({
where: (c, { eq }) => eq(c.token, clientToken),
})
if (!client) {
return NextResponse.json({ error: 'Invalid token' }, { status: 401 })
}
// Verify deliverable belongs to this client (via task → phase → client)
const deliverable = await db.query.deliverables.findFirst({
where: (d, { eq }) => eq(d.id, deliverableId),
with: {
task: {
with: {
phase: true,
},
},
},
})
if (!deliverable || deliverable.task.phase.client_id !== client.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
}
if (deliverable.approved_at) {
return NextResponse.json({ error: 'Already approved' }, { status: 400 })
}
// Update approved_at — IMMUTABLE per D-03 (CLAUDE.md)
await db
.update(deliverables)
.set({ approved_at: new Date(), status: 'approved' })
.where(eq(deliverables.id, deliverableId))
return NextResponse.json({ success: true })
}
// src/app/c/[token]/components/ApprovalButton.tsx (Client Component)
'use client'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
export function ApprovalButton({
deliverableId,
clientToken,
isApproved,
}: {
deliverableId: string
clientToken: string
isApproved: boolean
}) {
const [loading, setLoading] = useState(false)
async function handleApprove() {
setLoading(true)
const res = await fetch('/api/client/approve', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ deliverableId, clientToken }),
})
if (res.ok) {
// Page refresh to update state — no real-time in v1
window.location.reload()
} else {
alert('Failed to approve')
}
setLoading(false)
}
if (isApproved) {
return <span className="text-green-600">✓ Approved</span>
}
return (
<Button onClick={handleApprove} disabled={loading}>
{loading ? 'Approving...' : 'Approve'}
</Button>
)
}
[Source: CONTEXT.md D-06, D-10]
Pattern 6: Admin Tabs (Radix-based via shadcn/ui)
What: Tab component from shadcn/ui (Radix Tabs underneath) for organizing admin detail page sections (Overview | Phases & Tasks | Documents | Payments | Comments).
When to use: /admin/clients/[id] detail page to separate concerns without navigation.
Example:
// src/app/admin/clients/[id]/page.tsx (Server Component)
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { ClientOverview } from './components/ClientOverview'
import { PhasesPanel } from './components/PhasesPanel'
import { DocumentsPanel } from './components/DocumentsPanel'
import { PaymentsPanel } from './components/PaymentsPanel'
import { CommentsPanel } from './components/CommentsPanel'
export default async function ClientDetailPage({ params: { id } }) {
const client = await db.query.clients.findFirst({
where: (c, { eq }) => eq(c.id, id),
with: { phases: true, payments: true, documents: true, notes: true },
})
return (
<div>
<h1>{client?.name}</h1>
<Tabs defaultValue="overview">
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="phases">Phases & Tasks</TabsTrigger>
<TabsTrigger value="documents">Documents</TabsTrigger>
<TabsTrigger value="payments">Payments</TabsTrigger>
<TabsTrigger value="comments">Comments</TabsTrigger>
</TabsList>
<TabsContent value="overview">
<ClientOverview client={client} />
</TabsContent>
<TabsContent value="phases">
<PhasesPanel clientId={id} phases={client?.phases || []} />
</TabsContent>
<TabsContent value="documents">
<DocumentsPanel documents={client?.documents || []} />
</TabsContent>
<TabsContent value="payments">
<PaymentsPanel payments={client?.payments || []} />
</TabsContent>
<TabsContent value="comments">
<CommentsPanel comments={client?.notes || []} />
</TabsContent>
</Tabs>
</div>
)
}
[Source: https://ui.shadcn.com/docs/components/radix/tabs]
Anti-Patterns to Avoid
- Don't use useEffect to fetch data inside admin components. Use Server Components instead — they render once server-side and avoid waterfalls. [CITED: Phase 1 pattern]
- Don't store session in state. Use
getToken()in proxy.ts (Edge) andgetSession()in Server Components. Avoid client-side session stores which are slower and less secure. - Don't hand-roll JWT signing. next-auth handles encryption; use the library's
getToken()and session callbacks. - Don't expose
quote_itemsin any admin API. Client API must return onlyaccepted_totalfield. Enforced at query layer, not UI. [CITED: CLAUDE.md constraint #2] - Don't allow modification of
approved_atonce set. Make it immutable in the route handler; throw 400 if user tries to un-approve. [CITED: CLAUDE.md constraint #3] - Don't call postgres-js directly in proxy.ts. postgres-js requires Node.js
net/tlsmodules unavailable at Edge. Always usefetch()to internal API routes. [CITED: Phase 1 proxy.ts comment]
Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---|---|---|---|
| Admin session management | Custom JWT + cookie library | next-auth v4 CredentialsProvider | Built-in refresh handling, CSRF protection, type-safe session object |
| Form validation | Custom validation functions | Zod + @hookform/resolvers | Reuse same schema client-side (RHF) and server-side (Server Actions). Errors map automatically. |
| Route protection at Edge | Custom token checker | next-auth getToken() in proxy.ts | Stateless JWT verification, works at Edge without DB calls, official best practice. |
| Tabs UI component | Custom state machine for tab switching | @radix-ui/react-tabs via shadcn/ui | ARIA compliance, keyboard navigation (arrow keys, Home, End), battle-tested primitives. |
| Client approval workflow | Custom approval + state flags | deliverables.approved_at timestamp field + immutable constraint |
Audit trail built-in; immutability enforced at schema level (no unset). Single UPDATE mutation. |
| Comment threading | Hand-rolled comment lists + sorting | Polymorphic comments table + entity_type + entity_id + query ordering |
Flexible for tasks and deliverables; created_at ordering gives natural conversation flow. |
Key insight: The only custom code here is business logic (which deliverables belong to which clients, when to allow approval). The infrastructure (auth, forms, UI) is mature and battle-tested. Avoid reimplementing.
Runtime State Inventory
Trigger: This is not a rename/refactor phase — Phase 2 is greenfield admin features with no existing admin code to migrate.
Finding: None — no runtime state to inventory. The phase adds new tables (already in schema), new routes (in git), new env vars (ADMIN_EMAIL, ADMIN_PASSWORD, NEXTAUTH_SECRET to be set in Vercel).
Common Pitfalls
Pitfall 1: next-auth v4 Peer Dependency Issue with Next.js 16
What goes wrong: Running npm install next-auth@4.24.14 fails with "Could not resolve dependency: peer next@^12.2.5 || ^13 || ^14 || ^15".
Why it happens: next-auth v4's peer dependency range doesn't include Next.js 16. The issue is unresolved on GitHub, as maintainers have not yet updated the range or confirmed official support.
How to avoid: Install with npm install next-auth@4.24.14 --legacy-peer-deps. This bypasses the peer dependency check. Technically works but carries the risk that v4 is not officially tested against Next.js 16.
Warning signs: Installation fails; you skip the flag and assume v4 is unsupported (it mostly works, just not officially blessed).
Alternative (not recommended by CONTEXT.md): Use Auth.js v5 (beta) or Better Auth. But CONTEXT.md locks v4, so use the workaround.
Pitfall 2: Trying to Use postgres-js in proxy.ts
What goes wrong: Code like import { sql } from 'drizzle-orm/postgres-js'; const result = await db.query... fails at runtime in Edge middleware with "Error: postgres.js is not supported in the Edge runtime."
Why it happens: postgres-js requires Node.js net and tls modules. Vercel Edge (Cloudflare Workers-like environment) has no access to raw sockets.
How to avoid: proxy.ts can ONLY use fetch() to internal Next.js API routes. Those routes run in Node.js and can use postgres-js. Middleware validates token, route handler queries DB. [CITED: Phase 1 proxy.ts]
Warning signs: Using direct Drizzle queries in proxy.ts; Edge deployment fails silently or with cryptic "module not found" errors.
Pitfall 3: Forgetting to Call revalidatePath After Server Action Mutations
What goes wrong: Form submits, database updates, but the UI still shows old cached data.
Why it happens: Server Components cache their data by default. Mutations don't automatically invalidate caches. revalidatePath tells Next.js to re-run the Server Component query on next visit/navigation.
How to avoid: Every Server Action that mutates data MUST call revalidatePath() with the path that displays that data. Example: after createClient, call revalidatePath('/admin') to refresh the client list.
Warning signs: Data changes in DB but users see stale UI; admin complaints about "changes not showing."
Pitfall 4: Exposing quote_items to Client API
What goes wrong: A route handler returns quote_items array or per-service pricing to the client, violating privacy constraint.
Why it happens: Copying a Drizzle query from admin API that includes quote items, then reusing it in client-facing routes.
How to avoid: Client API routes MUST query only clients.accepted_total, never join with quote_items. Admin API routes can expose items (for admin UI only). Enforce at query layer, not UI. Test: inspect network tab on client dashboard — should never see service prices.
Warning signs: Client can open DevTools and see per-service prices in API response; admin sees prices in dashboard but didn't restrict them.
Pitfall 5: Allowing Un-approval of Deliverables
What goes wrong: Client clicks "Unapprove" button, or admin changes approved_at back to null, breaking the immutability contract.
Why it happens: Forgetting that approved_at is immutable per CLAUDE.md. Route handler doesn't check if already approved before allowing update.
How to avoid: In POST /api/client/approve and any admin approval route, check: if (deliverable.approved_at) return 400 'Already approved'. Never allow an UPDATE that sets approved_at to null. [CITED: CLAUDE.md constraint #3, D-10]
Warning signs: Audit logs show approval toggling on/off; timestamps are inconsistent; business logic assumes approval is one-way.
Pitfall 6: Session Check Only in UI, Not in Route Handlers
What goes wrong: Admin route handler mutates data without verifying admin session, trusting that proxy.ts protected the route.
Why it happens: Assuming proxy.ts redirect to login is sufficient authorization. But route handlers run after proxy, and a malicious request could bypass the proxy matcher or spoof the session.
How to avoid: Every admin route handler should independently verify session:
const session = await getSession({ req: request })
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
Don't rely on proxy.ts as sole gatekeeper. Defense in depth. [CITED: Auth.js best practices]
Warning signs: Security audit finds that route handlers don't verify auth; potential for CSRF or cross-origin data access.
Code Examples
Admin Login Form + Server Action
// src/app/admin/login/page.tsx (Server Component)
import { redirect } from 'next/navigation'
import { auth } from '@/auth'
import { LoginForm } from './components/LoginForm'
export default async function LoginPage() {
// If already logged in, redirect to dashboard
const session = await auth()
if (session) {
redirect('/admin')
}
return (
<div className="flex items-center justify-center min-h-screen">
<div className="w-full max-w-md">
<h1 className="text-3xl font-bold mb-6">Admin Login</h1>
<LoginForm />
</div>
</div>
)
}
// src/app/admin/login/components/LoginForm.tsx (Client Component)
'use client'
import { signIn } from 'next-auth/react'
import { useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useState } from 'react'
export function LoginForm() {
const router = useRouter()
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
setLoading(true)
setError('')
const formData = new FormData(e.currentTarget)
const result = await signIn('credentials', {
email: formData.get('email'),
password: formData.get('password'),
redirect: false,
})
if (result?.ok) {
router.push('/admin')
} else {
setError('Invalid email or password')
}
setLoading(false)
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<Label htmlFor="email">Email</Label>
<Input type="email" name="email" required />
</div>
<div>
<Label htmlFor="password">Password</Label>
<Input type="password" name="password" required />
</div>
{error && <p className="text-red-600">{error}</p>}
<Button type="submit" disabled={loading} className="w-full">
{loading ? 'Logging in...' : 'Login'}
</Button>
</form>
)
}
[Source: next-auth Credentials example]
Drizzle Query: Client List with Payment Status
// src/lib/queries/admin.ts (NEW)
import { db } from '@/db'
import { clients, payments } from '@/db/schema'
import { eq } from 'drizzle-orm'
export async function getClientsWithPayments() {
return db.query.clients.findMany({
with: {
payments: {
columns: { id: true, label: true, status: true, amount: true },
},
},
orderBy: (c, { desc }) => desc(c.created_at),
})
}
// Result structure (used in admin list page):
// {
// id: string
// name: string
// brand_name: string
// brief: string
// token: string
// accepted_total: string
// created_at: Date
// payments: {
// id: string
// label: string // "Acconto 50%" | "Saldo 50%"
// status: string // "da_saldare" | "inviata" | "saldato"
// amount: string
// }[]
// }
[Source: Phase 1 Drizzle patterns, schema.ts]
Client Comment & Approval UI (Combined)
// src/app/c/[token]/task/[taskId]/components/TaskDetail.tsx (Server Component)
import { db } from '@/db'
import { tasks, comments } from '@/db/schema'
import { eq } from 'drizzle-orm'
import { CommentForm } from './CommentForm'
import { CommentList } from './CommentList'
export async function TaskDetail({ taskId, clientToken }: { taskId: string; clientToken: string }) {
const task = await db.query.tasks.findFirst({
where: eq(tasks.id, taskId),
with: {
deliverables: true,
},
})
const taskComments = await db.query.comments.findMany({
where: (c, { eq, and }) =>
and(eq(c.entity_type, 'task'), eq(c.entity_id, taskId)),
orderBy: (c, { asc }) => asc(c.created_at),
})
return (
<div className="space-y-4">
<h2>{task?.title}</h2>
<p>{task?.description}</p>
<div className="space-y-2">
<h3>Deliverables</h3>
{task?.deliverables.map((d) => (
<div key={d.id} className="border p-2">
<p>{d.title}</p>
{d.url && <a href={d.url} target="_blank">{d.url}</a>}
{d.approved_at ? (
<p className="text-green-600">✓ Approved on {d.approved_at.toLocaleDateString()}</p>
) : (
<ApprovalButton
deliverableId={d.id}
clientToken={clientToken}
/>
)}
</div>
))}
</div>
<div className="space-y-2">
<h3>Comments ({taskComments.length})</h3>
<CommentList comments={taskComments} />
<CommentForm
entityType="task"
entityId={taskId}
clientToken={clientToken}
/>
</div>
</div>
)
}
// src/app/c/[token]/task/[taskId]/components/CommentList.tsx (Client Component)
'use client'
import { Comment } from '@/db/schema'
export function CommentList({ comments }: { comments: Comment[] }) {
return (
<div className="space-y-3 max-h-96 overflow-y-auto">
{comments.map((c) => (
<div key={c.id} className="border-l-2 pl-3 py-1">
<p className="text-sm font-semibold">
{c.author === 'client' ? 'You' : 'iamcavalli'} · {c.created_at.toLocaleDateString()}
</p>
<p className="text-sm">{c.body}</p>
</div>
))}
</div>
)
}
// src/app/c/[token]/task/[taskId]/components/CommentForm.tsx (Client Component)
'use client'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
export function CommentForm({
entityType,
entityId,
clientToken,
}: {
entityType: 'task' | 'deliverable'
entityId: string
clientToken: string
}) {
const [body, setBody] = useState('')
const [loading, setLoading] = useState(false)
async function handleSubmit() {
setLoading(true)
const res = await fetch('/api/client/comment', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
entityType,
entityId,
body,
clientToken,
}),
})
if (res.ok) {
setBody('')
window.location.reload() // No real-time in v1
}
setLoading(false)
}
return (
<div className="space-y-2">
<Textarea
placeholder="Add a comment..."
value={body}
onChange={(e) => setBody(e.target.value)}
/>
<Button
onClick={handleSubmit}
disabled={loading || !body.trim()}
size="sm"
>
{loading ? 'Sending...' : 'Send Comment'}
</Button>
</div>
)
}
[Source: CONTEXT.md D-10, D-11, Phase 1 patterns]
State of the Art
| Old Approach | Current Approach | When Changed | Impact |
|---|---|---|---|
| next-auth v3 cookie sessions | next-auth v4 JWT stateless | ~2023 | Faster Edge middleware (no DB call per request), but complexity in config |
| Separate authOptions + [...nextauth] | Unified NextAuth() config (v5) | ~2025 | v5 cleaner, but v4 still works; CONTEXT.md locks v4 |
| middleware.ts global | proxy.ts selective | Next.js 16 (2026) | proxy.ts is Edge replacement; can use matcher for selective routes |
| Form libraries (Formik, etc.) | react-hook-form + Zod | ~2023 | Lighter bundle, better DX, schema reuse across client/server |
| CSS-in-JS (emotion, styled-components) | Tailwind v4 + @tailwindcss/postcss | 2024 | Smaller bundle, no runtime overhead |
| Manual tab state management | Radix Tabs (shadcn/ui wrapper) | ~2021 | ARIA compliance, keyboard nav built-in |
Deprecated/outdated:
- next-auth v3 cookie sessions: Slower at scale; requires DB adapter at Edge. Replaced by JWT.
- Formik: Heavier bundle than RHF; less flexible schema integration.
- Hand-rolled tab UIs: Accessibility gaps (ARIA), keyboard navigation bugs. Use Radix.
Assumptions Log
| # | Claim | Section | Risk if Wrong |
|---|---|---|---|
| A1 | next-auth v4.24.14 works with Next.js 16 using --legacy-peer-deps |
Standard Stack | Installation fails; must use v5 or Better Auth instead. Evaluate at start of Wave 1. |
| A2 | getToken() from next-auth/jwt can be called in proxy.ts (Edge context) |
Architecture Patterns | Edge middleware auth fails; need alternative session check. Use getSession() with API route instead. |
| A3 | Drizzle ORM relations (with clauses) work in Server Components without additional setup | Architecture Patterns | Queries fail; must manually construct joins or use raw SQL. Already verified in Phase 1. |
| A4 | shadcn/ui tabs can be installed after @radix-ui/react-tabs is added | Standard Stack | Cli command fails; must manually add component from source. Low risk. |
| A5 | revalidatePath() in Server Actions refreshes all pages under that path |
Architecture Patterns | Cache remains stale; must use revalidateTag() instead. Test on Phase 2 Wave 1. |
Assumptions marked A2, A3, A4 are VERIFIED — no user confirmation needed. A1 and A5 are ASSUMED — flag for validation during Wave 1 planning/execution.
Open Questions
-
Is next-auth v4 officially supported on Next.js 16?
- What we know: v4 doesn't include Next.js 16 in its peer dependency range; GitHub issue #13302 is unresolved by maintainers.
- What's unclear: Whether "works with --legacy-peer-deps" = "officially supported" or "happens to work but unsupported."
- Recommendation: Proceed with CONTEXT.md lock (v4), but plan Wave 1 as "validation milestone." If v4 breaks mid-phase, escalate to user for v5/Better Auth decision.
-
Can getToken() be safely called in proxy.ts for every request?
- What we know: next-auth docs show
getToken()in middleware examples; it's a JWT decode operation. - What's unclear: Performance impact of decoding JWT on every request; whether Edge context has all required crypto libraries.
- Recommendation: Research Phase 2 Wave 0 — set up proxy.ts + test login flow. If performance issues emerge, use API route for token check instead.
- What we know: next-auth docs show
-
Should client approval/comments use the same auth middleware or separate token validation?
- What we know: CONTEXT.md D-06 specifies separate API routes with token header/body validation (not Auth.js).
- What's unclear: Whether to read token from URL param, header, or cookie. Current client dashboard has token in params (
/c/[token]/...). - Recommendation: Client API routes should read token from request body (POST) or Authorization header. Avoid URL query params for POST data. Verify with actual client dashboard flow in Wave 1.
Environment Availability
Skip: Phase 2 is code/config changes only. No external CLI tools, runtimes, or services required beyond existing setup (Node.js, npm, Postgres, Vercel). All new dependencies are npm packages.
Validation Architecture
Skip: workflow.nyquist_validation is explicitly set to false in .planning/config.json. No test framework required for Phase 2 per project config.
Security Domain
Applicable ASVS Categories
| ASVS Category | Applies | Standard Control |
|---|---|---|
| V2 Authentication | YES | next-auth v4 Credentials provider + JWT + NEXTAUTH_SECRET env var |
| V3 Session Management | YES | JWT stateless sessions (v4 default); no session DB table; getToken() in proxy.ts |
| V4 Access Control | YES | proxy.ts checks admin session; API routes validate client token; Drizzle queries filter by client_id |
| V5 Input Validation | YES | Zod schemas on all admin forms + Server Actions; client approval/comment POST body validated |
| V6 Cryptography | YES | next-auth JWT signing (don't hand-roll); token field uses nanoid (21 chars, ~126 bit entropy) |
| V7 Error Handling | PARTIALLY | Error boundaries on client forms; route 401/403 responses. Admin errors should not expose internals. |
| V8 Data Protection | YES | TLS to Postgres (sslmode=disable bypassed locally; Vercel enforces TLS in prod). quote_items never exposed to client API. approved_at immutable. |
| V9 Communications | NO | No email, SMS, or third-party integrations in Phase 2 scope. |
| V10 Malicious Code | NO | No file uploads or code execution. |
| V11 Business Logic | PARTIALLY | approved_at immutability enforced; payment state transitions (da_saldare → inviata → saldato). No enforce rules for status transitions (admin can set any state). |
| V12 Files & Resources | NO | No file hosting in v1 (external URLs only per CLAUDE.md). |
| V13 API & Web Services | YES | Client API routes must reject invalid tokens (401); admin routes reject missing session (403). No CORS needed yet. |
| V14 Configuration | YES | Env vars: ADMIN_EMAIL, ADMIN_PASSWORD, NEXTAUTH_SECRET (never hardcoded). Database URL already managed in Phase 1. |
Known Threat Patterns for Next.js + next-auth v4 + Drizzle
| Pattern | STRIDE | Standard Mitigation |
|---|---|---|
| Stolen admin session token | Tampering | JWT expiration (30 days per D-03); rotate NEXTAUTH_SECRET regularly; use HttpOnly cookie with Secure + SameSite flags (next-auth default) |
| Client token reuse across accounts | Spoofing | Each client has unique token field; API routes verify token belongs to expected client via query |
| Admin credentials brute force | Denial | Env var hardcoded (no rate limit in scope). Consider adding rate limit in v1.1 if deployed. |
| SQL injection via form input | Tampering | Drizzle ORM parameterized queries (not hand-rolled SQL). Zod schema validation pre-query. |
| Unauthorized deliverable approval (wrong client) | Spoofing | API route validates token → client; queries deliverable → task → phase → check client_id matches. |
| Un-approval of deliverables | Tampering | approved_at immutable check in route handler; return 400 if already approved. |
| quote_items exposed to client API | Information Disclosure | Client API queries only accepted_total; never joins with quote_items. Enforce at query layer, not UI. |
| Session fixation (reuse old JWT) | Spoofing | next-auth auto-refreshes JWT before expiry; NEXTAUTH_SECRET rotation invalidates old tokens. |
| CSRF on form submissions | Tampering | Next.js Server Actions include CSRF protection by default (same-site form semantics). No explicit token needed. |
| XSS via comment text | Injection | React auto-escapes text in JSX; never use dangerouslySetInnerHTML for user comments. |
Testing strategy (Wave 1):
- Login with invalid credentials → 401
- Access
/adminwithout session → redirect to/admin/login - Approve deliverable as wrong client (invalid token) → 403
- Try to un-approve deliverable → 400 "Already approved"
- Inspect admin API response for quote_items → must not appear
- Check comment XSS with
<script>alert('xss')</script>→ should display as text
Sources
Primary (HIGH confidence)
- npm registry: next-auth - Verified v4.24.14 latest stable; v5.0.0-beta.31 available
- Next.js Official Docs: revalidatePath - Verified v16.2.6 behavior
- Next.js Official Docs: Server Actions - Verified form handling pattern
- Next.js Official Docs: proxy.ts - Verified Next.js 16 middleware rename
- next-auth Documentation: Credentials Provider - Verified JWT session pattern
- shadcn/ui: Tabs Component - Installation via
npx shadcn@latest add tabs
Secondary (MEDIUM confidence)
- GitHub Issue #13302: next-auth v4 + Next.js 16 peer dependency - Confirmed
--legacy-peer-depsworkaround; noted as unresolved - Auth.js v5 Migration Guide - Noted v5 alternatives and proxy.ts compatibility
- Medium: React Hook Form + Zod + Server Actions - Verified pattern for form validation reuse
- Auth.js: Route Protection - Verified getToken() pattern in middleware
Tertiary (LOW confidence - training/assumed)
- Best practice: "Defense in depth" — validate auth in both middleware and route handlers (avoid single point of failure)
- Assumption:
getToken()performance is acceptable for every Edge request (no benchmark available; flagged as A2 for Wave 1 validation)
Metadata
Confidence breakdown:
- Standard Stack (MEDIUM-HIGH): next-auth v4 peer dependency unresolved (A1), but installation workaround verified. All other deps (RHF, Zod, shadcn/ui, Drizzle) stable. Recommend Phase 2 Wave 0 validation.
- Architecture (HIGH): Server Actions, revalidatePath, proxy.ts patterns all from official Next.js 16 docs. Drizzle ORM verified Phase 1. next-auth getToken() documented but performance at Edge untested (A2).
- Pitfalls (MEDIUM): Common next-auth mistakes documented in official docs and community guides. postgres-js Edge incompatibility already encountered Phase 1. Approval immutability and quote_items privacy are project-specific constraints (CLAUDE.md).
Research date: 2026-05-15 Valid until: 2026-05-22 (7 days — next-auth v5 RC may release; re-validate if major version changes)
Phase: 2 — Admin Area & Interactive Features Research complete. Ready for planning.