Spaces:
Running
Running
| // Server-side route protection — login-first. | |
| // | |
| // In Next.js 16 this file is `proxy.ts` (renamed from `middleware.ts` in 16) | |
| // and the exported function must be named `proxy`. | |
| // | |
| // GATE MODEL | |
| // ---------- | |
| // Every page requires a signed-in user OR a guest cookie. Unauthenticated | |
| // visitors are funneled to /login. The auth pages themselves (/login, | |
| // /forgot-password, /reset-password) stay reachable while signed out, otherwise | |
| // you could never get in. | |
| // | |
| // The guest cookie is the judge bypass (see app/lib/guest.ts). It is a UX | |
| // funnel, not security: this proxy decides which PAGE renders, while the data | |
| // itself is authorized separately by JWT verification in the FastAPI backend | |
| // (whatsapp_bot/auth.py). A forged cookie gets someone to the map, which shows | |
| // public government data anyway, and no further. | |
| // | |
| // Per the Next.js docs, proxy is for optimistic checks, not authorization — | |
| // which is exactly this split: routing funnel here, real enforcement at the | |
| // data layer. | |
| import { createServerClient } from "@supabase/ssr"; | |
| import { NextResponse, type NextRequest } from "next/server"; | |
| const GUEST_COOKIE = "agriflow_guest"; // keep in sync with app/lib/guest.ts | |
| const DEV_COOKIE = "agriflow_dev"; // keep in sync with app/lib/devauth.ts | |
| // Pages that must stay reachable while signed out. | |
| const PUBLIC_PREFIXES = ["/login", "/forgot-password", "/reset-password"]; | |
| export async function proxy(request: NextRequest) { | |
| const path = request.nextUrl.pathname; | |
| const isPublic = PUBLIC_PREFIXES.some((p) => path.startsWith(p)); | |
| const hasGuest = request.cookies.get(GUEST_COOKIE)?.value === "1"; | |
| // Dev-login cookie (see app/lib/devauth.ts). Only honoured when dev login is | |
| // explicitly enabled, so production ignores it even if someone sets it by | |
| // hand — the guest cookie is the only intended bypass there. Like guest, it | |
| // is a page-routing bypass only and never satisfies the backend's JWT check. | |
| const hasDev = | |
| process.env.NEXT_PUBLIC_DEV_LOGIN === "true" && | |
| Boolean(request.cookies.get(DEV_COOKIE)?.value); | |
| const url = process.env.NEXT_PUBLIC_SUPABASE_URL; | |
| const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; | |
| let response = NextResponse.next({ request }); | |
| let user = null; | |
| // Resolve the real session when Supabase is configured. Wrapped in try/catch | |
| // because a misconfigured or unreachable Supabase URL (e.g. placeholder creds | |
| // in local preview) would otherwise throw and 500 every page. On any failure | |
| // we treat the visitor as signed out and let the guest cookie be the way in. | |
| if (url && anonKey) { | |
| try { | |
| const supabase = createServerClient(url, anonKey, { | |
| cookies: { | |
| getAll() { | |
| return request.cookies.getAll(); | |
| }, | |
| setAll(cookiesToSet) { | |
| // Write refreshed tokens onto both the request (so any later read | |
| // this pass sees them) and the response (so the browser stores | |
| // them). Skipping the request copy logs the user out one navigation | |
| // after a token refresh. | |
| cookiesToSet.forEach(({ name, value }) => | |
| request.cookies.set(name, value), | |
| ); | |
| response = NextResponse.next({ request }); | |
| cookiesToSet.forEach(({ name, value, options }) => | |
| response.cookies.set(name, value, options), | |
| ); | |
| }, | |
| }, | |
| }); | |
| // getUser() revalidates the token with Supabase rather than trusting the | |
| // cookie contents, and refreshes it near expiry. | |
| user = (await supabase.auth.getUser()).data.user; | |
| } catch { | |
| user = null; | |
| } | |
| } | |
| const authed = Boolean(user) || hasGuest || hasDev; | |
| // A genuinely signed-in user has no reason to see the login page. | |
| // Guests are NOT redirected away from /login, so they can upgrade to a real | |
| // account whenever they want. | |
| if (path.startsWith("/login") && user) { | |
| return NextResponse.redirect(new URL("/", request.url)); | |
| } | |
| // Auth pages are always reachable. | |
| if (isPublic) return response; | |
| // Everything else is gated. | |
| if (!authed) { | |
| const redirect = request.nextUrl.clone(); | |
| redirect.pathname = "/login"; | |
| // Preserve the destination so login can return them there. LoginForm only | |
| // honours relative paths, so this cannot become an open redirect. | |
| redirect.searchParams.set("next", path); | |
| return NextResponse.redirect(redirect); | |
| } | |
| return response; | |
| } | |
| export const config = { | |
| // Skip static assets and image optimization — gating a .svg on a Supabase | |
| // round trip would add one to every asset request. | |
| matcher: [ | |
| "/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)", | |
| ], | |
| }; | |