83ad6536a3
Deploy to route.crispygoat.com / deploy (push) Successful in 5m46s
- Migrate login page to atelier design system (editorial modal style) - Polish root error.tsx, not-found.tsx, loading.tsx with atelier design - Add JSON-LD structured data: SoftwareApplication + LocalBusiness - Fix next.config.ts outputFileTracingRoot absolute path warning - Add force-dynamic to protected-example (uses cookies) - Add proper type for stops page (replace : any) - Fix unescaped quotes in StopTableClient - Fix no-explicit-any in db-schema route - Clean up console.logs in admin-permissions - Fix inline any in admin/stops page - Update OG image references to existing og-default.svg
249 lines
7.9 KiB
TypeScript
249 lines
7.9 KiB
TypeScript
import "server-only";
|
|
import { eq } from "drizzle-orm";
|
|
import { cookies } from "next/headers";
|
|
import { getSession } from "@/lib/auth";
|
|
import { withPlatformAdmin } from "@/db/client";
|
|
import { adminUsers, adminUserBrands, brands } from "@/db/schema";
|
|
import type { AdminRole, AdminUser, TenantContext } from "@/lib/admin-permissions-types";
|
|
|
|
/**
|
|
* Source of truth for the current admin user.
|
|
*
|
|
* Looks up the Neon Auth session, then resolves the user from the
|
|
* `admin_users` table (linked to `neon_auth.user` by email).
|
|
*
|
|
* In development mode (NODE_ENV !== "production"), also checks for
|
|
* dev_session cookie as a bypass for local testing.
|
|
*
|
|
* Returns `null` if:
|
|
* - No Neon Auth session (caller not signed in)
|
|
* - The session email doesn't match any `admin_users.email`
|
|
* - 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)
|
|
*
|
|
* 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<AdminUser | null> {
|
|
// Check for dev_session cookie in development mode
|
|
if (process.env.NODE_ENV !== "production") {
|
|
const cookieStore = await cookies();
|
|
const devSession = cookieStore.get("dev_session")?.value;
|
|
if (devSession === "platform_admin" || devSession === "brand_admin" || devSession === "store_employee") {
|
|
return buildDevAdmin(devSession as AdminRole);
|
|
}
|
|
}
|
|
|
|
let sessionEmail: string | null = null;
|
|
try {
|
|
const { data: session } = await getSession();
|
|
sessionEmail = session?.user?.email ?? null;
|
|
} catch (err) {
|
|
console.error("[admin-permissions] getSession() failed:", err);
|
|
return null;
|
|
}
|
|
|
|
if (!sessionEmail) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
return await withPlatformAdmin(async (db) => {
|
|
const userRows = await db
|
|
.select()
|
|
.from(adminUsers)
|
|
.where(eq(adminUsers.email, sessionEmail.toLowerCase()))
|
|
.limit(1);
|
|
const user = userRows[0];
|
|
if (!user) {
|
|
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,
|
|
})
|
|
.from(adminUserBrands)
|
|
.innerJoin(brands, eq(brands.id, adminUserBrands.brandId))
|
|
.where(eq(adminUserBrands.adminUserId, user.id));
|
|
|
|
// 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.
|
|
return null;
|
|
}
|
|
|
|
const first = membershipRows[0] ?? null;
|
|
return buildAdminUser({
|
|
id: user.id,
|
|
email: user.email,
|
|
displayName: user.name,
|
|
brandId: first?.brandId ?? null,
|
|
brandName: first?.brandName ?? null,
|
|
brandSlug: first?.brandSlug ?? null,
|
|
role,
|
|
active: true,
|
|
brandIds: membershipRows.map((m) => m.brandId),
|
|
});
|
|
});
|
|
} catch (err) {
|
|
console.error("[admin-permissions] Database query failed:", err);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolves the current admin user AND their brand. Returns `null` if
|
|
* the user is not signed in or has no brand. For platform_admin (no
|
|
* brand), `brand` is `null` and callers should use `withPlatformAdmin`
|
|
* to query across all brands.
|
|
*/
|
|
export async function getCurrentTenant(): Promise<TenantContext | null> {
|
|
const user = await getAdminUser();
|
|
if (!user) return null;
|
|
if (!user.brand_id) {
|
|
// platform_admin — no specific brand
|
|
return null;
|
|
}
|
|
return {
|
|
user,
|
|
tenant: {
|
|
id: user.brand_id,
|
|
name: user.display_name ?? user.brand_slug ?? "Unknown",
|
|
slug: user.brand_slug ?? "unknown",
|
|
status: "active",
|
|
},
|
|
};
|
|
}
|
|
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
// Re-exports for backward compat
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
|
|
export type { AdminUser, AdminRole, TenantContext } from "@/lib/admin-permissions-types";
|
|
|
|
/**
|
|
* @deprecated Kept for unit tests that exercise the dev shim path.
|
|
* Production code should never call this — `getAdminUser()` only reads
|
|
* the Neon Auth session now.
|
|
*/
|
|
export function buildDevAdmin(role: AdminRole): AdminUser {
|
|
const isPlatform = role === "platform_admin";
|
|
const brandId = isPlatform ? null : "dev-brand";
|
|
return {
|
|
id: "dev",
|
|
user_id: "dev",
|
|
email: null,
|
|
display_name: "Demo Admin",
|
|
brand_id: brandId,
|
|
brand_ids: brandId ? [brandId] : [],
|
|
brand_slug: isPlatform ? null : "tuxedo",
|
|
role,
|
|
active: true,
|
|
auth_provider: "dev",
|
|
...permissionsForRole(role),
|
|
must_change_password: false,
|
|
};
|
|
}
|
|
|
|
function buildAdminUser(input: {
|
|
id: string;
|
|
email: string;
|
|
displayName: string | null;
|
|
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: brandIds,
|
|
brand_slug: input.brandSlug,
|
|
role: input.role,
|
|
active: input.active,
|
|
auth_provider: "neon_auth",
|
|
...permissionsForRole(input.role),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Single source of truth for "what can a role do". Used by both the
|
|
* dev shim and the real user lookup so the demo and the real thing
|
|
* behave identically.
|
|
*/
|
|
export function permissionsForRole(role: AdminRole) {
|
|
if (role === "platform_admin") {
|
|
return {
|
|
can_manage_products: true,
|
|
can_manage_stops: true,
|
|
can_manage_orders: true,
|
|
can_manage_pickup: true,
|
|
can_manage_messages: true,
|
|
can_manage_refunds: true,
|
|
can_manage_users: true,
|
|
can_manage_water_log: true,
|
|
can_manage_reports: true,
|
|
can_manage_settings: true,
|
|
can_manage_billing: true,
|
|
can_manage_branding: true,
|
|
can_manage_marketing: true,
|
|
can_manage_team: true,
|
|
};
|
|
}
|
|
if (role === "brand_admin") {
|
|
return {
|
|
can_manage_products: true,
|
|
can_manage_stops: true,
|
|
can_manage_orders: true,
|
|
can_manage_pickup: true,
|
|
can_manage_messages: true,
|
|
can_manage_refunds: true,
|
|
can_manage_users: false,
|
|
can_manage_water_log: true,
|
|
can_manage_reports: true,
|
|
can_manage_settings: true,
|
|
can_manage_billing: true,
|
|
can_manage_branding: true,
|
|
can_manage_marketing: true,
|
|
can_manage_team: true,
|
|
};
|
|
}
|
|
// store_employee
|
|
return {
|
|
can_manage_products: false,
|
|
can_manage_stops: false,
|
|
can_manage_orders: true,
|
|
can_manage_pickup: true,
|
|
can_manage_messages: false,
|
|
can_manage_refunds: false,
|
|
can_manage_users: false,
|
|
can_manage_water_log: false,
|
|
can_manage_reports: false,
|
|
can_manage_settings: false,
|
|
can_manage_billing: false,
|
|
can_manage_branding: false,
|
|
can_manage_marketing: false,
|
|
can_manage_team: false,
|
|
};
|
|
}
|