feat(auth): seed admin user + email/password login UI

- db/seed.ts: upserts admin@route-commerce.local with a scrypt password
  hash (default password 'admin', override with SEED_ADMIN_PASSWORD)
  and grants them the platform_admin role on the Tuxedo tenant so
  getAdminUser() resolves it for the Credentials provider's authorize
  function
- src/app/login/page.tsx: reads ?error=... from the URL and passes
  hasCredentials + seededEmail + error to the client so the form
  pre-fills in dev and surfaces 'Invalid email or password' cleanly
- src/app/login/LoginClient.tsx: adds the email + password form below
  the Google button, with a divider, dev-mode pre-fill, and local error
  handling for client-side failures (server-side failures come back
  through the ?error=... param)
This commit is contained in:
2026-06-07 01:51:53 +00:00
parent ccfd89be75
commit 4da7aae5ce
3 changed files with 195 additions and 27 deletions
+35 -6
View File
@@ -16,6 +16,7 @@
*/
import "dotenv/config";
import { Pool } from "pg";
import { hashPassword } from "../src/lib/passwords";
// Seed needs elevated privileges (inserts into plans, add_ons, tenants —
// tables that are intentionally not RLS-scoped but the runtime app user
@@ -258,13 +259,41 @@ async function main() {
}
console.log(`Seeded ${tenantsData.length} tenants with sample data`);
// ── Platform admin user (not tied to any single tenant) ─────────────
await client.query(
`INSERT INTO users (email, name, auth_provider, auth_subject)
VALUES ('platform@route-commerce.example', 'Platform Admin', 'dev', 'dev-platform-admin')
ON CONFLICT (email) DO UPDATE SET name = EXCLUDED.name`,
// ── Platform admin (seeded for dev sign-in via Credentials provider)
// email: admin@route-commerce.local
// password: admin (override with SEED_ADMIN_PASSWORD)
//
// The user is attached to the Tuxedo tenant with the `platform_admin`
// role so `getAdminUser()` resolves it and grants cross-tenant
// visibility. To rotate the password, re-run `npm run db:seed`
// (the UPSERT updates `password_hash`).
const tuxedoRes = await client.query<{ id: string }>(
`SELECT id FROM tenants WHERE slug = 'tuxedo'`,
);
console.log("Seeded platform admin user");
const tuxedoId = tuxedoRes.rows[0]?.id;
if (tuxedoId) {
const adminPassword = process.env.SEED_ADMIN_PASSWORD ?? "admin";
const passwordHash = hashPassword(adminPassword);
const adminRes = await client.query<{ id: string }>(
`INSERT INTO users (email, name, auth_provider, auth_subject, password_hash)
VALUES ($1, 'Platform Admin', 'dev', 'dev-platform-admin', $2)
ON CONFLICT (email) DO UPDATE SET
name = EXCLUDED.name,
password_hash = EXCLUDED.password_hash
RETURNING id`,
["admin@route-commerce.local", passwordHash],
);
const adminId = adminRes.rows[0].id;
await client.query(
`INSERT INTO tenant_users (tenant_id, user_id, role)
VALUES ($1, $2, 'platform_admin')
ON CONFLICT (tenant_id, user_id) DO UPDATE SET role = EXCLUDED.role`,
[tuxedoId, adminId],
);
console.log(
`Seeded platform admin: admin@route-commerce.local / ${adminPassword === "admin" ? "admin" : "(SEED_ADMIN_PASSWORD)"}`,
);
}
await client.query("COMMIT");
console.log("✅ Seed complete");