feat: remove dev_session, add Drizzle schema + RLS + real auth

BREAKING: dev_session cookie bypass removed. Admin access now requires
a real Auth.js v5 session (Google OAuth in production). Provision users
by inserting into users + tenant_users tables.

New in this commit:
- db/migrations/0001_init.sql: 18-table SaaS schema with RLS (tenants,
  users, tenant_users, plans, add_ons, subscriptions, tenant_add_ons,
  products, product_images, stops, customers, orders, order_items,
  brand_settings, email_templates, campaigns, files, audit_log)
- db/schema/: Drizzle TypeScript mirror of every table
- db/client.ts: withTenant() / withPlatformAdmin() query wrappers that
  set Postgres GUCs (app.current_tenant_id, app.platform_admin) for
  RLS enforcement. Never query a tenant-scoped table without one.
- db/seed.ts: seeds 3 plans, 6 add-ons, 2 tenants (Tuxedo, Indian River
  Direct), brand_settings, sample products/stops/customers
- scripts/migrate.js: applies migrations in lexical order with tracking
- scripts/db-reset.js: drops + recreates DB, runs migrate + seed
- DATABASE_URL now uses rc_app (non-superuser, NOBYPASSRLS). RLS is
  enforced even for the app user. DATABASE_ADMIN_URL for migrations.
- src/lib/admin-permissions.ts: getAdminUser() reads Auth.js session,
  looks up user + tenant in Postgres. brand_id kept as alias for
  backward compat.
- src/middleware.ts: Auth.js-only route protection, dev_session gone
- src/app/login/LoginClient.tsx: Google OAuth only, no demo mode
- src/components/admin/AdminSidebar.tsx + AdminHeader.tsx: signOutAction
  replaces supabase signout
