docs(02-admin-area-interactive-features): complete phase 2 planning with 4-plan structure
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,481 @@
|
||||
---
|
||||
phase: "02-admin-area-interactive-features"
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- package.json
|
||||
- src/proxy.ts
|
||||
- src/app/api/auth/[...nextauth]/route.ts
|
||||
- src/app/admin/login/page.tsx
|
||||
- src/app/admin/login/actions.ts
|
||||
- src/lib/auth.ts
|
||||
- .env.local
|
||||
autonomous: true
|
||||
requirements:
|
||||
- ADMIN-01
|
||||
- ADMIN-02
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Admin can POST /admin/login with ADMIN_EMAIL + ADMIN_PASSWORD and receive a session JWT cookie"
|
||||
- "Visiting /admin/* without a valid session redirects to /admin/login"
|
||||
- "Visiting /c/[token]/* still validates token at edge (proxy.ts unchanged for client routes)"
|
||||
- "Session is JWT-based (stateless) — no DB users table involved"
|
||||
- "ADMIN_EMAIL and ADMIN_PASSWORD are read from env vars, never hardcoded"
|
||||
artifacts:
|
||||
- path: "src/lib/auth.ts"
|
||||
provides: "NextAuth config — CredentialsProvider validating against ADMIN_EMAIL/ADMIN_PASSWORD env vars"
|
||||
contains: "CredentialsProvider"
|
||||
- path: "src/app/api/auth/[...nextauth]/route.ts"
|
||||
provides: "NextAuth catch-all route handler"
|
||||
contains: "NextAuth"
|
||||
- path: "src/app/admin/login/page.tsx"
|
||||
provides: "Admin login form UI (email + password, submit)"
|
||||
min_lines: 30
|
||||
- path: "src/proxy.ts"
|
||||
provides: "Updated proxy: /c/* token validation + /admin/* session guard"
|
||||
contains: "getToken"
|
||||
key_links:
|
||||
- from: "src/proxy.ts"
|
||||
to: "src/app/api/auth/[...nextauth]/route.ts"
|
||||
via: "getToken({ req, secret: process.env.NEXTAUTH_SECRET })"
|
||||
pattern: "getToken"
|
||||
- from: "src/app/admin/login/page.tsx"
|
||||
to: "/api/auth/callback/credentials"
|
||||
via: "signIn('credentials', { email, password })"
|
||||
pattern: "signIn"
|
||||
- from: "ADMIN_EMAIL + ADMIN_PASSWORD"
|
||||
to: "CredentialsProvider authorize()"
|
||||
via: "process.env.ADMIN_EMAIL"
|
||||
pattern: "ADMIN_EMAIL"
|
||||
---
|
||||
|
||||
<objective>
|
||||
**Auth.js Admin Session + Proxy Guard:** Install next-auth@4, configure a CredentialsProvider that validates against ADMIN_EMAIL/ADMIN_PASSWORD env vars, wire the catch-all API route, build the login page, and extend the existing src/proxy.ts to guard /admin/* routes with a session check.
|
||||
|
||||
Purpose: Gate the entire admin area behind Auth.js JWT session before any admin UI is built. Two independent auth paths are enforced: /c/[token]/* uses edge token validation (unchanged from Phase 1), /admin/* uses getToken() from next-auth/jwt. No DB users table — single admin, env-var credentials only (per D-01, D-02, D-03, D-04).
|
||||
|
||||
Output: Working /admin/login page, session cookie on successful login, automatic redirect to /admin/login for unauthenticated access to any /admin/* route.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/phases/02-admin-area-interactive-features/02-CONTEXT.md
|
||||
@.planning/phases/01-foundation-client-dashboard/01-05-SUMMARY.md
|
||||
|
||||
<interfaces>
|
||||
<!-- Existing proxy from Phase 1 (src/proxy.ts) — EXTEND this file, do not create src/middleware.ts -->
|
||||
<!-- Current structure: named export `proxy(request)` + config.matcher = ['/c/:path*'] -->
|
||||
<!-- Next.js requires the export to be named `middleware` — rename proxy→middleware in this task -->
|
||||
<!-- Phase 2 extends it to also handle /admin/:path* session guard using getToken() from next-auth/jwt -->
|
||||
|
||||
Current src/proxy.ts content:
|
||||
```typescript
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export async function proxy(request: NextRequest) {
|
||||
const pathname = request.nextUrl.pathname;
|
||||
|
||||
// Extract token from path: /c/[token]/...
|
||||
const tokenMatch = pathname.match(/^\/c\/([a-zA-Z0-9_-]+)/);
|
||||
if (!tokenMatch) {
|
||||
return NextResponse.rewrite(new URL('/not-found', request.url));
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ['/c/:path*'],
|
||||
};
|
||||
```
|
||||
|
||||
Note: Next.js middleware MUST be exported as `middleware`, not `proxy`. The Phase 1 file uses `proxy` — this plan must rename it to `middleware` while extending it with /admin/* guard. No src/middleware.ts should ever be created.
|
||||
|
||||
From src/db/index.ts:
|
||||
```typescript
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import postgres from "postgres";
|
||||
export const db = drizzle(client);
|
||||
```
|
||||
|
||||
From src/db/schema.ts (types needed in this plan):
|
||||
```typescript
|
||||
// No schema changes needed — no users table. Auth is env-var only.
|
||||
export type Client = typeof clients.$inferSelect;
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Install next-auth@4, create src/lib/auth.ts and NextAuth catch-all route</name>
|
||||
<files>
|
||||
package.json
|
||||
src/lib/auth.ts
|
||||
src/app/api/auth/[...nextauth]/route.ts
|
||||
.env.local
|
||||
</files>
|
||||
<action>
|
||||
Install next-auth v4 (stable — v5 is still beta RC as of 2026-05-15, per D-01):
|
||||
```
|
||||
npm install next-auth@4
|
||||
```
|
||||
|
||||
Add to .env.local (generate NEXTAUTH_SECRET with: `openssl rand -base64 32`):
|
||||
```
|
||||
NEXTAUTH_URL=http://localhost:3000
|
||||
NEXTAUTH_SECRET=<generated-32-byte-base64-string>
|
||||
ADMIN_EMAIL=simone.cavalli.gestione@gmail.com
|
||||
ADMIN_PASSWORD=<choose-a-strong-password>
|
||||
```
|
||||
|
||||
Create `src/lib/auth.ts` — NextAuth config, no DB adapter (per D-03):
|
||||
```typescript
|
||||
import type { NextAuthOptions } from "next-auth";
|
||||
import CredentialsProvider from "next-auth/providers/credentials";
|
||||
|
||||
export const authOptions: NextAuthOptions = {
|
||||
providers: [
|
||||
CredentialsProvider({
|
||||
name: "credentials",
|
||||
credentials: {
|
||||
email: { label: "Email", type: "email" },
|
||||
password: { label: "Password", type: "password" },
|
||||
},
|
||||
async authorize(credentials) {
|
||||
if (!credentials?.email || !credentials?.password) return null;
|
||||
|
||||
const adminEmail = process.env.ADMIN_EMAIL;
|
||||
const adminPassword = process.env.ADMIN_PASSWORD;
|
||||
|
||||
if (!adminEmail || !adminPassword) {
|
||||
throw new Error("ADMIN_EMAIL and ADMIN_PASSWORD env vars must be set");
|
||||
}
|
||||
|
||||
if (
|
||||
credentials.email === adminEmail &&
|
||||
credentials.password === adminPassword
|
||||
) {
|
||||
// Return minimal session user — no DB lookup needed
|
||||
return { id: "admin", email: adminEmail, name: "Admin" };
|
||||
}
|
||||
|
||||
return null; // null = unauthorized (NextAuth returns 401)
|
||||
},
|
||||
}),
|
||||
],
|
||||
session: {
|
||||
strategy: "jwt", // stateless JWT — no DB session table (per D-03)
|
||||
maxAge: 30 * 24 * 60 * 60, // 30 days
|
||||
},
|
||||
pages: {
|
||||
signIn: "/admin/login", // custom login page (per D-07)
|
||||
},
|
||||
callbacks: {
|
||||
async jwt({ token, user }) {
|
||||
if (user) {
|
||||
token.id = user.id;
|
||||
}
|
||||
return token;
|
||||
},
|
||||
async session({ session, token }) {
|
||||
if (session.user) {
|
||||
(session.user as { id?: string }).id = token.id as string;
|
||||
}
|
||||
return session;
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
Create `src/app/api/auth/[...nextauth]/route.ts` — NextAuth catch-all:
|
||||
```typescript
|
||||
import NextAuth from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
|
||||
const handler = NextAuth(authOptions);
|
||||
|
||||
export { handler as GET, handler as POST };
|
||||
```
|
||||
|
||||
Note: next-auth@4 with App Router uses this export pattern. The handler handles
|
||||
GET (session fetch, CSRF) and POST (sign in, sign out).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>grep -q '"next-auth"' package.json && echo "next-auth installed"</automated>
|
||||
<automated>test -f src/lib/auth.ts && grep -q "CredentialsProvider" src/lib/auth.ts && echo "CredentialsProvider configured"</automated>
|
||||
<automated>grep -q "strategy.*jwt" src/lib/auth.ts && echo "JWT session strategy set"</automated>
|
||||
<automated>grep -q "ADMIN_EMAIL" src/lib/auth.ts && echo "ADMIN_EMAIL env var referenced"</automated>
|
||||
<automated>test -f src/app/api/auth/\[...nextauth\]/route.ts && grep -q "NextAuth" src/app/api/auth/\[...nextauth\]/route.ts && echo "NextAuth route created"</automated>
|
||||
<automated>grep -q "NEXTAUTH_SECRET" .env.local && echo "NEXTAUTH_SECRET in .env.local"</automated>
|
||||
<automated>npm run build 2>&1 | grep -v "warning" | grep -qi "error" && echo "BUILD ERRORS" || echo "TypeScript OK"</automated>
|
||||
</verify>
|
||||
<done>
|
||||
- next-auth@4 is in package.json
|
||||
- src/lib/auth.ts exports authOptions with CredentialsProvider using env vars
|
||||
- src/app/api/auth/[...nextauth]/route.ts exports GET and POST handlers
|
||||
- NEXTAUTH_SECRET, ADMIN_EMAIL, ADMIN_PASSWORD are set in .env.local
|
||||
- npm run build passes without errors
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Extend src/proxy.ts to guard /admin/* with session check; create /admin/login page</name>
|
||||
<files>
|
||||
src/proxy.ts
|
||||
src/app/admin/login/page.tsx
|
||||
src/app/admin/login/actions.ts
|
||||
</files>
|
||||
<action>
|
||||
**Replace** `src/proxy.ts` entirely. NEVER create src/middleware.ts — it would be a dead file ignored by Next.js since this project uses src/proxy.ts as the middleware entry point. The new file renames the export from `proxy` to `middleware` (required by Next.js) and adds the /admin/* guard alongside the existing /c/* token validation logic (per D-04):
|
||||
|
||||
```typescript
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getToken } from "next-auth/jwt";
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
const pathname = request.nextUrl.pathname;
|
||||
|
||||
// ── ADMIN GUARD ──────────────────────────────────────────────────────────
|
||||
if (pathname.startsWith("/admin")) {
|
||||
// Allow the login page and NextAuth API routes through without session check
|
||||
if (
|
||||
pathname === "/admin/login" ||
|
||||
pathname.startsWith("/api/auth")
|
||||
) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
const token = await getToken({
|
||||
req: request,
|
||||
secret: process.env.NEXTAUTH_SECRET,
|
||||
});
|
||||
|
||||
if (!token) {
|
||||
const loginUrl = new URL("/admin/login", request.url);
|
||||
loginUrl.searchParams.set("callbackUrl", pathname);
|
||||
return NextResponse.redirect(loginUrl);
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// ── CLIENT TOKEN GUARD ───────────────────────────────────────────────────
|
||||
if (pathname.startsWith("/c/")) {
|
||||
const tokenMatch = pathname.match(/^\/c\/([a-zA-Z0-9_-]+)/);
|
||||
if (!tokenMatch) {
|
||||
return NextResponse.rewrite(new URL("/not-found", request.url));
|
||||
}
|
||||
|
||||
const clientToken = tokenMatch[1];
|
||||
|
||||
try {
|
||||
const validateUrl = new URL(
|
||||
`/api/internal/validate-token?token=${encodeURIComponent(clientToken)}`,
|
||||
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.next();
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/admin/:path*", "/c/:path*"],
|
||||
};
|
||||
```
|
||||
|
||||
Note: The export is renamed from `proxy` to `middleware`. This is the only correct Next.js middleware export name. The /c/* logic is preserved verbatim from Phase 1.
|
||||
|
||||
Create `src/app/admin/login/page.tsx` — login form as Client Component:
|
||||
```typescript
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
export default function AdminLoginPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const callbackUrl = searchParams.get("callbackUrl") ?? "/admin";
|
||||
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const result = await signIn("credentials", {
|
||||
email,
|
||||
password,
|
||||
redirect: false, // handle redirect manually to show errors
|
||||
});
|
||||
|
||||
if (result?.error) {
|
||||
setError("Email o password non corretti.");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
router.replace(callbackUrl);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<Card className="w-full max-w-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">Admin — ClientHub</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
autoComplete="email"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="text-sm text-red-600">{error}</p>
|
||||
)}
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? "Accesso in corso..." : "Accedi"}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Do NOT create src/app/admin/login/actions.ts — the login is handled
|
||||
client-side via signIn(). No Server Action file is needed.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>test -f src/proxy.ts && grep -q "getToken" src/proxy.ts && echo "getToken imported in proxy.ts"</automated>
|
||||
<automated>grep -q "export async function middleware" src/proxy.ts && echo "export named middleware (not proxy)"</automated>
|
||||
<automated>grep -q '"/admin/:path\*"' src/proxy.ts && echo "admin matcher configured"</automated>
|
||||
<automated>grep -q '"/c/:path\*"' src/proxy.ts && echo "client matcher still present"</automated>
|
||||
<automated>grep -q "pathname === \"/admin/login\"" src/proxy.ts && echo "login page exempted from auth guard"</automated>
|
||||
<automated>test -f src/app/admin/login/page.tsx && grep -q "signIn" src/app/admin/login/page.tsx && echo "login page uses signIn"</automated>
|
||||
<automated>grep -q '"use client"' src/app/admin/login/page.tsx && echo "login page is Client Component"</automated>
|
||||
<automated>test ! -f src/middleware.ts && echo "src/middleware.ts does NOT exist (correct)"</automated>
|
||||
<automated>npm run build 2>&1 | grep -v "warning" | grep -qi "error" && echo "BUILD ERRORS" || echo "TypeScript OK"</automated>
|
||||
</verify>
|
||||
<done>
|
||||
- src/proxy.ts guards /admin/* routes: unauthenticated requests redirect to /admin/login?callbackUrl=...
|
||||
- Export renamed from proxy to middleware (required by Next.js)
|
||||
- /admin/login and /api/auth/* are exempt from the session guard
|
||||
- /c/:path* token validation is unchanged
|
||||
- /admin/login page renders email+password form, calls signIn('credentials'), shows error on failure, redirects on success
|
||||
- src/middleware.ts does NOT exist
|
||||
- npm run build passes
|
||||
- Manual verification: visiting http://localhost:3000/admin redirects to /admin/login; successful login redirects back to /admin
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Browser → /admin/* | All admin routes gated by JWT session cookie; proxy.ts rejects unauthenticated requests before any page code runs |
|
||||
| Login form → CredentialsProvider | Email + password transmitted over HTTPS; validated in server-side authorize() only |
|
||||
| NEXTAUTH_SECRET → JWT signing | All session tokens are HMAC-signed; tampering is detectable |
|
||||
| ADMIN_EMAIL/ADMIN_PASSWORD → env vars | Credentials never in source code; must be in .env.local and Vercel environment |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-02-01 | Spoofing | Admin login | mitigate | CredentialsProvider validates against env vars server-side; password never logged; JWT signed with NEXTAUTH_SECRET |
|
||||
| T-02-02 | Tampering | JWT session cookie | mitigate | next-auth signs JWT with NEXTAUTH_SECRET (HMAC-SHA256); proxy.ts verifies signature on every /admin request via getToken() |
|
||||
| T-02-03 | Information Disclosure | ADMIN_PASSWORD in env | mitigate | Stored only in .env.local (gitignored) and Vercel environment secrets; never returned in API responses |
|
||||
| T-02-04 | Elevation of Privilege | /api/auth/* exemption | accept | NextAuth API routes are exempt from session guard by design; they perform their own CSRF and credential validation internally |
|
||||
| T-02-05 | Denial of Service | Brute-force login | accept | Single admin, not a public product; no rate limiting in v1. If needed in v2, add next-auth rate limit middleware. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
After plan execution:
|
||||
1. `npm run build` — no TypeScript errors
|
||||
2. `npm run dev`, visit http://localhost:3000/admin → redirects to /admin/login
|
||||
3. Submit wrong credentials → error message "Email o password non corretti." appears
|
||||
4. Submit correct ADMIN_EMAIL + ADMIN_PASSWORD → redirects to /admin (200, even if page is blank)
|
||||
5. Visit http://localhost:3000/c/any-token → still validates token (client path unchanged)
|
||||
6. Visit http://localhost:3000/api/auth/session after login → returns `{ user: { email, id: "admin" } }`
|
||||
7. Confirm src/middleware.ts does not exist in the repo
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Admin can log in at /admin/login with env-var credentials and receive a JWT session cookie
|
||||
- All /admin/* routes (except /admin/login and /api/auth/*) redirect unauthenticated visitors to /admin/login
|
||||
- Client token route /c/:path* is unaffected
|
||||
- No DB users table exists or is needed
|
||||
- src/proxy.ts is the middleware file — src/middleware.ts never created
|
||||
- npm run build passes cleanly
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/02-admin-area-interactive-features/02-01-SUMMARY.md`
|
||||
</output>
|
||||
Reference in New Issue
Block a user