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:
+134
-18
@@ -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
@@ -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"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user