Files
clienthub/.planning/phases/02-admin-area-interactive-features/02-RESEARCH.md
T
2026-05-15 10:30:27 +02:00

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 adminsrc/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/header
  • POST /api/client/comment — Expects client token in body/header
  • POST /api/internal/validate-token — Called by proxy.ts (no auth needed, internal only)
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>
  )
}

[Source: https://medium.com/@ctrlaltmonique/how-to-use-react-hook-form-zod-with-next-js-server-actions-437aaca3d72d]

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) and getSession() 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_items in any admin API. Client API must return only accepted_total field. Enforced at query layer, not UI. [CITED: CLAUDE.md constraint #2]
  • Don't allow modification of approved_at once 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/tls modules unavailable at Edge. Always use fetch() 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

  1. 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.
  2. 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.
  3. 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 /admin without 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)

Secondary (MEDIUM confidence)

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.