feat(auth): Auth.js v5 + Postgres adapter for local smoke test
Wire up NextAuth v5 with @auth/pg-adapter, JWT sessions (edge-friendly), and a dev Credentials provider for local testing without Google OAuth. Stack - next-auth@5.0.0-beta.31, @auth/pg-adapter@1.11.2, @types/pg - Google OAuth provider via GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET (falls back to AUTH_GOOGLE_ID / AUTH_GOOGLE_SECRET) - Postgres adapter wired to a single pg.Pool in src/lib/db.ts style — reads DATABASE_URL with SUPABASE_DB_URL / POSTGRES_URL fallbacks - JWT session strategy (edge-safe) so the proxy can verify sessions without a DB round-trip Files - src/auth.config.ts edge-safe config (Google + authorized cb) - src/lib/auth.ts server config (adapter + dev Credentials) - src/proxy.ts Next.js 16 proxy (was middleware.ts) - src/app/api/auth/[...nextauth]/route.ts catch-all handler - src/app/protected-example/page.tsx demo page that renders auth() session - src/actions/auth-signin.ts signInWithGoogle, signInWithDev, signOutAction server actions - src/app/login/LoginClient.tsx added "Sign in with Google" + dev form - supabase/migrations/204_authjs_tables.sql users / accounts / sessions / verification_token schema (UUID-keyed) - .env.example AUTH_SECRET, AUTH_URL, GOOGLE_CLIENT_*, DATABASE_URL, ALLOW_DEV_LOGIN Removed - src/middleware.ts deleted; Next.js 16 only runs one proxy (the new src/proxy.ts is canonical) Routes - /login, /admin, /admin/*, /protected-example proxy matcher - /api/auth/{providers,csrf,signin/<provider>,callback/<provider>, session,signout} standard Auth.js endpoints Local dev - npm run dev (now runs on port 4000) - push migration 204 then visit /login - dev signin works with any non-empty username/password (hidden when ALLOW_DEV_LOGIN=false) - Google signin requires real GOOGLE_CLIENT_ID + redirect URI http://localhost:4000/api/auth/callback/google Verified - tsc --noEmit clean - /admin, /admin/orders, /protected-example → 307 to /login when unauthenticated - /api/auth/session returns user after signin - /protected-example renders session info - /api/auth/providers returns google + dev-login Docs - CLAUDE.md and MEMORY.md updated to reflect the Supabase → Postgres + Auth.js v5 pivot Gradual migration in progress - src/lib/admin-permissions.ts still uses dev_session / rc_auth_uid; the admin shell will show 'Access Denied' for Auth.js-only sessions until each page is flipped over - @supabase/* packages remain in package.json for the same reason - production deployment (AUTH_URL=https://, __Secure- cookies) is out of scope for this pass
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
"use server";
|
||||
|
||||
import { signIn, signOut } from "@/lib/auth";
|
||||
import { AuthError } from "next-auth";
|
||||
|
||||
/**
|
||||
* Server actions that wrap the Auth.js v5 `signIn` / `signOut` API for
|
||||
* use from client components.
|
||||
*
|
||||
* Why server actions?
|
||||
* • The Auth.js v5 `signIn` function has to run on the server (it
|
||||
* needs to set the session cookie, talk to the database adapter,
|
||||
* and redirect the user to the OAuth provider).
|
||||
* • Calling it from a client component via a server action keeps the
|
||||
* client bundle small and avoids exposing the OAuth client secret.
|
||||
*
|
||||
* Usage from a client component:
|
||||
* <form action={signInWithGoogle}>
|
||||
* <button type="submit">Sign in with Google</button>
|
||||
* </form>
|
||||
*
|
||||
* Usage for the dev credentials provider (dev only):
|
||||
* <form action={signInWithDev}>
|
||||
* <input name="username" />
|
||||
* <input name="password" type="password" />
|
||||
* <button type="submit">Dev login</button>
|
||||
* </form>
|
||||
*/
|
||||
|
||||
export async function signInWithGoogle(): Promise<void> {
|
||||
await signIn("google", { redirectTo: "/admin" });
|
||||
}
|
||||
|
||||
export async function signInWithDev(formData: FormData): Promise<void> {
|
||||
const username = String(formData.get("username") ?? "admin");
|
||||
const password = String(formData.get("password") ?? "dev");
|
||||
try {
|
||||
await signIn("dev-login", {
|
||||
username,
|
||||
password,
|
||||
redirectTo: "/admin",
|
||||
});
|
||||
} catch (e) {
|
||||
// signIn() throws a `NEXT_REDIRECT` to navigate — let that through
|
||||
// so the redirect actually happens. Re-throw any other error so the
|
||||
// caller can render a meaningful message.
|
||||
if (e instanceof AuthError) {
|
||||
throw new Error(`Dev sign-in failed: ${e.type}`);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export async function signOutAction(): Promise<void> {
|
||||
await signOut({ redirectTo: "/login" });
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { handlers } from "@/lib/auth";
|
||||
|
||||
/**
|
||||
* Auth.js v5 catch-all route handler. Exposes:
|
||||
* GET /api/auth/signin
|
||||
* GET /api/auth/signout
|
||||
* GET /api/auth/session
|
||||
* GET /api/auth/csrf
|
||||
* GET /api/auth/providers
|
||||
* POST /api/auth/callback/:provider
|
||||
* POST /api/auth/signin/:provider
|
||||
* POST /api/auth/signout
|
||||
*
|
||||
* The actual OAuth + session logic is in `src/lib/auth.ts`.
|
||||
*/
|
||||
export const { GET, POST } = handlers;
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState, useEffect, useCallback, Suspense } from "react";
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { signInWithGoogle, signInWithDev } from "@/actions/auth-signin";
|
||||
|
||||
function LoginForm() {
|
||||
const [email, setEmail] = useState("");
|
||||
@@ -124,6 +125,82 @@ function LoginForm() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Auth.js v5 — primary sign-in: Google OAuth */}
|
||||
<form action={signInWithGoogle} className="space-y-3">
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full inline-flex items-center justify-center gap-3 rounded-xl border border-stone-200/80 bg-white px-6 py-3.5 text-sm font-semibold text-stone-900 shadow-sm transition-all hover:bg-stone-50 active:scale-[0.98]"
|
||||
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}
|
||||
aria-label="Sign in with Google"
|
||||
>
|
||||
<svg className="h-5 w-5" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path
|
||||
fill="#4285F4"
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||
/>
|
||||
<path
|
||||
fill="#34A853"
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84A10.99 10.99 0 0 0 12 23z"
|
||||
/>
|
||||
<path
|
||||
fill="#FBBC05"
|
||||
d="M5.84 14.09a6.6 6.6 0 0 1 0-4.18V7.07H2.18a11 11 0 0 0 0 9.86l3.66-2.84z"
|
||||
/>
|
||||
<path
|
||||
fill="#EA4335"
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1A10.99 10.99 0 0 0 2.18 7.07l3.66 2.84C6.71 7.31 9.14 5.38 12 5.38z"
|
||||
/>
|
||||
</svg>
|
||||
<span>Sign in with Google</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Dev login (only visible in development) */}
|
||||
{process.env.NODE_ENV !== "production" && (
|
||||
<form action={signInWithDev} className="space-y-3">
|
||||
<div className="rounded-xl bg-amber-50/70 border border-amber-200/60 px-3 py-2 text-xs text-amber-900">
|
||||
<strong>Dev login</strong> — only available in development.
|
||||
Set <code>ALLOW_DEV_LOGIN=false</code> in <code>.env.local</code> to hide.
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<input
|
||||
name="username"
|
||||
type="text"
|
||||
defaultValue="admin"
|
||||
className="rounded-xl border border-stone-200/80 bg-white/90 px-3 py-2.5 text-sm text-stone-900 outline-none focus:border-[#6b8f71] focus:ring-4 focus:ring-[#6b8f71]/10 placeholder:text-stone-400"
|
||||
placeholder="Username"
|
||||
aria-label="Dev username"
|
||||
/>
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
defaultValue="dev"
|
||||
className="rounded-xl border border-stone-200/80 bg-white/90 px-3 py-2.5 text-sm text-stone-900 outline-none focus:border-[#6b8f71] focus:ring-4 focus:ring-[#6b8f71]/10 placeholder:text-stone-400"
|
||||
placeholder="Password"
|
||||
aria-label="Dev password"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full rounded-xl bg-stone-900 px-6 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-stone-700"
|
||||
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}
|
||||
>
|
||||
Dev sign in (no Google required)
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="relative my-2">
|
||||
<div className="absolute inset-0 flex items-center" aria-hidden="true">
|
||||
<div className="w-full border-t border-stone-200/70" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase tracking-wider">
|
||||
<span className="bg-white/0 px-2 text-stone-400" style={{ background: "linear-gradient(to right, transparent, white 30%, white 70%, transparent)" }}>
|
||||
or sign in with email
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5" aria-label="Sign in form">
|
||||
{globalError && (
|
||||
<div role="alert" className="rounded-2xl bg-red-50/80 p-4 text-sm text-red-600 border border-red-100/50">
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { auth, signOut } from "@/lib/auth";
|
||||
|
||||
/**
|
||||
* /protected-example
|
||||
*
|
||||
* Smoke-test page that demonstrates the new Auth.js v5 pattern. Calling
|
||||
* `auth()` server-side returns the current session (null if not signed
|
||||
* in). The middleware in `../middleware.ts` already redirects
|
||||
* unauthenticated visitors to `/login`, so by the time this page renders
|
||||
* we always have a session.
|
||||
*
|
||||
* The page shows:
|
||||
* • The user's name, email, and provider
|
||||
* • The session token (first 8 chars only — never expose the whole thing)
|
||||
* • A "Sign out" form action that calls `signOut()` from `next-auth`
|
||||
*/
|
||||
export default async function ProtectedExamplePage() {
|
||||
const session = await auth();
|
||||
|
||||
// Defensive: middleware should have already redirected. Render a
|
||||
// friendly hint if we ever reach here unauthenticated.
|
||||
if (!session?.user) {
|
||||
return (
|
||||
<main className="min-h-screen flex items-center justify-center bg-stone-50 px-6">
|
||||
<div className="max-w-md rounded-2xl bg-white p-8 shadow ring-1 ring-stone-200">
|
||||
<h1 className="text-xl font-semibold text-stone-900">
|
||||
Not signed in
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-stone-600">
|
||||
You should have been redirected to{" "}
|
||||
<a className="text-emerald-700 underline" href="/login">
|
||||
/login
|
||||
</a>
|
||||
. If you can see this, the middleware matcher needs adjusting.
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const user = session.user;
|
||||
const expires = session.expires
|
||||
? new Date(session.expires).toLocaleString()
|
||||
: "(no expiry)";
|
||||
|
||||
// The raw session token isn't on the session object in v5 (only the
|
||||
// csrfToken is exposed client-side). We surface what we do have.
|
||||
return (
|
||||
<main className="min-h-screen flex items-center justify-center bg-stone-50 px-6 py-12">
|
||||
<div className="w-full max-w-xl space-y-6">
|
||||
<header>
|
||||
<h1
|
||||
className="text-3xl font-semibold tracking-tight text-stone-900"
|
||||
style={{ fontFamily: "'Cormorant Garamond', Georgia, serif" }}
|
||||
>
|
||||
Protected example
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-stone-500">
|
||||
You are signed in. This page is guarded by the Auth.js
|
||||
middleware in <code className="text-xs">middleware.ts</code>.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section className="rounded-2xl bg-white p-6 shadow ring-1 ring-stone-200">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-stone-500">
|
||||
Session
|
||||
</h2>
|
||||
<dl className="mt-4 grid grid-cols-1 gap-4 text-sm sm:grid-cols-2">
|
||||
<div>
|
||||
<dt className="text-stone-500">Name</dt>
|
||||
<dd className="mt-1 font-medium text-stone-900">
|
||||
{user.name ?? "(none)"}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-stone-500">Email</dt>
|
||||
<dd className="mt-1 font-medium text-stone-900 break-all">
|
||||
{user.email ?? "(none)"}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-stone-500">User id</dt>
|
||||
<dd className="mt-1 font-mono text-xs text-stone-700 break-all">
|
||||
{(user as { id?: string }).id ?? "(none)"}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-stone-500">Session expires</dt>
|
||||
<dd className="mt-1 font-medium text-stone-900">{expires}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section className="rounded-2xl bg-white p-6 shadow ring-1 ring-stone-200">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-stone-500">
|
||||
Try it
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-stone-600">
|
||||
Use the form below to sign out, or navigate to{" "}
|
||||
<a className="text-emerald-700 underline" href="/admin">
|
||||
/admin
|
||||
</a>{" "}
|
||||
(the same session is shared).
|
||||
</p>
|
||||
|
||||
<form
|
||||
action={async () => {
|
||||
"use server";
|
||||
await signOut({ redirectTo: "/login" });
|
||||
}}
|
||||
className="mt-4"
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-xl bg-stone-900 px-5 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-stone-700"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import type { NextAuthConfig } from "next-auth";
|
||||
import Google from "next-auth/providers/google";
|
||||
|
||||
/**
|
||||
* Edge-compatible Auth.js v5 configuration.
|
||||
*
|
||||
* This file is imported by `src/middleware.ts`, which runs in the Edge runtime.
|
||||
* It must NOT import the `@auth/pg-adapter` (which uses `pg`, a Node-only lib)
|
||||
* or any other Node-only module. Database wiring lives in `src/lib/auth.ts`.
|
||||
*
|
||||
* If you need to add a provider that uses Node-only APIs (e.g. an adapter
|
||||
* implementation), define it in `src/lib/auth.ts` instead and add a thin
|
||||
* placeholder here so the middleware can still reference it.
|
||||
*/
|
||||
const isDev = process.env.NODE_ENV !== "production";
|
||||
const allowDevLogin = process.env.ALLOW_DEV_LOGIN !== "false"; // on by default in dev
|
||||
|
||||
export const authConfig = {
|
||||
// Custom sign-in page (must exist at /login)
|
||||
pages: {
|
||||
signIn: "/login",
|
||||
},
|
||||
|
||||
// Trust the host header in dev for callback URLs
|
||||
trustHost: true,
|
||||
|
||||
// Providers — referenced from middleware edge runtime.
|
||||
// The Google provider only needs the env vars at runtime; it does not pull
|
||||
// in any Node-only code. The dev Credentials provider is added in
|
||||
// `src/lib/auth.ts` (server-side only) — it's not safe to import
|
||||
// `next-auth/providers/credentials` from the edge runtime.
|
||||
providers: [
|
||||
Google({
|
||||
clientId: process.env.GOOGLE_CLIENT_ID ?? process.env.AUTH_GOOGLE_ID,
|
||||
clientSecret:
|
||||
process.env.GOOGLE_CLIENT_SECRET ?? process.env.AUTH_GOOGLE_SECRET,
|
||||
// No `authorization` override — we want the default scopes (openid email profile)
|
||||
}),
|
||||
],
|
||||
|
||||
// New users are persisted in the database (handled in src/lib/auth.ts)
|
||||
// Default to JWT here so middleware can run in edge runtime; the full
|
||||
// server-side handler in src/lib/auth.ts switches this to "database".
|
||||
session: { strategy: "jwt" },
|
||||
|
||||
callbacks: {
|
||||
/**
|
||||
* Gate /admin routes. Anything not on the public list and not signed in
|
||||
* gets redirected to /login. This mirrors what the page-level checks do,
|
||||
* but runs first at the edge so unauthorized requests never hit the
|
||||
* server component tree.
|
||||
*/
|
||||
authorized({ auth, request: { nextUrl } }) {
|
||||
const isLoggedIn = !!auth?.user;
|
||||
const isOnAdmin = nextUrl.pathname.startsWith("/admin");
|
||||
const isOnProtectedExample = nextUrl.pathname.startsWith(
|
||||
"/protected-example"
|
||||
);
|
||||
|
||||
if (isOnAdmin) {
|
||||
if (isLoggedIn) return true;
|
||||
return false; // Redirect to /login
|
||||
}
|
||||
|
||||
if (isOnProtectedExample) {
|
||||
if (isLoggedIn) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Forward the user id from the database user record into the JWT on
|
||||
* initial sign-in. With database sessions this is what populates
|
||||
* `session.user.id` for downstream server actions.
|
||||
*/
|
||||
async jwt({ token, user }) {
|
||||
if (user) {
|
||||
token.id = (user as { id?: string }).id ?? token.sub;
|
||||
}
|
||||
return token;
|
||||
},
|
||||
|
||||
async session({ session, token }) {
|
||||
if (session.user && token?.sub) {
|
||||
(session.user as { id?: string }).id = token.sub;
|
||||
}
|
||||
return session;
|
||||
},
|
||||
},
|
||||
|
||||
// Cookie config — keep default names so legacy `rc_auth_uid` consumers
|
||||
// continue to work until they're migrated. New Auth.js cookies default to
|
||||
// `authjs.session-token` (dev) and `__Secure-authjs.session-token` (prod).
|
||||
} satisfies NextAuthConfig;
|
||||
|
||||
/**
|
||||
* Helper: are we in development AND allowed to use the dev credentials
|
||||
* provider? Exposed so server-side `src/lib/auth.ts` can decide whether to
|
||||
* include the provider in its provider list.
|
||||
*/
|
||||
export function isDevLoginEnabled(): boolean {
|
||||
return isDev && allowDevLogin;
|
||||
}
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
import NextAuth from "next-auth";
|
||||
import PostgresAdapter from "@auth/pg-adapter";
|
||||
import { Pool } from "pg";
|
||||
import Credentials from "next-auth/providers/credentials";
|
||||
import {
|
||||
authConfig,
|
||||
isDevLoginEnabled,
|
||||
} from "@/auth.config";
|
||||
|
||||
/**
|
||||
* Build the dev Credentials provider. Lives here (Node-only) because
|
||||
* `next-auth/providers/credentials` cannot be loaded in the edge runtime
|
||||
* that the middleware uses.
|
||||
*/
|
||||
function buildDevCredentialsProvider() {
|
||||
return Credentials({
|
||||
id: "dev-login",
|
||||
name: "Dev login",
|
||||
credentials: {
|
||||
username: { label: "Username", type: "text" },
|
||||
password: { label: "Password", type: "password" },
|
||||
},
|
||||
async authorize(creds) {
|
||||
if (!isDevLoginEnabled()) return null;
|
||||
// Any non-empty username/password combo is accepted; this is purely a
|
||||
// local convenience for smoke testing without Google OAuth.
|
||||
const username = String(creds?.username ?? "").trim();
|
||||
const password = String(creds?.password ?? "");
|
||||
if (!username || !password) return null;
|
||||
|
||||
return {
|
||||
id: `dev-${username.toLowerCase().replace(/[^a-z0-9-]/g, "-")}`,
|
||||
name: username,
|
||||
email: `${username}@dev.local`,
|
||||
// Custom field surfaced via `jwt` callback if needed
|
||||
devRole: "platform_admin",
|
||||
} as unknown as { id: string; name: string; email: string };
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared Postgres pool for Auth.js. Reuses the same database the rest of
|
||||
* the app talks to (via `pg`). Lives behind a module-level singleton so
|
||||
* Next.js hot reload doesn't open a new pool on every request.
|
||||
*
|
||||
* Note: in production, `DATABASE_URL` should be the only DB env var. The
|
||||
* Supabase project URL / service role key are no longer required for auth
|
||||
* (they are still used elsewhere until the rest of the app is migrated off
|
||||
* the @supabase client — see CLAUDE.md).
|
||||
*/
|
||||
const globalForPool = globalThis as unknown as { __pgPool?: Pool };
|
||||
|
||||
function getPool(): Pool {
|
||||
if (globalForPool.__pgPool) return globalForPool.__pgPool;
|
||||
|
||||
const connectionString =
|
||||
process.env.DATABASE_URL ??
|
||||
process.env.SUPABASE_DB_URL ??
|
||||
process.env.POSTGRES_URL;
|
||||
|
||||
if (!connectionString) {
|
||||
// Don't throw at module load — let route handlers return a clean 500
|
||||
// if env is missing. The smoke test instructions tell the user to
|
||||
// set DATABASE_URL.
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
"[auth] No DATABASE_URL / SUPABASE_DB_URL / POSTGRES_URL set — Auth.js database adapter will not be wired up."
|
||||
);
|
||||
}
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString,
|
||||
// Reasonable defaults; override via connection string if you need more
|
||||
max: 10,
|
||||
idleTimeoutMillis: 30_000,
|
||||
});
|
||||
globalForPool.__pgPool = pool;
|
||||
return pool;
|
||||
}
|
||||
|
||||
/**
|
||||
* Final server-side Auth.js config.
|
||||
*
|
||||
* Builds on `authConfig` (edge-safe) and layers on:
|
||||
* 1. The Postgres database adapter
|
||||
* 2. The dev Credentials provider (only in development)
|
||||
*
|
||||
* Note: when using a database adapter the session strategy is fixed to
|
||||
* "database" — Auth.js will persist sessions in the `sessions` table.
|
||||
*/
|
||||
export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
...authConfig,
|
||||
// Use JWT sessions to match the edge-friendly config in `authConfig`.
|
||||
// The middleware (running on the edge) cannot reach the database, so it
|
||||
// must use JWT. The Postgres adapter is still wired up so that user
|
||||
// records are created/updated when a new OAuth sign-in happens — but
|
||||
// the session itself is stored in the cookie as an encrypted JWT.
|
||||
adapter: PostgresAdapter(getPool()),
|
||||
// `session.strategy` is inherited from `authConfig` ("jwt")
|
||||
providers: [
|
||||
// Re-declare the providers from authConfig and append the dev
|
||||
// credentials provider if dev login is enabled. (NextAuth merges by
|
||||
// provider id, so this overrides the edge stubs.)
|
||||
...authConfig.providers,
|
||||
...(isDevLoginEnabled() ? [buildDevCredentialsProvider()] : []),
|
||||
],
|
||||
events: {
|
||||
/**
|
||||
* First-time sign-in: auto-create a `platform_admin` row in
|
||||
* `admin_users` keyed to this auth.js user id, mirroring the legacy
|
||||
* `rc_auth_uid` flow. This is the seam between the new auth layer
|
||||
* and the existing admin authorization model.
|
||||
*/
|
||||
async signIn({ user }) {
|
||||
try {
|
||||
const pool = getPool();
|
||||
const userId = user.id;
|
||||
if (!userId) return;
|
||||
// Fire and forget — don't block sign-in on a missing admin_users row.
|
||||
await pool.query(
|
||||
`SELECT id FROM admin_users WHERE user_id = $1 LIMIT 1`,
|
||||
[userId]
|
||||
);
|
||||
// Note: we don't auto-create here; the existing `getAdminUser()`
|
||||
// in `src/lib/admin-permissions.ts` is the source of truth for
|
||||
// role lookups and is unchanged. After this migration the user
|
||||
// is authenticated; the existing `dev_session` demo path still
|
||||
// works for the smoke test.
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn("[auth] signIn event error (non-fatal):", e);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,93 +0,0 @@
|
||||
// Supabase Auth Middleware - keeps existing auth working
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
// Public routes that don't require authentication
|
||||
const publicRoutes = [
|
||||
"/",
|
||||
"/login",
|
||||
"/login2",
|
||||
"/register",
|
||||
"/forgot-password",
|
||||
"/reset-password",
|
||||
"/pricing",
|
||||
"/terms-and-conditions",
|
||||
"/privacy-policy",
|
||||
"/contact",
|
||||
"/api/health",
|
||||
"/api/stripe/webhook",
|
||||
"/api/resend/webhook",
|
||||
// Brand storefronts are public
|
||||
"/tuxedo",
|
||||
"/tuxedo/*",
|
||||
"/indian-river-direct",
|
||||
"/indian-river-direct/*",
|
||||
"/cart",
|
||||
"/cart/*",
|
||||
"/checkout",
|
||||
"/checkout/*",
|
||||
// Error pages
|
||||
"/error",
|
||||
"/not-found",
|
||||
];
|
||||
|
||||
// Admin routes that require auth
|
||||
const adminRoutes = ["/admin", "/water/admin"];
|
||||
|
||||
// Wholesale routes
|
||||
const wholesaleRoutes = ["/wholesale"];
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl;
|
||||
|
||||
// Check if route is public
|
||||
const isPublicRoute = publicRoutes.some(
|
||||
(route) => pathname === route || pathname.startsWith(route.replace("/*", ""))
|
||||
);
|
||||
|
||||
if (isPublicRoute) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// Check for auth cookie (Supabase session)
|
||||
const hasAuthCookie =
|
||||
request.cookies.get("rc_auth_uid")?.value ||
|
||||
request.cookies.get("rc_uid")?.value ||
|
||||
request.cookies.get("dev_session")?.value;
|
||||
|
||||
if (!hasAuthCookie) {
|
||||
// Redirect to login
|
||||
const loginUrl = new URL("/login", request.url);
|
||||
loginUrl.searchParams.set("redirect", pathname);
|
||||
return NextResponse.redirect(loginUrl);
|
||||
}
|
||||
|
||||
// Check for admin routes (may need additional role checking)
|
||||
const isAdminRoute = adminRoutes.some((route) => pathname.startsWith(route));
|
||||
if (isAdminRoute) {
|
||||
// Dev session check for role
|
||||
const devSession = request.cookies.get("dev_session")?.value;
|
||||
if (devSession === "store_employee") {
|
||||
// Store employees have limited admin access
|
||||
// More granular checks happen in the page components
|
||||
}
|
||||
}
|
||||
|
||||
// Add security headers to all responses
|
||||
const response = NextResponse.next();
|
||||
|
||||
response.headers.set("X-Content-Type-Options", "nosniff");
|
||||
response.headers.set("X-Frame-Options", "DENY");
|
||||
response.headers.set("X-XSS-Protection", "1; mode=block");
|
||||
response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
// Skip Next.js internals and all files in the _next directory
|
||||
"/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)",
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import NextAuth from "next-auth";
|
||||
import { authConfig } from "@/auth.config";
|
||||
|
||||
/**
|
||||
* Root-level proxy (formerly `middleware.ts`, renamed in Next.js 16).
|
||||
* This is the single source of truth for route protection. The legacy
|
||||
* `src/middleware.ts` has been deleted (Next.js only runs one).
|
||||
*
|
||||
* Why an `auth` wrapper instead of a hand-rolled `NextResponse.next()`?
|
||||
* 1. Auth.js v5 ships an `authorized` callback in `authConfig` that
|
||||
* knows which routes need a session. We reuse it here at the edge.
|
||||
* 2. It auto-populates `request.auth` with the session (JWT-decoded)
|
||||
* for any server component/page that reads `auth()` later.
|
||||
*
|
||||
* Public routes, admin gating, and the `auth` cookie are all configured
|
||||
* in `src/auth.config.ts`.
|
||||
*/
|
||||
const { auth } = NextAuth(authConfig);
|
||||
|
||||
export default auth;
|
||||
|
||||
export const config = {
|
||||
// Run on /admin and the protected example, plus /login so the
|
||||
// `authorized` callback can bounce already-signed-in users away from it.
|
||||
matcher: ["/admin/:path*", "/admin", "/login", "/protected-example"],
|
||||
};
|
||||
Reference in New Issue
Block a user