feat(storage): MinIO object storage, Neon Auth, Supabase removal
Deploy to route.crispygoat.com / deploy (push) Failing after 7s

- 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 c86e97e816
348 changed files with 6616 additions and 3096 deletions
+11 -2
View File
@@ -34,16 +34,25 @@ test.describe("Smoke Tests", () => {
expect(page.url()).toMatch(/\/login(\?|$)/);
});
test("Login page renders the Google CTA (or 'not configured' notice)", async ({ page }) => {
test("Login page renders the sign-in form", async ({ page }) => {
await page.goto(`${BASE}/login`);
// Check for email/password form (the main login method)
const hasEmail = await page.getByLabel(/email/i).isVisible().catch(() => false);
const hasPassword = await page.getByLabel(/password/i).isVisible().catch(() => false);
const hasSignInButton = await page.getByRole("button", { name: /sign in/i }).isVisible().catch(() => false);
// OR check for Google button (alternative method)
const hasGoogle = await page
.getByRole("button", { name: /continue with google/i })
.isVisible()
.catch(() => false);
// OR check for "not configured" message
const hasSetup = await page
.getByText(/Google sign-in is not configured/i)
.isVisible()
.catch(() => false);
expect(hasGoogle || hasSetup).toBe(true);
expect(hasEmail || hasPassword || hasSignInButton || hasGoogle || hasSetup).toBe(true);
});
});
+139
View File
@@ -0,0 +1,139 @@
import { test, expect } from "@playwright/test";
const BASE = process.env.PLAYWRIGHT_URL ?? "http://localhost:3000";
test.describe("Console Error Checks", () => {
const pagesToTest = [
{ path: "/", name: "Homepage" },
{ path: "/tuxedo", name: "Tuxedo Storefront" },
{ path: "/indian-river-direct", name: "Indian River Direct" },
{ path: "/admin", name: "Admin (unauthenticated)" },
{ path: "/login", name: "Login" },
{ path: "/cart", name: "Cart" },
{ path: "/pricing", name: "Pricing" },
{ path: "/water", name: "Water Log" },
];
for (const { path, name } of pagesToTest) {
test(`${name} (${path}) - no console errors`, async ({ page }) => {
const errors: string[] = [];
page.on("console", (msg) => {
if (msg.type() === "error") {
const text = msg.text();
// Ignore known benign errors
if (
text.includes("favicon") ||
text.includes("Warning:") ||
text.includes("Hydration") ||
text.includes("download the React DevTools") ||
text.includes("404")
) {
return;
}
errors.push(text);
}
});
await page.goto(`${BASE}${path}`, { waitUntil: "domcontentloaded" });
// Wait a bit for any async errors
await page.waitForTimeout(2000);
// Filter out any warnings about smooth scroll (non-critical)
const criticalErrors = errors.filter(e =>
!e.includes("scroll-behavior") &&
!e.includes("setState") &&
!e.includes("Can't perform a React state update")
);
if (criticalErrors.length > 0) {
console.log("Console errors found:", criticalErrors);
}
expect(criticalErrors).toHaveLength(0);
});
}
test("Tax Dashboard - no setState during render errors", async ({ page }) => {
const errors: string[] = [];
page.on("console", (msg) => {
if (msg.type() === "error") {
const text = msg.text();
if (
text.includes("Cannot update a component") ||
text.includes("setState") ||
text.includes("Can't perform a React state update")
) {
errors.push(text);
}
}
});
// Navigate to taxes page (will redirect to login if unauthenticated)
await page.goto(`${BASE}/admin/taxes`, { waitUntil: "domcontentloaded" });
await page.waitForTimeout(2000);
expect(errors).toHaveLength(0);
});
test("Dashboard - no router warnings", async ({ page }) => {
const errors: string[] = [];
page.on("console", (msg) => {
if (msg.type() === "error") {
const text = msg.text();
if (
text.includes("Cannot update a component") ||
text.includes("Router")
) {
errors.push(text);
}
}
});
await page.goto(`${BASE}/admin`, { waitUntil: "domcontentloaded" });
await page.waitForTimeout(2000);
expect(errors).toHaveLength(0);
});
});
test.describe("Navigation Tests", () => {
test("Admin navigation - no errors when clicking links", async ({ page }) => {
const errors: string[] = [];
page.on("console", (msg) => {
if (msg.type() === "error") {
errors.push(msg.text());
}
});
// Login first
await page.goto(`${BASE}/login`, { waitUntil: "domcontentloaded" });
await page.waitForTimeout(1000);
// Check if we're on login page or already authenticated
const onLoginPage = page.url().includes("/login");
if (onLoginPage) {
// Try to see if we can proceed (may fail due to no auth)
await page.waitForTimeout(500);
}
// Navigate to various pages
const paths = ["/", "/tuxedo", "/pricing", "/contact"];
for (const path of paths) {
await page.goto(`${BASE}${path}`, { waitUntil: "domcontentloaded" });
await page.waitForTimeout(500);
}
// Filter out non-critical errors
const criticalErrors = errors.filter(e =>
!e.includes("favicon") &&
!e.includes("Warning:") &&
!e.includes("404") &&
!e.includes("net::ERR")
);
expect(criticalErrors).toHaveLength(0);
});
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

+10 -17
View File
@@ -7,11 +7,9 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const signInMock = vi.fn();
const signOutMock = vi.fn();
vi.mock("@/lib/auth", () => ({
signIn: signInMock,
signOut: signOutMock,
}));
@@ -20,27 +18,22 @@ vi.mock("@/lib/auth", () => ({
vi.mock("server-only", () => ({}));
// Import after mocks.
const { signInWithGoogle, signOutAction } = await import(
"@/actions/auth-actions"
);
const { signOutAction } = await import("@/actions/auth-actions");
beforeEach(() => {
signInMock.mockReset();
signOutMock.mockReset();
});
describe("signInWithGoogle", () => {
it("calls signIn with the google provider and /admin redirect", async () => {
signInMock.mockResolvedValue(undefined);
await signInWithGoogle();
expect(signInMock).toHaveBeenCalledWith("google", { redirectTo: "/admin" });
});
});
describe("signOutAction", () => {
it("calls signOut with the login redirect", async () => {
it("calls signOut and redirects to login", async () => {
signOutMock.mockResolvedValue(undefined);
await signOutAction();
expect(signOutMock).toHaveBeenCalledWith({ redirectTo: "/login" });
// Note: signOutAction calls redirect() which throws in test context
// We just verify signOut was called
try {
await signOutAction();
} catch {
// Expected - redirect() throws
}
expect(signOutMock).toHaveBeenCalled();
});
});
+29 -33
View File
@@ -1,7 +1,7 @@
/**
* Unit tests for `getAdminUser()`. The dev_session cookie bypass has been
* removed; the only path into the admin is through a real Auth.js
* session. These tests mock the `auth()` function and the DB layer
* removed; the only path into the admin is through a real Neon Auth
* session. These tests mock the `getSession()` function and the DB layer
* to exercise the lookup.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
@@ -10,16 +10,17 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("server-only", () => ({}));
// Mock the Drizzle client wrapper so we don't need a real DB.
type MockFn = (arg: unknown) => Promise<unknown>;
const mockSelect = vi.fn();
const mockWithPlatformAdmin = vi.fn(async (fn: any) => fn({ select: mockSelect }));
const mockWithPlatformAdmin = vi.fn(async (fn: MockFn) => fn({ select: mockSelect }));
vi.mock("@/db/client", () => ({
withPlatformAdmin: (fn: any) => mockWithPlatformAdmin(fn),
withPlatformAdmin: (fn: MockFn) => mockWithPlatformAdmin(fn),
}));
// Mock the auth() function. The default mock returns null (no session).
const authMock = vi.fn();
// Mock the getSession() function. The default mock returns null (no session).
const getSessionMock = vi.fn();
vi.mock("@/lib/auth", () => ({
auth: () => authMock(),
getSession: getSessionMock,
}));
// Mock cookies() so we don't read a real cookie store.
@@ -35,26 +36,26 @@ import { getAdminUser, buildDevAdmin, permissionsForRole } from "@/lib/admin-per
beforeEach(() => {
mockSelect.mockReset();
authMock.mockReset();
getSessionMock.mockReset();
cookieStoreGet.mockReset();
});
describe("getAdminUser()", () => {
it("returns null when there is no Auth.js session", async () => {
authMock.mockResolvedValue(null);
it("returns null when there is no Neon Auth session", async () => {
getSessionMock.mockResolvedValue({ data: null });
cookieStoreGet.mockReturnValue(undefined);
const u = await getAdminUser();
expect(u).toBeNull();
});
it("returns null when the session has no email", async () => {
authMock.mockResolvedValue({ user: { name: "no-email" } });
getSessionMock.mockResolvedValue({ data: { user: { name: "no-email" } } });
const u = await getAdminUser();
expect(u).toBeNull();
});
it("returns null when the email is not in the users table", async () => {
authMock.mockResolvedValue({ user: { email: "unknown@example.com" } });
getSessionMock.mockResolvedValue({ data: { user: { email: "unknown@example.com" } } });
// First select: users. Returns empty.
mockSelect.mockReturnValueOnce({
from: () => ({
@@ -67,8 +68,8 @@ describe("getAdminUser()", () => {
expect(u).toBeNull();
});
it("returns null when the user exists but has no tenant_users row", async () => {
authMock.mockResolvedValue({ user: { email: "no-tenant@example.com" } });
it("returns null when the user exists but has no admin_user_brands row", async () => {
getSessionMock.mockResolvedValue({ data: { user: { email: "no-brand@example.com" } } });
// First select: users — returns the user
mockSelect
.mockReturnValueOnce({
@@ -77,16 +78,14 @@ describe("getAdminUser()", () => {
limit: async () => [
{
id: "user-1",
email: "no-tenant@example.com",
name: "No Tenant",
authProvider: "google",
authSubject: "google-sub",
email: "no-brand@example.com",
name: "No Brand",
},
],
}),
}),
})
// Second select: tenantUsers — returns empty
// Second select: adminUserBrands — returns empty
.mockReturnValueOnce({
from: () => ({
innerJoin: () => ({
@@ -101,7 +100,7 @@ describe("getAdminUser()", () => {
});
it("returns a fully-populated AdminUser for a provisioned brand_admin", async () => {
authMock.mockResolvedValue({ user: { email: "admin@tuxedo.example" } });
getSessionMock.mockResolvedValue({ data: { user: { email: "admin@tuxedo.example" } } });
mockSelect
.mockReturnValueOnce({
from: () => ({
@@ -111,8 +110,6 @@ describe("getAdminUser()", () => {
id: "user-tux",
email: "admin@tuxedo.example",
name: "Tux Admin",
authProvider: "google",
authSubject: "google-tux",
},
],
}),
@@ -124,10 +121,9 @@ describe("getAdminUser()", () => {
where: () => ({
limit: async () => [
{
tenantId: "tenant-tux",
tenantName: "Tuxedo Citrus",
tenantSlug: "tuxedo",
tenantStatus: "active",
brandId: "brand-tux",
brandName: "Tuxedo Citrus",
brandSlug: "tuxedo",
role: "brand_admin",
},
],
@@ -139,8 +135,8 @@ describe("getAdminUser()", () => {
const u = await getAdminUser();
expect(u).not.toBeNull();
expect(u?.email).toBe("admin@tuxedo.example");
expect(u?.tenant_id).toBe("tenant-tux");
expect(u?.tenant_slug).toBe("tuxedo");
expect(u?.brand_id).toBe("brand-tux");
expect(u?.brand_slug).toBe("tuxedo");
expect(u?.role).toBe("brand_admin");
expect(u?.can_manage_products).toBe(true);
expect(u?.can_manage_team).toBe(true);
@@ -148,18 +144,18 @@ describe("getAdminUser()", () => {
});
describe("buildDevAdmin()", () => {
it("returns a platform_admin with no tenant", () => {
it("returns a platform_admin with no brand", () => {
const u = buildDevAdmin("platform_admin");
expect(u.role).toBe("platform_admin");
expect(u.tenant_id).toBeNull();
expect(u.tenant_slug).toBeNull();
expect(u.brand_id).toBeNull();
expect(u.brand_slug).toBeNull();
expect(u.can_manage_billing).toBe(true);
});
it("returns a brand_admin tied to the tuxedo tenant", () => {
it("returns a brand_admin tied to the tuxedo brand", () => {
const u = buildDevAdmin("brand_admin");
expect(u.role).toBe("brand_admin");
expect(u.tenant_slug).toBe("tuxedo");
expect(u.brand_slug).toBe("tuxedo");
expect(u.can_manage_users).toBe(false);
});