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
+134 -18
View File
@@ -1,29 +1,28 @@
"use client";
import { signInWithGoogle } from "@/actions/auth-actions";
import { useState, useTransition } from "react";
import { signInWithGoogle, signInWithCredentials } from "@/actions/auth-actions";
type LoginClientProps = {
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 }) {
if (!hasGoogle) {
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="mt-1 text-amber-800">
<p className="mt-1 text-stone-600">
Add <code className="font-mono text-xs">AUTH_GOOGLE_ID</code> and{" "}
<code className="font-mono text-xs">AUTH_GOOGLE_SECRET</code> to your
environment to enable it. See{" "}
<a
className="underline"
href="https://authjs.dev/getting-started/providers/google"
target="_blank"
rel="noreferrer"
>
Auth.js Google provider docs
</a>
.
environment to enable it.
</p>
</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 (
<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>{`
@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; }
@@ -69,20 +171,34 @@ export default function LoginClient({ hasGoogle }: LoginClientProps) {
<div className="p-8 sm:p-10">
<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}>
<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>
</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
</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
</p>
</div>
<GoogleSignIn hasGoogle={hasGoogle} />
{hasCredentials && hasGoogle && <Divider />}
{hasCredentials && <CredentialsForm seededEmail={seededEmail} error={error} />}
</div>
</div>
</div>
+26 -3
View File
@@ -1,5 +1,6 @@
import type { Metadata } from "next";
import LoginClient from "./LoginClient";
import { isDevLoginEnabled } from "@/auth.config";
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
// 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.
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"}
/>
);
}