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:
+35
-6
@@ -16,6 +16,7 @@
|
|||||||
*/
|
*/
|
||||||
import "dotenv/config";
|
import "dotenv/config";
|
||||||
import { Pool } from "pg";
|
import { Pool } from "pg";
|
||||||
|
import { hashPassword } from "../src/lib/passwords";
|
||||||
|
|
||||||
// Seed needs elevated privileges (inserts into plans, add_ons, tenants —
|
// Seed needs elevated privileges (inserts into plans, add_ons, tenants —
|
||||||
// tables that are intentionally not RLS-scoped but the runtime app user
|
// 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`);
|
console.log(`Seeded ${tenantsData.length} tenants with sample data`);
|
||||||
|
|
||||||
// ── Platform admin user (not tied to any single tenant) ──────────────
|
// ── Platform admin (seeded for dev sign-in via Credentials provider) ─
|
||||||
await client.query(
|
// email: admin@route-commerce.local
|
||||||
`INSERT INTO users (email, name, auth_provider, auth_subject)
|
// password: admin (override with SEED_ADMIN_PASSWORD)
|
||||||
VALUES ('platform@route-commerce.example', 'Platform Admin', 'dev', 'dev-platform-admin')
|
//
|
||||||
ON CONFLICT (email) DO UPDATE SET name = EXCLUDED.name`,
|
// 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");
|
await client.query("COMMIT");
|
||||||
console.log("✅ Seed complete");
|
console.log("✅ Seed complete");
|
||||||
|
|||||||
+134
-18
@@ -1,29 +1,28 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { signInWithGoogle } from "@/actions/auth-actions";
|
import { useState, useTransition } from "react";
|
||||||
|
import { signInWithGoogle, signInWithCredentials } from "@/actions/auth-actions";
|
||||||
|
|
||||||
type LoginClientProps = {
|
type LoginClientProps = {
|
||||||
hasGoogle: boolean;
|
hasGoogle: boolean;
|
||||||
|
hasCredentials: boolean;
|
||||||
|
/** Server-rendered error message, if any (from ?error=...) */
|
||||||
|
error: string | null;
|
||||||
|
/** Pre-fill the email in dev (for the seeded admin). */
|
||||||
|
seededEmail?: string;
|
||||||
|
/** Where to send the user after a successful sign-in. */
|
||||||
|
redirectTo?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function GoogleSignIn({ hasGoogle }: { hasGoogle: boolean }) {
|
function GoogleSignIn({ hasGoogle }: { hasGoogle: boolean }) {
|
||||||
if (!hasGoogle) {
|
if (!hasGoogle) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
|
<div className="rounded-xl border border-stone-200/80 bg-stone-50 p-4 text-sm text-stone-700">
|
||||||
<p className="font-medium">Google sign-in is not configured.</p>
|
<p className="font-medium">Google sign-in is not configured.</p>
|
||||||
<p className="mt-1 text-amber-800">
|
<p className="mt-1 text-stone-600">
|
||||||
Add <code className="font-mono text-xs">AUTH_GOOGLE_ID</code> and{" "}
|
Add <code className="font-mono text-xs">AUTH_GOOGLE_ID</code> and{" "}
|
||||||
<code className="font-mono text-xs">AUTH_GOOGLE_SECRET</code> to your
|
<code className="font-mono text-xs">AUTH_GOOGLE_SECRET</code> to your
|
||||||
environment to enable it. See{" "}
|
environment to enable it.
|
||||||
<a
|
|
||||||
className="underline"
|
|
||||||
href="https://authjs.dev/getting-started/providers/google"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
Auth.js Google provider docs
|
|
||||||
</a>
|
|
||||||
.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -48,9 +47,112 @@ function GoogleSignIn({ hasGoogle }: { hasGoogle: boolean }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function LoginClient({ hasGoogle }: LoginClientProps) {
|
function CredentialsForm({
|
||||||
|
seededEmail,
|
||||||
|
error,
|
||||||
|
}: {
|
||||||
|
seededEmail?: string;
|
||||||
|
error: string | null;
|
||||||
|
}) {
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
const [localError, setLocalError] = useState<string | null>(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen flex flex-col relative overflow-hidden" style={{ backgroundColor: "#faf8f5", height: "100vh" }}>
|
<form
|
||||||
|
action={(formData) => {
|
||||||
|
setLocalError(null);
|
||||||
|
startTransition(async () => {
|
||||||
|
try {
|
||||||
|
await signInWithCredentials(formData);
|
||||||
|
} catch (err) {
|
||||||
|
// Auth.js throws NEXT_REDIRECT on success — that's the normal
|
||||||
|
// flow. We only care about non-redirect errors here.
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
if (!msg.includes("NEXT_REDIRECT")) {
|
||||||
|
setLocalError("Sign-in failed. Please try again.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="space-y-3"
|
||||||
|
>
|
||||||
|
<label className="block">
|
||||||
|
<span className="block text-xs font-medium text-stone-700 mb-1.5" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}>
|
||||||
|
Email
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
autoComplete="username"
|
||||||
|
defaultValue={seededEmail ?? ""}
|
||||||
|
className="w-full rounded-xl border border-stone-200/80 bg-white px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 focus:outline-none focus:ring-2 focus:ring-[#6b8f71]/40 focus:border-[#6b8f71]"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="block text-xs font-medium text-stone-700 mb-1.5" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}>
|
||||||
|
Password
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
autoComplete="current-password"
|
||||||
|
className="w-full rounded-xl border border-stone-200/80 bg-white px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 focus:outline-none focus:ring-2 focus:ring-[#6b8f71]/40 focus:border-[#6b8f71]"
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{(error || localError) && (
|
||||||
|
<p className="text-sm text-rose-700 bg-rose-50 border border-rose-200 rounded-lg px-3 py-2">
|
||||||
|
{error ?? localError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isPending}
|
||||||
|
className="w-full rounded-xl px-6 py-3.5 text-sm font-semibold text-white shadow-sm transition-all disabled:opacity-60 disabled:cursor-not-allowed active:scale-[0.98]"
|
||||||
|
style={{
|
||||||
|
fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif",
|
||||||
|
background: isPending
|
||||||
|
? "linear-gradient(135deg, #6b8f71 0%, #7ba085 100%)"
|
||||||
|
: "linear-gradient(135deg, #1a4d2e 0%, #2d6a45 100%)",
|
||||||
|
boxShadow: "0 8px 24px rgba(26, 77, 46, 0.20)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isPending ? "Signing in…" : "Sign in with email"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Divider() {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3 my-5" aria-hidden="true">
|
||||||
|
<div className="flex-1 h-px bg-stone-200/80" />
|
||||||
|
<span className="text-xs uppercase tracking-wider text-stone-400" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}>
|
||||||
|
or
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 h-px bg-stone-200/80" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LoginClient({
|
||||||
|
hasGoogle,
|
||||||
|
hasCredentials,
|
||||||
|
error,
|
||||||
|
seededEmail,
|
||||||
|
}: LoginClientProps) {
|
||||||
|
// Render the Google button first (or a setup message), then the divider,
|
||||||
|
// then the credentials form if dev login is enabled. The order is the
|
||||||
|
// most-common-first progression: prod users see Google; dev users
|
||||||
|
// see Google at top, email/password below as the fast path.
|
||||||
|
return (
|
||||||
|
<main
|
||||||
|
className="min-h-screen flex flex-col relative overflow-hidden"
|
||||||
|
style={{ backgroundColor: "#faf8f5", height: "100vh" }}
|
||||||
|
>
|
||||||
<style jsx global>{`
|
<style jsx global>{`
|
||||||
@import url("https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,500;0,600;0,700;1,400&family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap");
|
@import url("https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,500;0,600;0,700;1,400&family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap");
|
||||||
html, body { overflow: hidden; }
|
html, body { overflow: hidden; }
|
||||||
@@ -69,20 +171,34 @@ export default function LoginClient({ hasGoogle }: LoginClientProps) {
|
|||||||
|
|
||||||
<div className="p-8 sm:p-10">
|
<div className="p-8 sm:p-10">
|
||||||
<div className="text-center mb-8">
|
<div className="text-center mb-8">
|
||||||
<div className="inline-flex h-16 w-16 items-center justify-center rounded-2xl mb-5" style={{ background: "linear-gradient(135deg, #1a4d2e 0%, #2d6a45 100%)", boxShadow: "0 12px 32px rgba(26, 77, 46, 0.25)" }}>
|
<div
|
||||||
|
className="inline-flex h-16 w-16 items-center justify-center rounded-2xl mb-5"
|
||||||
|
style={{
|
||||||
|
background: "linear-gradient(135deg, #1a4d2e 0%, #2d6a45 100%)",
|
||||||
|
boxShadow: "0 12px 32px rgba(26, 77, 46, 0.25)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<svg className="h-8 w-8 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
<svg className="h-8 w-8 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-3xl font-semibold text-stone-900" style={{ fontFamily: "'Cormorant Garamond', Georgia, serif", letterSpacing: "-0.02em" }}>
|
<h1
|
||||||
|
className="text-3xl font-semibold text-stone-900"
|
||||||
|
style={{ fontFamily: "'Cormorant Garamond', Georgia, serif", letterSpacing: "-0.02em" }}
|
||||||
|
>
|
||||||
Welcome back
|
Welcome back
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-2 text-sm" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#7a7570" }}>
|
<p
|
||||||
|
className="mt-2 text-sm"
|
||||||
|
style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#7a7570" }}
|
||||||
|
>
|
||||||
Sign in to your account
|
Sign in to your account
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<GoogleSignIn hasGoogle={hasGoogle} />
|
<GoogleSignIn hasGoogle={hasGoogle} />
|
||||||
|
{hasCredentials && hasGoogle && <Divider />}
|
||||||
|
{hasCredentials && <CredentialsForm seededEmail={seededEmail} error={error} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+26
-3
@@ -1,5 +1,6 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import LoginClient from "./LoginClient";
|
import LoginClient from "./LoginClient";
|
||||||
|
import { isDevLoginEnabled } from "@/auth.config";
|
||||||
|
|
||||||
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://routecommerce.com";
|
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://routecommerce.com";
|
||||||
|
|
||||||
@@ -21,10 +22,32 @@ export const metadata: Metadata = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function LoginPage() {
|
type SearchParams = { error?: string; redirect?: string };
|
||||||
|
|
||||||
|
export default async function LoginPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<SearchParams>;
|
||||||
|
}) {
|
||||||
// The Google provider is only added to the Auth.js config when these
|
// The Google provider is only added to the Auth.js config when these
|
||||||
// two env vars are set. Pass the flag down so the client can hide the
|
// two env vars are set. Pass the flag down so the client can hide the
|
||||||
// button (and surface a helpful message) when Google is unavailable.
|
// button (and surface a helpful message) when Google is unavailable.
|
||||||
const hasGoogle = !!(process.env.AUTH_GOOGLE_ID && process.env.AUTH_GOOGLE_SECRET);
|
const hasGoogle = !!(process.env.AUTH_GOOGLE_ID && process.env.AUTH_GOOGLE_SECRET);
|
||||||
return <LoginClient hasGoogle={hasGoogle} />;
|
const hasCredentials = isDevLoginEnabled();
|
||||||
}
|
const params = await searchParams;
|
||||||
|
const error =
|
||||||
|
params?.error === "CredentialsSignin" || params?.error === "MissingCredentials"
|
||||||
|
? "Invalid email or password."
|
||||||
|
: params?.error
|
||||||
|
? "Sign-in failed. Please try again."
|
||||||
|
: null;
|
||||||
|
return (
|
||||||
|
<LoginClient
|
||||||
|
hasGoogle={hasGoogle}
|
||||||
|
hasCredentials={hasCredentials}
|
||||||
|
error={error}
|
||||||
|
seededEmail={hasCredentials ? "admin@route-commerce.local" : undefined}
|
||||||
|
redirectTo={params?.redirect ?? "/admin"}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user