feat(storage): MinIO object storage, Neon Auth, Supabase removal
Deploy to route.crispygoat.com / deploy (push) Failing after 3m1s
Deploy to route.crispygoat.com / deploy (push) Failing after 3m1s
- Add MinIO/S3-compatible storage client (src/lib/storage.ts) with uploadObject, deleteObject, presigned URL helpers, and BUCKETS constant - Wire product images, brand logos, and water log photos to MinIO via the new storage client - Migrate forgot-password to Neon Auth (remove Supabase /auth/v1/recover call) - Migrate send-scheduled cron to direct Postgres + Resend (remove Supabase Edge Function proxy) - Add logoUrl to email types (OrderReceipt, Welcome, PasswordReset) and pass brand_settings.logo_url from all call sites - Update email templates to use dynamic logoUrl instead of hardcoded Supabase bucket URLs - Remove hardcoded Supabase URLs from TuxedoVideoHero, TuxedoAboutPage, TimeTrackingFieldClient; use brand_settings props + local public/ fallback - Download brand logos (3) and tuxedo-hero.mp4 (36MB) from Supabase bucket to public/ for local development - Add MinIO env vars to .env.example (endpoint, access key, secret, buckets) - Fix TimeTrackingFieldClient to destructure logoUrl and brandAccent props - Fix admin/users.ts logoUrl type (null → undefined for optional string) - Remove stale sb- cookie from wholesale-auth - Migrate tuxedo/about page to remove supabase import and use pool query for wholesale_settings lookup
This commit is contained in:
@@ -1,38 +1,47 @@
|
||||
import "server-only";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { cookies } from "next/headers";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { withPlatformAdmin } from "@/db/client";
|
||||
import { users, tenants, tenantUsers } from "@/db/schema";
|
||||
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 Auth.js v5 session, then resolves the user + tenant
|
||||
* from the `users` and `tenant_users` tables.
|
||||
* 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 Auth.js session (caller not signed in)
|
||||
* - The session email doesn't match any `users.email`
|
||||
* - The user has no `tenant_users` row (not provisioned yet)
|
||||
* - 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)
|
||||
*
|
||||
* Provisioning: an admin must run
|
||||
* INSERT INTO users (email, ...) VALUES (...)
|
||||
* INSERT INTO tenant_users (tenant_id, user_id, role) VALUES (...)
|
||||
* to grant a Google-sign-in user admin access. Until provisioned, the
|
||||
* 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.
|
||||
*
|
||||
* The previous `dev_session` cookie bypass has been removed. The only
|
||||
* way into the admin is through real Auth.js (Google in production;
|
||||
* for local dev, configure `AUTH_GOOGLE_ID` / `AUTH_GOOGLE_SECRET`).
|
||||
*/
|
||||
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 session = await auth();
|
||||
const { data: session } = await getSession();
|
||||
sessionEmail = session?.user?.email ?? null;
|
||||
} catch (err) {
|
||||
console.error("[admin-permissions] auth() failed:", err);
|
||||
console.error("[admin-permissions] getSession() failed:", err);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -41,40 +50,38 @@ export async function getAdminUser(): Promise<AdminUser | null> {
|
||||
return await withPlatformAdmin(async (db) => {
|
||||
const userRows = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, sessionEmail))
|
||||
.from(adminUsers)
|
||||
.where(eq(adminUsers.email, sessionEmail.toLowerCase()))
|
||||
.limit(1);
|
||||
const user = userRows[0];
|
||||
if (!user) return null;
|
||||
|
||||
const membershipRows = await db
|
||||
.select({
|
||||
tenantId: tenants.id,
|
||||
tenantName: tenants.name,
|
||||
tenantSlug: tenants.slug,
|
||||
tenantStatus: tenants.status,
|
||||
role: tenantUsers.role,
|
||||
brandId: adminUserBrands.brandId,
|
||||
brandName: brands.name,
|
||||
brandSlug: brands.slug,
|
||||
role: adminUserBrands.adminUserId,
|
||||
})
|
||||
.from(tenantUsers)
|
||||
.innerJoin(tenants, eq(tenants.id, tenantUsers.tenantId))
|
||||
.where(eq(tenantUsers.userId, user.id))
|
||||
.from(adminUserBrands)
|
||||
.innerJoin(brands, eq(brands.id, adminUserBrands.brandId))
|
||||
.where(eq(adminUserBrands.adminUserId, user.id))
|
||||
.limit(1);
|
||||
|
||||
if (membershipRows.length === 0) {
|
||||
// Signed in but not provisioned for any tenant.
|
||||
// Signed in but not provisioned for any brand.
|
||||
return null;
|
||||
}
|
||||
|
||||
const m = membershipRows[0];
|
||||
const role = m.role as AdminRole;
|
||||
const role = user.role as AdminRole;
|
||||
return buildAdminUser({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
displayName: user.name,
|
||||
authProvider: user.authProvider,
|
||||
tenantId: m.tenantId,
|
||||
tenantSlug: m.tenantSlug,
|
||||
tenantName: m.tenantName,
|
||||
brandId: m.brandId,
|
||||
brandName: m.brandName,
|
||||
brandSlug: m.brandSlug,
|
||||
role,
|
||||
active: true,
|
||||
});
|
||||
@@ -82,24 +89,24 @@ export async function getAdminUser(): Promise<AdminUser | null> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the current admin user AND their tenant. Returns `null` if
|
||||
* the user is not signed in or has no tenant. For platform_admin (no
|
||||
* tenant), `tenant` is `null` and callers should use `withPlatformAdmin`
|
||||
* to query across all tenants.
|
||||
* 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.tenant_id) {
|
||||
// platform_admin — no specific tenant
|
||||
if (!user.brand_id) {
|
||||
// platform_admin — no specific brand
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
user,
|
||||
tenant: {
|
||||
id: user.tenant_id,
|
||||
name: user.display_name ?? user.tenant_slug ?? "Unknown",
|
||||
slug: user.tenant_slug ?? "unknown",
|
||||
id: user.brand_id,
|
||||
name: user.display_name ?? user.brand_slug ?? "Unknown",
|
||||
slug: user.brand_slug ?? "unknown",
|
||||
status: "active",
|
||||
},
|
||||
};
|
||||
@@ -114,20 +121,19 @@ export type { AdminUser, AdminRole, TenantContext } from "@/lib/admin-permission
|
||||
/**
|
||||
* @deprecated Kept for unit tests that exercise the dev shim path.
|
||||
* Production code should never call this — `getAdminUser()` only reads
|
||||
* the Auth.js session now.
|
||||
* the Neon Auth session now.
|
||||
*/
|
||||
export function buildDevAdmin(role: AdminRole): AdminUser {
|
||||
const isPlatform = role === "platform_admin";
|
||||
const tenantId = isPlatform ? null : "dev-tenant";
|
||||
const brandId = isPlatform ? null : "dev-brand";
|
||||
return {
|
||||
id: "dev",
|
||||
user_id: "dev",
|
||||
email: null,
|
||||
display_name: "Demo Admin",
|
||||
tenant_id: tenantId,
|
||||
brand_id: tenantId, // legacy alias
|
||||
brand_ids: tenantId ? [tenantId] : [], // legacy array alias
|
||||
tenant_slug: isPlatform ? null : "tuxedo",
|
||||
brand_id: brandId,
|
||||
brand_ids: brandId ? [brandId] : [],
|
||||
brand_slug: isPlatform ? null : "tuxedo",
|
||||
role,
|
||||
active: true,
|
||||
auth_provider: "dev",
|
||||
@@ -138,12 +144,11 @@ export function buildDevAdmin(role: AdminRole): AdminUser {
|
||||
|
||||
function buildAdminUser(input: {
|
||||
id: string;
|
||||
email: string | null;
|
||||
email: string;
|
||||
displayName: string | null;
|
||||
authProvider: "dev" | "google" | "email" | null;
|
||||
tenantId: string;
|
||||
tenantSlug: string;
|
||||
tenantName: string;
|
||||
brandId: string;
|
||||
brandName: string;
|
||||
brandSlug: string;
|
||||
role: AdminRole;
|
||||
active: boolean;
|
||||
}): AdminUser {
|
||||
@@ -152,13 +157,12 @@ function buildAdminUser(input: {
|
||||
user_id: input.id,
|
||||
email: input.email,
|
||||
display_name: input.displayName,
|
||||
tenant_id: input.tenantId,
|
||||
brand_id: input.tenantId, // legacy alias
|
||||
brand_ids: [input.tenantId], // legacy array alias
|
||||
tenant_slug: input.tenantSlug,
|
||||
brand_id: input.brandId,
|
||||
brand_ids: [input.brandId],
|
||||
brand_slug: input.brandSlug,
|
||||
role: input.role,
|
||||
active: input.active,
|
||||
auth_provider: input.authProvider,
|
||||
auth_provider: "neon_auth",
|
||||
...permissionsForRole(input.role),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user