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

19 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
02-admin-area-interactive-features 01 execute 1
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
true
ADMIN-01
ADMIN-02
truths artifacts key_links
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
path provides contains
src/lib/auth.ts NextAuth config — CredentialsProvider validating against ADMIN_EMAIL/ADMIN_PASSWORD env vars CredentialsProvider
path provides contains
src/app/api/auth/[...nextauth]/route.ts NextAuth catch-all route handler NextAuth
path provides min_lines
src/app/admin/login/page.tsx Admin login form UI (email + password, submit) 30
path provides contains
src/proxy.ts Updated proxy: /c/* token validation + /admin/* session guard getToken
from to via pattern
src/proxy.ts src/app/api/auth/[...nextauth]/route.ts getToken({ req, secret: process.env.NEXTAUTH_SECRET }) getToken
from to via pattern
src/app/admin/login/page.tsx /api/auth/callback/credentials signIn('credentials', { email, password }) signIn
from to via pattern
ADMIN_EMAIL + ADMIN_PASSWORD CredentialsProvider authorize() process.env.ADMIN_EMAIL ADMIN_EMAIL
**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.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>

@.planning/ROADMAP.md @.planning/phases/02-admin-area-interactive-features/02-CONTEXT.md @.planning/phases/01-foundation-client-dashboard/01-05-SUMMARY.md

Current src/proxy.ts content:

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:

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):

// No schema changes needed — no users table. Auth is env-var only.
export type Client = typeof clients.$inferSelect;
Task 1: Install next-auth@4, create src/lib/auth.ts and NextAuth catch-all route package.json src/lib/auth.ts src/app/api/auth/[...nextauth]/route.ts .env.local 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).
grep -q '"next-auth"' package.json && echo "next-auth installed" test -f src/lib/auth.ts && grep -q "CredentialsProvider" src/lib/auth.ts && echo "CredentialsProvider configured" grep -q "strategy.*jwt" src/lib/auth.ts && echo "JWT session strategy set" grep -q "ADMIN_EMAIL" src/lib/auth.ts && echo "ADMIN_EMAIL env var referenced" test -f src/app/api/auth/\[...nextauth\]/route.ts && grep -q "NextAuth" src/app/api/auth/\[...nextauth\]/route.ts && echo "NextAuth route created" grep -q "NEXTAUTH_SECRET" .env.local && echo "NEXTAUTH_SECRET in .env.local" npm run build 2>&1 | grep -v "warning" | grep -qi "error" && echo "BUILD ERRORS" || echo "TypeScript OK" - 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 Task 2: Extend src/proxy.ts to guard /admin/* with session check; create /admin/login page src/proxy.ts src/app/admin/login/page.tsx src/app/admin/login/actions.ts **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.
test -f src/proxy.ts && grep -q "getToken" src/proxy.ts && echo "getToken imported in proxy.ts" grep -q "export async function middleware" src/proxy.ts && echo "export named middleware (not proxy)" grep -q '"/admin/:path\*"' src/proxy.ts && echo "admin matcher configured" grep -q '"/c/:path\*"' src/proxy.ts && echo "client matcher still present" grep -q "pathname === \"/admin/login\"" src/proxy.ts && echo "login page exempted from auth guard" test -f src/app/admin/login/page.tsx && grep -q "signIn" src/app/admin/login/page.tsx && echo "login page uses signIn" grep -q '"use client"' src/app/admin/login/page.tsx && echo "login page is Client Component" test ! -f src/middleware.ts && echo "src/middleware.ts does NOT exist (correct)" npm run build 2>&1 | grep -v "warning" | grep -qi "error" && echo "BUILD ERRORS" || echo "TypeScript OK" - 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

<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>
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

<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>
After completion, create `.planning/phases/02-admin-area-interactive-features/02-01-SUMMARY.md`