- @/db/* path aliases in tsconfig.json + vitest.config.ts
- drizzle.config.ts added
- db/auth_schema.sql removed (was a stub; replaced by real schema)
- src/app/api/dev-login/route.ts deleted
- tests: updated to remove dev_session coverage
This commit is contained in:
2026-06-07 01:23:44 +00:00
parent f96dcd01f2
commit 7cd0603cfb
41 changed files with 2917 additions and 1950 deletions
+23 -104
View File
@@ -3,115 +3,34 @@ import { test, expect } from "@playwright/test";
const BASE = process.env.PLAYWRIGHT_URL ?? "http://localhost:3000";
// ─────────────────────────────────────────────────────────────────────
// Login form renders the right affordances
// Login page — Google OAuth is the only login path
// ─────────────────────────────────────────────────────────────────────
test.describe("Login form rendering", () => {
test("renders Google button, email + password fields, and Sign in button", async ({ page }) => {
test.describe("Login page", () => {
test("renders the Google sign-in button", async ({ page }) => {
await page.goto(`${BASE}/login`);
// The Google button is the primary CTA at the top of the form.
await expect(page.getByRole("button", { name: /continue with google/i })).toBeVisible();
// Email + password inputs
await expect(page.locator("#email")).toBeVisible();
await expect(page.locator("#password")).toBeVisible();
// The "Sign in" submit button is on the email/password form (not on the
// Google one). Use the form's aria-label to scope the lookup.
const signInForm = page.getByRole("form", { name: /sign in form/i });
await expect(signInForm.getByRole("button", { name: /^sign in$/i })).toBeVisible();
});
test("missing email surfaces browser required validation", async ({ page }) => {
test("shows a friendly 'not configured' message when Google OAuth env is missing", async ({ page }) => {
// The page either shows the Google button (when AUTH_GOOGLE_ID/SECRET
// are set) or a setup message (when they aren't). We accept both as
// correct renderings.
await page.goto(`${BASE}/login`);
await page.locator("#password").fill("something");
// Don't fill email
const signInForm = page.getByRole("form", { name: /sign in form/i });
await signInForm.getByRole("button", { name: /^sign in$/i }).click();
// The email input has `required` — the browser should block submission
// and the email field remains focused. We don't assert on URL change.
await expect(page.locator("#email")).toHaveAttribute("required", "");
});
});
// ─────────────────────────────────────────────────────────────────────
// Demo mode (?demo=1) — the path that works without a Supabase backend
// ─────────────────────────────────────────────────────────────────────
test.describe("Demo mode (?demo=1)", () => {
test("Platform Admin button sets dev_session and lands on /admin", async ({ page, context }) => {
await page.goto(`${BASE}/login?demo=1`);
await page.getByRole("button", { name: /platform admin/i }).click();
// The click sets the cookie client-side and navigates. We should land
// on the admin dashboard.
await page.waitForURL(/\/admin/, { timeout: 10_000 });
const cookies = await context.cookies();
expect(cookies.find((c) => c.name === "dev_session")?.value).toBe("platform_admin");
});
test("Brand Admin button sets dev_session=brand_admin", async ({ page, context }) => {
await page.goto(`${BASE}/login?demo=1`);
await page.getByRole("button", { name: /brand admin/i }).click();
await page.waitForURL(/\/admin/, { timeout: 10_000 });
const cookies = await context.cookies();
expect(cookies.find((c) => c.name === "dev_session")?.value).toBe("brand_admin");
});
test("Store Employee button sets dev_session=store_employee", async ({ page, context }) => {
await page.goto(`${BASE}/login?demo=1`);
await page.getByRole("button", { name: /store employee/i }).click();
await page.waitForURL(/\/admin/, { timeout: 10_000 });
const cookies = await context.cookies();
expect(cookies.find((c) => c.name === "dev_session")?.value).toBe("store_employee");
});
});
// ─────────────────────────────────────────────────────────────────────
// Credentials sign-in (skipped if env vars aren't set — needs a real
// Supabase auth user to actually succeed)
// ─────────────────────────────────────────────────────────────────────
test.describe("Credentials sign-in", () => {
test.skip(
!process.env.TEST_ADMIN_EMAIL || !process.env.TEST_ADMIN_PASSWORD,
"Set TEST_ADMIN_EMAIL and TEST_ADMIN_PASSWORD to run the credentials flow against a real Supabase backend.",
);
test("valid credentials redirect to /admin", async ({ page }) => {
await page.goto(`${BASE}/login`);
await page.locator("#email").fill(process.env.TEST_ADMIN_EMAIL!);
await page.locator("#password").fill(process.env.TEST_ADMIN_PASSWORD!);
const signInForm = page.getByRole("form", { name: /sign in form/i });
await signInForm.getByRole("button", { name: /^sign in$/i }).click();
await page.waitForURL(/\/admin/, { timeout: 15_000 });
await expect(page.locator("body")).not.toContainText("Access Denied");
});
test("wrong password shows an error and stays on /login", async ({ page }) => {
await page.goto(`${BASE}/login`);
await page.locator("#email").fill(process.env.TEST_ADMIN_EMAIL!);
await page.locator("#password").fill("definitely-wrong-password");
const signInForm = page.getByRole("form", { name: /sign in form/i });
await signInForm.getByRole("button", { name: /^sign in$/i }).click();
// The error banner uses role="alert"
await expect(page.locator('[role="alert"]')).toBeVisible({ timeout: 8_000 });
await expect(page).toHaveURL(/\/login/);
});
test("session persists across reload", async ({ page }) => {
await page.goto(`${BASE}/login`);
await page.locator("#email").fill(process.env.TEST_ADMIN_EMAIL!);
await page.locator("#password").fill(process.env.TEST_ADMIN_PASSWORD!);
const signInForm = page.getByRole("form", { name: /sign in form/i });
await signInForm.getByRole("button", { name: /^sign in$/i }).click();
await page.waitForURL(/\/admin/, { timeout: 15_000 });
await page.reload();
await expect(page).toHaveURL(/\/admin/);
await expect(page.locator("body")).not.toContainText("Access Denied");
const hasGoogle = await page
.getByRole("button", { name: /continue with google/i })
.isVisible()
.catch(() => false);
const hasSetup = await page
.getByText(/Google sign-in is not configured/i)
.isVisible()
.catch(() => false);
expect(hasGoogle || hasSetup).toBe(true);
});
test("/admin redirects to /login when not authenticated", async ({ page }) => {
const res = await page.goto(`${BASE}/admin`, { waitUntil: "domcontentloaded" });
// After redirect, the URL should be /login (with ?redirect=/admin).
expect(page.url()).toMatch(/\/login(\?|$)/);
expect(res?.status()).toBeLessThan(500);
});
});