diff --git a/frontend/components/ops/__tests__/opsLocalAccess.test.ts b/frontend/components/ops/__tests__/opsLocalAccess.test.ts index 4d50f7a0..b959faac 100644 --- a/frontend/components/ops/__tests__/opsLocalAccess.test.ts +++ b/frontend/components/ops/__tests__/opsLocalAccess.test.ts @@ -43,6 +43,19 @@ export function runTests() { "ops server page gate must honor localhost ops access before Supabase/admin-email redirects", ); + assert( + opsAdminSource.includes("verifyOpsAdminWithBackend") && + opsAdminSource.includes("POLYWEATHER_API_BASE_URL") && + opsAdminSource.includes("/api/ops/online-users") && + opsAdminSource.includes("Authorization") && + opsAdminSource.includes("BACKEND_ENTITLEMENT_HEADER") && + opsAdminSource.includes("await supabase.auth.getSession()") && + opsAdminSource.includes("if (allowedEmails.includes(email))") && + opsAdminSource.includes("if (await verifyOpsAdminWithBackend(accessToken))") && + !opsAdminSource.includes("if (!allowedEmails.length || !hasSupabaseServerEnv())"), + "ops admin page gate must fall back to the backend ops admin check when the frontend admin email env is missing or stale", + ); + assert( opsProxyAuthSource.includes("isLocalOpsAccessHost") && opsProxyAuthSource.includes("x-forwarded-host") && diff --git a/frontend/lib/ops-admin.ts b/frontend/lib/ops-admin.ts index c59fddd2..b12e5322 100644 --- a/frontend/lib/ops-admin.ts +++ b/frontend/lib/ops-admin.ts @@ -1,5 +1,6 @@ import { cookies, headers } from "next/headers"; import { redirect } from "next/navigation"; +import { BACKEND_ENTITLEMENT_HEADER } from "@/lib/backend-auth"; import { isLocalOpsAccessHost } from "@/lib/ops-local-access"; import { createSupabaseServerClient, @@ -7,6 +8,7 @@ import { hasSupabaseSessionCookieValues, } from "@/lib/supabase/server"; +const API_BASE = process.env.POLYWEATHER_API_BASE_URL; const LOCAL_DEV_OPS_EMAIL = "local-dev@polyweather.local"; function parseAdminEmails() { @@ -16,6 +18,30 @@ function parseAdminEmails() { .filter(Boolean); } +async function verifyOpsAdminWithBackend(accessToken: string) { + const token = String(accessToken || "").trim(); + if (!API_BASE || !token) return false; + + const headers = new Headers({ + Accept: "application/json", + Authorization: `Bearer ${token}`, + }); + const backendToken = process.env.POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN?.trim(); + if (backendToken) { + headers.set(BACKEND_ENTITLEMENT_HEADER, backendToken); + } + + try { + const response = await fetch(`${API_BASE.replace(/\/+$/, "")}/api/ops/online-users`, { + cache: "no-store", + headers, + }); + return response.ok; + } catch { + return false; + } +} + export async function requireOpsAdmin(nextPath = "/ops") { const headerStore = await headers(); const requestHost = @@ -25,7 +51,7 @@ export async function requireOpsAdmin(nextPath = "/ops") { } const allowedEmails = parseAdminEmails(); - if (!allowedEmails.length || !hasSupabaseServerEnv()) { + if (!hasSupabaseServerEnv()) { redirect("/"); } @@ -57,9 +83,18 @@ export async function requireOpsAdmin(nextPath = "/ops") { if (!email) { redirect(`/auth/login?next=${encodeURIComponent(nextPath)}`); } - if (!allowedEmails.includes(email)) { - redirect("/"); + + if (allowedEmails.includes(email)) { + return { email }; } - return { email }; + const { + data: { session }, + } = await supabase.auth.getSession(); + const accessToken = session?.access_token || ""; + if (await verifyOpsAdminWithBackend(accessToken)) { + return { email }; + } + + redirect("/"); }