fix(auth): port dev auto-login to Next.js 16 proxy.ts
Deploy to route.crispygoat.com / deploy (push) Successful in 3m8s

Next.js 16 renamed middleware.ts to proxy.ts and only allows one of the
two. Delete src/middleware.ts and rewrite src/proxy.ts to do the dev
auto-login + /login bounce + /admin gate explicitly (no NextAuth auth()
wrapper — auth.handler() returns a NextMiddleware taking (req, event),
which makes wrapping it awkward when we also need to set cookies on the
response).

Drop the now-dead 'authorized' callback from auth.config.ts (it was only
fired by the NextAuth wrapper, which we no longer use).

Migration 209: add a defensive unique constraint on admin_users.user_id
so the ON CONFLICT (user_id) clause in the new RPC resolves regardless
of whether the Supabase-dashboard-created table shipped with one.
This commit is contained in:
2026-06-06 21:52:19 +00:00
parent b63d0415ab
commit 1cecbce392
4 changed files with 101 additions and 127 deletions
-26
View File
@@ -44,32 +44,6 @@ export const authConfig = {
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
-86
View File
@@ -1,86 +0,0 @@
import { NextResponse, type NextRequest } from "next/server";
/**
* Middleware for /admin/* and /login routes.
*
* Recognises three auth sources at the edge:
* 1. Auth.js v5 JWT cookie (`authjs.session-token` or the `__Secure-`
* variant in prod) — set after Google sign-in.
* 2. `dev_session`, `rc_auth_uid`, `rc_uid` — legacy / dev cookies.
* 3. Demo / dev auto-login — when ALLOW_DEV_LOGIN is enabled (on by
* default in non-prod) and the user has no auth cookie, automatically
* issue `dev_session=platform_admin` so visiting /admin just works.
* No buttons, no demo page, no client-side cookie games.
*
* This is the single source of truth for "am I allowed in?" at the edge.
* The page-level `getAdminUser()` re-checks the same cookies (and reads
* the Auth.js JWT) to resolve the role.
*/
export function middleware(request: NextRequest) {
const { nextUrl } = request;
const isOnAdmin = nextUrl.pathname.startsWith("/admin");
const isOnLogin = nextUrl.pathname === "/login";
// Read auth cookies once.
const dev = request.cookies.get("dev_session")?.value;
const rcAuthUid = request.cookies.get("rc_auth_uid")?.value;
const rcUid = request.cookies.get("rc_uid")?.value;
// Auth.js v5 sets an encrypted JWT cookie. Names differ by transport:
// `authjs.session-token` (dev / HTTP) and `__Secure-authjs.session-token`
// (prod / HTTPS). The presence of either cookie is sufficient to
// consider the request authenticated at the edge — the page-level
// `getAdminUser()` re-reads the JWT and resolves the role.
const authJsSessionToken = request.cookies.get("authjs.session-token")?.value;
const authJsSecureToken = request.cookies.get("__Secure-authjs.session-token")?.value;
const hasDevSession =
dev === "platform_admin" ||
dev === "brand_admin" ||
dev === "store_employee";
const hasRealAuth = Boolean(rcAuthUid || rcUid);
const hasAuthJsSession = Boolean(authJsSessionToken || authJsSecureToken);
const isAuthenticated = hasDevSession || hasRealAuth || hasAuthJsSession;
// ── /admin/* ──────────────────────────────────────────────────────
if (isOnAdmin) {
if (isAuthenticated) {
return NextResponse.next();
}
// No auth cookie — try demo auto-login. ALLOW_DEV_LOGIN is on by
// default; set it to "false" in prod env to disable.
const allowDev = process.env.ALLOW_DEV_LOGIN !== "false";
if (allowDev) {
const response = NextResponse.next();
response.cookies.set("dev_session", "platform_admin", {
path: "/",
maxAge: 60 * 60 * 24, // 1 day
sameSite: "lax",
});
return response;
}
// No auth and dev disabled — bounce to /login.
const loginUrl = nextUrl.clone();
loginUrl.pathname = "/login";
loginUrl.search = "";
return NextResponse.redirect(loginUrl);
}
// ── /login ────────────────────────────────────────────────────────
if (isOnLogin) {
// If already authenticated, skip the login page.
if (isAuthenticated) {
const adminUrl = nextUrl.clone();
adminUrl.pathname = "/admin";
adminUrl.search = "";
return NextResponse.redirect(adminUrl);
}
return NextResponse.next();
}
return NextResponse.next();
}
export const config = {
matcher: ["/admin/:path*", "/login"],
};
+72 -15
View File
@@ -1,26 +1,83 @@
import NextAuth from "next-auth";
import { authConfig } from "@/auth.config";
import { NextResponse, type NextRequest } from "next/server";
/**
* 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.
* Routing policy:
* 1. `/admin/*` with no auth cookie + `ALLOW_DEV_LOGIN !== "false"`
* → set `dev_session=platform_admin` and let the request through.
* This makes `/admin` "just work" in dev/demo without any login
* UI gymnastics.
* 2. `/login` with an auth cookie (any flavour)
* → redirect to `/admin` so an authenticated user never sees the
* login form.
* 3. `/admin/*` (or `/protected-example`) with no auth cookie
* → redirect to `/login`.
* 4. Everything else → continue.
*
* Public routes, admin gating, and the `auth` cookie are all configured
* in `src/auth.config.ts`.
* Auth-cookie flavours recognised:
* - `dev_session` (dev auto-login, see above)
* - `rc_auth_uid` / `rc_uid` (legacy /api/login flow)
* - `authjs.session-token` / `__Secure-authjs.session-token` (Auth.js v5)
*
* The proxy only checks cookie *presence*. The real auth check (JWT
* signature decryption, admin role lookup) happens in
* `getAdminUser()` server-side. The proxy is just routing.
*/
const { auth } = NextAuth(authConfig);
export default auth;
function isAuthenticated(request: NextRequest): boolean {
const dev = request.cookies.get("dev_session")?.value;
if (
dev === "platform_admin" ||
dev === "brand_admin" ||
dev === "store_employee"
) {
return true;
}
if (request.cookies.get("rc_auth_uid")?.value) return true;
if (request.cookies.get("rc_uid")?.value) return true;
if (request.cookies.get("authjs.session-token")?.value) return true;
if (request.cookies.get("__Secure-authjs.session-token")?.value) return true;
return false;
}
export default function proxy(request: NextRequest) {
const { nextUrl } = request;
const isOnAdmin = nextUrl.pathname.startsWith("/admin");
const isOnProtectedExample = nextUrl.pathname.startsWith(
"/protected-example"
);
const isOnLogin = nextUrl.pathname === "/login";
const authenticated = isAuthenticated(request);
// ── 1. Dev auto-login for /admin/* ───────────────────────────────
if (isOnAdmin && !authenticated) {
const allowDev = process.env.ALLOW_DEV_LOGIN !== "false";
if (allowDev) {
const response = NextResponse.next();
response.cookies.set("dev_session", "platform_admin", {
path: "/",
maxAge: 60 * 60 * 24, // 1 day
sameSite: "lax",
});
return response;
}
}
// ── 2. Bounce authenticated users away from /login ───────────────
if (isOnLogin && isAuthenticated(request)) {
return NextResponse.redirect(new URL("/admin", nextUrl));
}
// ── 3. Gate protected routes ─────────────────────────────────────
if ((isOnAdmin || isOnProtectedExample) && !authenticated) {
return NextResponse.redirect(new URL("/login", nextUrl));
}
// ── 4. Everything else: continue ─────────────────────────────────
return NextResponse.next();
}
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"],
};