From d312783f3a1d264f173f16098b8b28cf7777f5ca Mon Sep 17 00:00:00 2001 From: openclaw Date: Tue, 9 Jun 2026 15:30:23 -0600 Subject: [PATCH] fix: admin auth for prod Neon Auth deployment - getAdminUser now properly supports platform_admin with 0 brand links and loads full brand_ids - createAdminUser action now inserts into admin_user_brands join table - Admin layout surfaces the signed-in email on Access Denied - AdminAccessDenied links to /login instead of dead-end /admin - Main dashboard uses direct pool query instead of dead supabase shim - Improved provision-admin.ts script for prod bootstrap (loads .env.production too) --- scripts/provision-admin.ts | 27 ++++++----- src/actions/admin/users.ts | 19 ++++++++ src/app/admin/layout.tsx | 17 ++++++- src/app/admin/page.tsx | 19 ++++---- src/components/admin/AdminAccessDenied.tsx | 10 ++++- src/lib/admin-permissions.ts | 52 +++++++++++++--------- 6 files changed, 100 insertions(+), 44 deletions(-) diff --git a/scripts/provision-admin.ts b/scripts/provision-admin.ts index a5a0838..fc2f2c8 100644 --- a/scripts/provision-admin.ts +++ b/scripts/provision-admin.ts @@ -8,16 +8,23 @@ import pg from "pg"; import * as fs from "fs"; import * as path from "path"; -// Load .env.local manually -const envPath = path.join(process.cwd(), ".env.local"); -if (fs.existsSync(envPath)) { - const envContent = fs.readFileSync(envPath, "utf-8"); - for (const line of envContent.split("\n")) { - const trimmed = line.trim(); - if (trimmed && !trimmed.startsWith("#")) { - const [key, ...valueParts] = trimmed.split("="); - process.env[key.trim()] = valueParts.join("=").trim(); +// Load .env.local (or .env.production) manually so the script works in prod bootstrap +const envFiles = [".env.local", ".env.production"]; +for (const f of envFiles) { + const envPath = path.join(process.cwd(), f); + if (fs.existsSync(envPath)) { + const envContent = fs.readFileSync(envPath, "utf-8"); + for (const line of envContent.split("\n")) { + const trimmed = line.trim(); + if (trimmed && !trimmed.startsWith("#")) { + const [key, ...valueParts] = trimmed.split("="); + const k = key.trim(); + if (!process.env[k]) { + process.env[k] = valueParts.join("=").trim(); + } + } } + console.log(`[provision] loaded ${f}`); } } @@ -106,7 +113,7 @@ async function main() { } console.log(`\n✅ ${email} is now provisioned as ${role}!`); - console.log(` They can access the admin at http://localhost:4000/admin`); + console.log(` They can access the admin at your production URL /admin (sign in first if needed).`); } finally { await pool.end(); diff --git a/src/actions/admin/users.ts b/src/actions/admin/users.ts index c0a932d..68548a0 100644 --- a/src/actions/admin/users.ts +++ b/src/actions/admin/users.ts @@ -211,6 +211,25 @@ export async function createAdminUser(input: CreateAdminUserInput): Promise<{ us ); if (!rows[0]) return { user: null, error: "Insert returned no row" }; + const newAdminId = String(rows[0].id); + + // Ensure the admin_user_brands link exists for brand-scoped roles. + // (Platform admins created without a chosen brand may have 0 links and still + // get access via role; getAdminUser allows this.) + if (input.brand_id) { + try { + await query( + `INSERT INTO admin_user_brands (admin_user_id, brand_id) + VALUES ($1, $2) + ON CONFLICT (admin_user_id, brand_id) DO NOTHING`, + [newAdminId, input.brand_id], + ); + } catch (linkErr) { + console.error("[createAdminUser] Failed to create admin_user_brands link:", linkErr); + // Non-fatal — the user row exists; a platform admin can link manually. + } + } + await sendWelcomeEmailSafe({ to: input.email, name: input.display_name ?? input.email.split("@")[0], diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx index 0d42720..5bb585a 100644 --- a/src/app/admin/layout.tsx +++ b/src/app/admin/layout.tsx @@ -4,6 +4,7 @@ import { getAdminUser } from "@/lib/admin-permissions"; import { getActiveBrandId } from "@/lib/brand-scope"; import { listBrandsForAdmin } from "@/actions/brands"; import { redirect } from "next/navigation"; +import { getSession } from "@/lib/auth"; import "@/styles/admin-design-system.css"; import { ToastProvider } from "@/components/admin/Toast"; import { ToastContainer } from "@/components/admin/ToastContainer"; @@ -47,13 +48,25 @@ export default async function AdminLayout({ children }: { children: React.ReactN ); } - // Not authenticated + // Not authenticated / not provisioned if (!adminUser) { + // Best-effort: surface the Neon Auth identity so the user (or support) knows + // which account was checked. getAdminUser already logged details. + let attemptedEmail: string | null = null; + try { + const { data: session } = await getSession(); + attemptedEmail = session?.user?.email ?? null; + } catch { + // ignore + } + const message = attemptedEmail + ? `Your account (${attemptedEmail}) does not have admin access. Contact a platform administrator to be provisioned.` + : "Your account does not have admin access."; return (
- +
); diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index ac149bb..037c6e0 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -1,10 +1,10 @@ import Link from "next/link"; -import { supabase } from "@/lib/supabase"; import { getAdminUser } from "@/lib/admin-permissions"; import { getActiveBrandId } from "@/lib/brand-scope"; import { isFeatureEnabled } from "@/lib/feature-flags"; import { getBillingOverview } from "@/actions/billing/billing-overview"; import DashboardClient from "@/components/admin/DashboardClient"; +import { pool } from "@/lib/db"; const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de"; @@ -31,17 +31,18 @@ export default async function AdminPage() { // so a transient DB/network failure can't crash the whole admin page. let dashboardBrandId: string | null = adminUser ? await getActiveBrandId(adminUser) : null; if (!dashboardBrandId && adminUser?.role === "platform_admin") { + // Direct pg query (the supabase shim returns empty results). + // This ensures a platform_admin sees real dashboard stats even on first login + // before they have chosen an active brand. try { - const { data: firstBrand } = await supabase - .from("brands") - .select("id") - .limit(1) - .single(); - if (firstBrand?.id) { - dashboardBrandId = String(firstBrand.id); + const { rows } = await pool.query<{ id: string }>( + `SELECT id FROM brands ORDER BY created_at ASC LIMIT 1` + ); + if (rows[0]?.id) { + dashboardBrandId = rows[0].id; } } catch (err) { - console.error("[admin/page] supabase brands lookup failed:", err); + console.error("[admin/page] brands lookup failed:", err); } } diff --git a/src/components/admin/AdminAccessDenied.tsx b/src/components/admin/AdminAccessDenied.tsx index 02b92b3..b1ba473 100644 --- a/src/components/admin/AdminAccessDenied.tsx +++ b/src/components/admin/AdminAccessDenied.tsx @@ -31,10 +31,16 @@ export default function AdminAccessDenied({

