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 (
{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