fix: admin auth for prod Neon Auth deployment
Deploy to route.crispygoat.com / deploy (push) Successful in 3m59s
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:
@@ -8,18 +8,25 @@ 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"];
|
||||||
|
for (const f of envFiles) {
|
||||||
|
const envPath = path.join(process.cwd(), f);
|
||||||
if (fs.existsSync(envPath)) {
|
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();
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user