{message}

- Back to Admin + Go to Login + + + Return to homepage diff --git a/src/lib/admin-permissions.ts b/src/lib/admin-permissions.ts index 9b012a4..13a09c4 100644 --- a/src/lib/admin-permissions.ts +++ b/src/lib/admin-permissions.ts @@ -18,13 +18,15 @@ import type { AdminRole, AdminUser, TenantContext } from "@/lib/admin-permission * Returns `null` if: * - No Neon Auth session (caller not signed in) * - The session email doesn't match any `admin_users.email` - * - The user has no `admin_user_brands` row (not provisioned yet) + * - The user is a brand-scoped role (brand_admin / store_employee) and has no + * rows in `admin_user_brands` (not provisioned for any brand yet) * - * Provisioning: an admin must run - * INSERT INTO admin_users (email, ...) VALUES (...) - * INSERT INTO admin_user_brands (admin_user_id, brand_id, role) VALUES (...) - * to grant a signed-in user admin access. Until provisioned, the - * layout shows "Access Denied" — correct behavior. + * Platform admins may be provisioned with zero brand links and still receive + * access (they see all brands). Brand-scoped admins require >= 1 link row. + * + * Provisioning: an admin must ensure rows exist in both tables for the user's + * email (matched from their Neon Auth session). Until provisioned, the layout + * shows "Access Denied" — correct behavior. */ export async function getAdminUser(): Promise { // Check for dev_session cookie in development mode @@ -67,37 +69,41 @@ export async function getAdminUser(): Promise { return null; } + const role = (user.role as AdminRole) || "brand_admin"; + + // Load all brand memberships for this admin (supports multi-brand admins). + // The redundant adminUsers join was removed; role lives on admin_users. const membershipRows = await db .select({ brandId: adminUserBrands.brandId, brandName: brands.name, brandSlug: brands.slug, - // Role comes from admin_users table, not admin_user_brands - role: adminUsers.role, }) .from(adminUserBrands) .innerJoin(brands, eq(brands.id, adminUserBrands.brandId)) - .innerJoin(adminUsers, eq(adminUsers.id, adminUserBrands.adminUserId)) - .where(eq(adminUserBrands.adminUserId, user.id)) - .limit(1); + .where(eq(adminUserBrands.adminUserId, user.id)); console.log("[admin-permissions] Membership rows found:", membershipRows.length, membershipRows); - if (membershipRows.length === 0) { + + // Brand-scoped roles (brand_admin, store_employee) require at least one brand link. + // Platform admins may have zero or more explicit links; they get cross-brand access via role. + if (membershipRows.length === 0 && role !== "platform_admin") { // Signed in but not provisioned for any brand. + console.log("[admin-permissions] No brand memberships for non-platform user — denying"); return null; } - const m = membershipRows[0]; - const role = user.role as AdminRole; + const first = membershipRows[0] ?? null; return buildAdminUser({ id: user.id, email: user.email, displayName: user.name, - brandId: m.brandId, - brandName: m.brandName, - brandSlug: m.brandSlug, + brandId: first?.brandId ?? null, + brandName: first?.brandName ?? null, + brandSlug: first?.brandSlug ?? null, role, active: true, + brandIds: membershipRows.map((m) => m.brandId), }); }); } catch (err) { @@ -164,19 +170,23 @@ function buildAdminUser(input: { id: string; email: string; displayName: string | null; - brandId: string; - brandName: string; - brandSlug: string; + brandId: string | null; + brandName: string | null; + brandSlug: string | null; role: AdminRole; active: boolean; + brandIds?: string[]; }): AdminUser { + const brandIds = input.brandIds && input.brandIds.length > 0 + ? input.brandIds + : (input.brandId ? [input.brandId] : []); return { id: input.id, user_id: input.id, email: input.email, display_name: input.displayName, brand_id: input.brandId, - brand_ids: [input.brandId], + brand_ids: brandIds, brand_slug: input.brandSlug, role: input.role, active: input.active,