fix: admin auth for prod Neon Auth deployment
Deploy to route.crispygoat.com / deploy (push) Successful in 3m59s

- 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)
This commit is contained in:
openclaw
2026-06-09 15:30:23 -06:00
parent 1af47698a1
commit d312783f3a
6 changed files with 100 additions and 44 deletions
+12 -5
View File
@@ -8,17 +8,24 @@ import pg from "pg";
import * as fs from "fs"; import * as fs from "fs";
import * as path from "path"; import * as path from "path";
// Load .env.local manually // Load .env.local (or .env.production) manually so the script works in prod bootstrap
const envPath = path.join(process.cwd(), ".env.local"); const envFiles = [".env.local", ".env.production"];
if (fs.existsSync(envPath)) { for (const f of envFiles) {
const envPath = path.join(process.cwd(), f);
if (fs.existsSync(envPath)) {
const envContent = fs.readFileSync(envPath, "utf-8"); const envContent = fs.readFileSync(envPath, "utf-8");
for (const line of envContent.split("\n")) { for (const line of envContent.split("\n")) {
const trimmed = line.trim(); const trimmed = line.trim();
if (trimmed && !trimmed.startsWith("#")) { if (trimmed && !trimmed.startsWith("#")) {
const [key, ...valueParts] = trimmed.split("="); const [key, ...valueParts] = trimmed.split("=");
process.env[key.trim()] = valueParts.join("=").trim(); const k = key.trim();
if (!process.env[k]) {
process.env[k] = valueParts.join("=").trim();
} }
} }
}
console.log(`[provision] loaded ${f}`);
}
} }
const { Pool } = pg; const { Pool } = pg;
@@ -106,7 +113,7 @@ async function main() {
} }
console.log(`\n✅ ${email} is now provisioned as ${role}!`); 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 { } finally {
await pool.end(); await pool.end();
+19
View File
@@ -211,6 +211,25 @@ export async function createAdminUser(input: CreateAdminUserInput): Promise<{ us
); );
if (!rows[0]) return { user: null, error: "Insert returned no row" }; 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({ await sendWelcomeEmailSafe({
to: input.email, to: input.email,
name: input.display_name ?? input.email.split("@")[0], name: input.display_name ?? input.email.split("@")[0],
+15 -2
View File
@@ -4,6 +4,7 @@ import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope"; import { getActiveBrandId } from "@/lib/brand-scope";
import { listBrandsForAdmin } from "@/actions/brands"; import { listBrandsForAdmin } from "@/actions/brands";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getSession } from "@/lib/auth";
import "@/styles/admin-design-system.css"; import "@/styles/admin-design-system.css";
import { ToastProvider } from "@/components/admin/Toast"; import { ToastProvider } from "@/components/admin/Toast";
import { ToastContainer } from "@/components/admin/ToastContainer"; 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) { 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 ( return (
<ToastProviderWrapper> <ToastProviderWrapper>
<AdminSidebar userRole={null} /> <AdminSidebar userRole={null} />
<div className="min-h-screen lg:pl-60 admin-section" style={{ backgroundColor: "var(--admin-bg)" }}> <div className="min-h-screen lg:pl-60 admin-section" style={{ backgroundColor: "var(--admin-bg)" }}>
<AdminAccessDenied message="Your account does not have admin access." /> <AdminAccessDenied message={message} />
</div> </div>
</ToastProviderWrapper> </ToastProviderWrapper>
); );
+10 -9
View File
@@ -1,10 +1,10 @@
import Link from "next/link"; import Link from "next/link";
import { supabase } from "@/lib/supabase";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope"; import { getActiveBrandId } from "@/lib/brand-scope";
import { isFeatureEnabled } from "@/lib/feature-flags"; import { isFeatureEnabled } from "@/lib/feature-flags";
import { getBillingOverview } from "@/actions/billing/billing-overview"; import { getBillingOverview } from "@/actions/billing/billing-overview";
import DashboardClient from "@/components/admin/DashboardClient"; import DashboardClient from "@/components/admin/DashboardClient";
import { pool } from "@/lib/db";
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de"; 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. // so a transient DB/network failure can't crash the whole admin page.
let dashboardBrandId: string | null = adminUser ? await getActiveBrandId(adminUser) : null; let dashboardBrandId: string | null = adminUser ? await getActiveBrandId(adminUser) : null;
if (!dashboardBrandId && adminUser?.role === "platform_admin") { 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 { try {
const { data: firstBrand } = await supabase const { rows } = await pool.query<{ id: string }>(
.from("brands") `SELECT id FROM brands ORDER BY created_at ASC LIMIT 1`
.select("id") );
.limit(1) if (rows[0]?.id) {
.single(); dashboardBrandId = rows[0].id;
if (firstBrand?.id) {
dashboardBrandId = String(firstBrand.id);
} }
} catch (err) { } catch (err) {
console.error("[admin/page] supabase brands lookup failed:", err); console.error("[admin/page] brands lookup failed:", err);
} }
} }
+8 -2
View File
@@ -31,10 +31,16 @@ export default function AdminAccessDenied({
</h1> </h1>
<p className="mt-2 text-sm text-stone-500">{message}</p> <p className="mt-2 text-sm text-stone-500">{message}</p>
<Link <Link
href="/admin" href="/login"
className="mt-6 inline-flex items-center gap-2 rounded-xl bg-emerald-600 hover:bg-emerald-500 px-5 py-2.5 text-sm font-medium text-white transition-all shadow-sm" className="mt-6 inline-flex items-center gap-2 rounded-xl bg-emerald-600 hover:bg-emerald-500 px-5 py-2.5 text-sm font-medium text-white transition-all shadow-sm"
> >
Back to Admin Go to Login
</Link>
<Link
href="/"
className="mt-3 block text-sm text-stone-500 hover:text-stone-700 underline-offset-2 hover:underline"
>
Return to homepage
</Link> </Link>
</div> </div>
</div> </div>
+31 -21
View File
@@ -18,13 +18,15 @@ import type { AdminRole, AdminUser, TenantContext } from "@/lib/admin-permission
* Returns `null` if: * Returns `null` if:
* - No Neon Auth session (caller not signed in) * - No Neon Auth session (caller not signed in)
* - The session email doesn't match any `admin_users.email` * - 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 * Platform admins may be provisioned with zero brand links and still receive
* INSERT INTO admin_users (email, ...) VALUES (...) * access (they see all brands). Brand-scoped admins require >= 1 link row.
* INSERT INTO admin_user_brands (admin_user_id, brand_id, role) VALUES (...) *
* to grant a signed-in user admin access. Until provisioned, the * Provisioning: an admin must ensure rows exist in both tables for the user's
* layout shows "Access Denied" — correct behavior. * email (matched from their Neon Auth session). Until provisioned, the layout
* shows "Access Denied" — correct behavior.
*/ */
export async function getAdminUser(): Promise<AdminUser | null> { export async function getAdminUser(): Promise<AdminUser | null> {
// Check for dev_session cookie in development mode // Check for dev_session cookie in development mode
@@ -67,37 +69,41 @@ export async function getAdminUser(): Promise<AdminUser | null> {
return null; 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 const membershipRows = await db
.select({ .select({
brandId: adminUserBrands.brandId, brandId: adminUserBrands.brandId,
brandName: brands.name, brandName: brands.name,
brandSlug: brands.slug, brandSlug: brands.slug,
// Role comes from admin_users table, not admin_user_brands
role: adminUsers.role,
}) })
.from(adminUserBrands) .from(adminUserBrands)
.innerJoin(brands, eq(brands.id, adminUserBrands.brandId)) .innerJoin(brands, eq(brands.id, adminUserBrands.brandId))
.innerJoin(adminUsers, eq(adminUsers.id, adminUserBrands.adminUserId)) .where(eq(adminUserBrands.adminUserId, user.id));
.where(eq(adminUserBrands.adminUserId, user.id))
.limit(1);
console.log("[admin-permissions] Membership rows found:", membershipRows.length, membershipRows); 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. // Signed in but not provisioned for any brand.
console.log("[admin-permissions] No brand memberships for non-platform user — denying");
return null; return null;
} }
const m = membershipRows[0]; const first = membershipRows[0] ?? null;
const role = user.role as AdminRole;
return buildAdminUser({ return buildAdminUser({
id: user.id, id: user.id,
email: user.email, email: user.email,
displayName: user.name, displayName: user.name,
brandId: m.brandId, brandId: first?.brandId ?? null,
brandName: m.brandName, brandName: first?.brandName ?? null,
brandSlug: m.brandSlug, brandSlug: first?.brandSlug ?? null,
role, role,
active: true, active: true,
brandIds: membershipRows.map((m) => m.brandId),
}); });
}); });
} catch (err) { } catch (err) {
@@ -164,19 +170,23 @@ function buildAdminUser(input: {
id: string; id: string;
email: string; email: string;
displayName: string | null; displayName: string | null;
brandId: string; brandId: string | null;
brandName: string; brandName: string | null;
brandSlug: string; brandSlug: string | null;
role: AdminRole; role: AdminRole;
active: boolean; active: boolean;
brandIds?: string[];
}): AdminUser { }): AdminUser {
const brandIds = input.brandIds && input.brandIds.length > 0
? input.brandIds
: (input.brandId ? [input.brandId] : []);
return { return {
id: input.id, id: input.id,
user_id: input.id, user_id: input.id,
email: input.email, email: input.email,
display_name: input.displayName, display_name: input.displayName,
brand_id: input.brandId, brand_id: input.brandId,
brand_ids: [input.brandId], brand_ids: brandIds,
brand_slug: input.brandSlug, brand_slug: input.brandSlug,
role: input.role, role: input.role,
active: input.active, active: input.active,