290 lines
13 KiB
TypeScript
290 lines
13 KiB
TypeScript
"use client";
|
|
|
|
import Link from "next/link";
|
|
import { useCallback, useState, useId, useTransition } from "react";
|
|
import { devLoginAction } from "@/actions/auth-actions";
|
|
|
|
type LoginClientProps = {
|
|
error: string | null;
|
|
};
|
|
|
|
const REDIRECT_URL = "/admin";
|
|
|
|
// Development mode detection
|
|
const isDev = process.env.NODE_ENV !== "production";
|
|
|
|
export default function LoginClient({ error }: LoginClientProps) {
|
|
const emailId = useId();
|
|
const passwordId = useId();
|
|
const [email, setEmail] = useState("");
|
|
const [password, setPassword] = useState("");
|
|
const [loading, setLoading] = useState(false);
|
|
const [googleLoading, setGoogleLoading] = useState(false);
|
|
const [localError, setLocalError] = useState<string | null>(null);
|
|
const [, startDevLoginTransition] = useTransition();
|
|
|
|
async function handleSignIn(e: React.FormEvent<HTMLFormElement>) {
|
|
e.preventDefault();
|
|
if (!email || !password) {
|
|
setLocalError("Email and password are required.");
|
|
return;
|
|
}
|
|
setLocalError(null);
|
|
setLoading(true);
|
|
|
|
try {
|
|
const res = await fetch("/api/auth/sign-in", {
|
|
method: "POST",
|
|
credentials: "include",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ email: email.trim().toLowerCase(), password: password.trim() }),
|
|
});
|
|
|
|
const data = await res.json();
|
|
if (!res.ok || data.error) {
|
|
setLocalError(data.message ?? "Sign-in failed. Please check your email and password.");
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
// Cookie is set server-side via next/headers — just redirect on success.
|
|
window.location.href = REDIRECT_URL;
|
|
} catch {
|
|
setLocalError("Sign-in failed. Please try again.");
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
async function handleGoogleSignIn() {
|
|
setGoogleLoading(true);
|
|
setLocalError(null);
|
|
try {
|
|
const { signInWithGoogleAction } = await import("@/actions/auth-actions");
|
|
const result = await signInWithGoogleAction({ callbackURL: REDIRECT_URL });
|
|
if (result.error || !result.url) {
|
|
setLocalError(
|
|
result.error ??
|
|
"Google sign-in is not available. Contact your administrator or sign in with email + password.",
|
|
);
|
|
setGoogleLoading(false);
|
|
return;
|
|
}
|
|
window.location.href = result.url;
|
|
} catch {
|
|
setLocalError("Google sign-in failed. Please try again.");
|
|
setGoogleLoading(false);
|
|
}
|
|
}
|
|
|
|
const handleDevLogin = useCallback((role: string) => {
|
|
// Server action sets the dev_session cookie with httpOnly + sameSite
|
|
// so it's safe against XSS-driven theft. The action is a no-op in
|
|
// production, so this is only a dev escape hatch.
|
|
startDevLoginTransition(async () => {
|
|
await devLoginAction(role);
|
|
window.location.assign(REDIRECT_URL);
|
|
});
|
|
}, []);
|
|
|
|
const displayError = error ?? localError;
|
|
|
|
return (
|
|
<main className="atelier-canvas relative min-h-screen flex flex-col overflow-hidden">
|
|
{/* Decorative grain layer */}
|
|
<div className="atelier-grain" aria-hidden="true" />
|
|
|
|
{/* Ambient background flourishes */}
|
|
<div className="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden="true">
|
|
<div
|
|
className="absolute -top-32 -right-32 w-[28rem] h-[28rem] rounded-full opacity-30"
|
|
style={{ background: "radial-gradient(circle at 30% 30%, rgba(202, 138, 4, 0.18) 0%, transparent 70%)", filter: "blur(8px)" }}
|
|
/>
|
|
<div
|
|
className="absolute -bottom-48 -left-48 w-[36rem] h-[36rem] rounded-full opacity-25"
|
|
style={{ background: "radial-gradient(circle at 70% 70%, rgba(34, 78, 47, 0.16) 0%, transparent 70%)", filter: "blur(8px)" }}
|
|
/>
|
|
</div>
|
|
|
|
{/* Editorial corner flourish */}
|
|
<div className="absolute top-6 left-6 atelier-flourish w-20 h-20 opacity-40" aria-hidden="true" />
|
|
|
|
<div className="flex-1 flex items-center justify-center px-6 py-12 relative z-10">
|
|
<div className="w-full max-w-md">
|
|
{/* Editorial numeral + section label */}
|
|
<div className="text-center mb-6">
|
|
<span className="atelier-section-num">No. 01</span>
|
|
</div>
|
|
|
|
<div className="atelier-enter relative bg-white/90 backdrop-blur-xl rounded-2xl ring-1 ring-stone-200/80 shadow-[0_24px_64px_-24px_rgba(20,83,45,0.25)] overflow-hidden">
|
|
{/* Top hairline rule */}
|
|
<div className="atelier-rule" />
|
|
|
|
<div className="px-8 sm:px-10 py-10">
|
|
{/* Brand mark */}
|
|
<div className="flex flex-col items-center mb-8">
|
|
<div
|
|
className="inline-flex h-14 w-14 items-center justify-center rounded-2xl mb-5"
|
|
style={{
|
|
background: "linear-gradient(135deg, #14532D 0%, #1F6B3E 50%, #14532D 100%)",
|
|
boxShadow: "0 10px 28px -6px rgba(20, 83, 45, 0.45), inset 0 1px 0 rgba(255, 255, 255, 0.12)",
|
|
}}
|
|
>
|
|
<svg className="h-7 w-7 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} aria-hidden="true">
|
|
<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="atelier-title text-3xl sm:text-4xl text-center">
|
|
Welcome <span className="atelier-italic text-[#786B53]">back</span>
|
|
</h1>
|
|
<p className="mt-2 text-sm text-stone-500 text-center">
|
|
Sign in to your <em className="atelier-italic not-italic font-normal">atelier</em> account
|
|
</p>
|
|
</div>
|
|
|
|
<form
|
|
onSubmit={handleSignIn}
|
|
className="space-y-5"
|
|
noValidate
|
|
>
|
|
{/* Google sign-in (primary path — works for both new and existing users) */}
|
|
<button
|
|
type="button"
|
|
onClick={handleGoogleSignIn}
|
|
disabled={googleLoading || loading}
|
|
className="atelier-cta atelier-cta-secondary w-full"
|
|
style={{ background: "white", color: "#1c1917", border: "1px solid #e7e5e4", boxShadow: "0 1px 2px rgba(0,0,0,0.04)" }}
|
|
>
|
|
{googleLoading ? (
|
|
<span className="relative z-10 inline-flex items-center gap-2">
|
|
<svg className="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
|
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
|
</svg>
|
|
Redirecting to Google…
|
|
</span>
|
|
) : (
|
|
<span className="relative z-10 inline-flex items-center gap-2.5">
|
|
<svg className="h-4 w-4" viewBox="0 0 24 24" aria-hidden="true">
|
|
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" />
|
|
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" />
|
|
<path fill="#FBBC05" d="M5.84 14.1c-.22-.66-.35-1.36-.35-2.1s.13-1.44.35-2.1V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l3.66-2.83z" />
|
|
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" />
|
|
</svg>
|
|
Continue with Google
|
|
</span>
|
|
)}
|
|
</button>
|
|
|
|
{/* Divider */}
|
|
<div className="flex items-center gap-3" aria-hidden="true">
|
|
<div className="h-px flex-1 bg-stone-200" />
|
|
<span className="text-[10px] font-semibold tracking-[0.15em] uppercase text-stone-400" style={{ fontFamily: "var(--font-fragment-mono)" }}>
|
|
or
|
|
</span>
|
|
<div className="h-px flex-1 bg-stone-200" />
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-1.5">
|
|
<label htmlFor={emailId} className="atelier-section-label">
|
|
Email
|
|
</label>
|
|
<input aria-label="You@yourfarm.com"
|
|
id={emailId}
|
|
type="email"
|
|
required
|
|
autoComplete="username"
|
|
className="atelier-input"
|
|
placeholder="you@yourfarm.com"
|
|
value={email}
|
|
onChange={(e) => setEmail(e.target.value)}
|
|
disabled={loading}
|
|
/>
|
|
</div>
|
|
<div className="flex flex-col gap-1.5">
|
|
<div className="flex items-center justify-between">
|
|
<label htmlFor={passwordId} className="atelier-section-label">
|
|
Password
|
|
</label>
|
|
<Link href="/forgot-password" className="text-[10px] tracking-[0.15em] uppercase text-stone-500 hover:text-[#14532D] transition-colors" style={{ fontFamily: "var(--font-fragment-mono)" }}>
|
|
Forgot?
|
|
</Link>
|
|
</div>
|
|
<input aria-label="••••••••"
|
|
id={passwordId}
|
|
type="password"
|
|
required
|
|
autoComplete="current-password"
|
|
className="atelier-input"
|
|
placeholder="••••••••"
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
disabled={loading}
|
|
/>
|
|
</div>
|
|
|
|
{displayError && (
|
|
<div
|
|
role="alert"
|
|
className="atelier-stagger rounded-lg border border-rose-200/80 bg-rose-50/80 px-3.5 py-2.5 text-sm text-rose-800 flex items-start gap-2"
|
|
>
|
|
<svg className="h-4 w-4 mt-0.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} aria-hidden="true">
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
|
</svg>
|
|
<span>{displayError}</span>
|
|
</div>
|
|
)}
|
|
|
|
<button
|
|
type="submit"
|
|
disabled={loading}
|
|
className="atelier-cta w-full"
|
|
>
|
|
<span className="relative z-10">{loading ? "Signing in…" : "Sign in"}</span>
|
|
{!loading && (
|
|
<svg className="h-4 w-4 relative z-10" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M13 7l5 5m0 0l-5 5m5-5H6" />
|
|
</svg>
|
|
)}
|
|
<span className="atelier-cta-shimmer" aria-hidden="true" />
|
|
</button>
|
|
</form>
|
|
|
|
{/* Development bypass buttons */}
|
|
{isDev && (
|
|
<div className="mt-8 pt-6 border-t border-stone-200/80">
|
|
<p className="atelier-section-label text-center mb-3">Dev Mode · Quick Access</p>
|
|
<div className="grid grid-cols-3 gap-2">
|
|
{[
|
|
{ role: "platform_admin", label: "Platform", color: "#14532D" },
|
|
{ role: "brand_admin", label: "Brand", color: "#1F6B3E" },
|
|
{ role: "store_employee", label: "Store", color: "#6F8562" },
|
|
].map((opt) => (
|
|
<button
|
|
key={opt.role}
|
|
type="button"
|
|
onClick={() => handleDevLogin(opt.role)}
|
|
className="rounded-lg px-3 py-2 text-[11px] font-semibold text-white transition-all hover:opacity-90 active:scale-[0.98]"
|
|
style={{
|
|
fontFamily: "var(--font-manrope)",
|
|
background: opt.color,
|
|
letterSpacing: "0.04em",
|
|
}}
|
|
>
|
|
{opt.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<p className="atelier-fineprint text-center mt-6">
|
|
Protected by Neon Auth · Encrypted at rest
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
);
|
|
}
|