feat(storage): MinIO object storage, Neon Auth, Supabase removal
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:
openclaw
2026-06-09 11:32:17 -06:00
parent bb464bda65
commit 916ad39176
349 changed files with 6706 additions and 3343 deletions
+61 -57
View File
@@ -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),
};
}