Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
197 lines
6.1 KiB
TypeScript
197 lines
6.1 KiB
TypeScript
"use client";
|
|
|
|
import { useRef, useEffect, useState, useCallback } from "react";
|
|
import type { ReactElement } from "react";
|
|
import { Loader2 } from "lucide-react";
|
|
import { AuthErrorBanner } from "./AuthErrorBanner";
|
|
|
|
export interface LoginFormProps {
|
|
onSubmit: (email: string, password: string) => void | Promise<void>;
|
|
isLoading?: boolean;
|
|
error?: string | null;
|
|
disabled?: boolean;
|
|
}
|
|
|
|
export function LoginForm({
|
|
onSubmit,
|
|
isLoading = false,
|
|
error = null,
|
|
disabled = false,
|
|
}: LoginFormProps): ReactElement {
|
|
const emailRef = useRef<HTMLInputElement>(null);
|
|
const [email, setEmail] = useState("");
|
|
const [password, setPassword] = useState("");
|
|
const [emailError, setEmailError] = useState<string | null>(null);
|
|
const [passwordError, setPasswordError] = useState<string | null>(null);
|
|
const [dismissedError, setDismissedError] = useState(false);
|
|
|
|
useEffect((): void => {
|
|
emailRef.current?.focus();
|
|
}, []);
|
|
|
|
// Reset dismissed state when a new error comes in
|
|
useEffect((): void => {
|
|
if (error) {
|
|
setDismissedError(false);
|
|
}
|
|
}, [error]);
|
|
|
|
const validateEmail = useCallback((value: string): boolean => {
|
|
if (!value.includes("@")) {
|
|
setEmailError("Please enter a valid email address.");
|
|
return false;
|
|
}
|
|
setEmailError(null);
|
|
return true;
|
|
}, []);
|
|
|
|
const validatePassword = useCallback((value: string): boolean => {
|
|
if (value.length === 0) {
|
|
setPasswordError("Password is recommended.");
|
|
return false;
|
|
}
|
|
setPasswordError(null);
|
|
return true;
|
|
}, []);
|
|
|
|
const handleSubmit = (e: React.SyntheticEvent<HTMLFormElement>): void => {
|
|
e.preventDefault();
|
|
|
|
const isEmailValid = validateEmail(email);
|
|
const isPasswordValid = validatePassword(password);
|
|
|
|
if (!isEmailValid || !isPasswordValid) {
|
|
return;
|
|
}
|
|
|
|
void onSubmit(email, password);
|
|
};
|
|
|
|
return (
|
|
<form onSubmit={handleSubmit} className="space-y-4" noValidate>
|
|
{error && !dismissedError && (
|
|
<AuthErrorBanner
|
|
message={error}
|
|
onDismiss={(): void => {
|
|
setDismissedError(true);
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
<div>
|
|
<label
|
|
htmlFor="login-email"
|
|
className="mb-2 block text-[0.72rem] font-semibold uppercase tracking-[0.08em] text-[#2f3b52] dark:text-[#c5d0e6]"
|
|
>
|
|
Email
|
|
</label>
|
|
<input
|
|
ref={emailRef}
|
|
id="login-email"
|
|
type="email"
|
|
value={email}
|
|
onChange={(e): void => {
|
|
setEmail(e.target.value);
|
|
if (emailError) {
|
|
validateEmail(e.target.value);
|
|
}
|
|
}}
|
|
disabled={isLoading || disabled}
|
|
autoComplete="email"
|
|
className={[
|
|
"w-full rounded-lg border px-3.5 py-2.5 text-sm",
|
|
"bg-[#f8faff]/90 text-[#0f141d] placeholder:text-[#5a6a87]",
|
|
"transition-colors focus:outline-none focus:ring-2 focus:ring-[#56a0ff]/25",
|
|
"dark:bg-[#0f141d]/80 dark:text-[#eef3ff] dark:placeholder:text-[#8f9db7]",
|
|
emailError
|
|
? "border-[#f06a6f] focus:border-[#e5484d]"
|
|
: "border-[#b8c4de] focus:border-[#2f80ff] dark:border-[#2f3b52] dark:focus:border-[#56a0ff]",
|
|
isLoading || disabled ? "opacity-50" : "",
|
|
]
|
|
.filter(Boolean)
|
|
.join(" ")}
|
|
aria-invalid={emailError ? "true" : "false"}
|
|
aria-describedby={emailError ? "login-email-error" : undefined}
|
|
/>
|
|
{emailError && (
|
|
<p
|
|
id="login-email-error"
|
|
className="mt-1 text-sm text-[#b91c1c] dark:text-[#fda4af]"
|
|
role="alert"
|
|
>
|
|
{emailError}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<div>
|
|
<label
|
|
htmlFor="login-password"
|
|
className="mb-2 block text-[0.72rem] font-semibold uppercase tracking-[0.08em] text-[#2f3b52] dark:text-[#c5d0e6]"
|
|
>
|
|
Password
|
|
</label>
|
|
<input
|
|
id="login-password"
|
|
type="password"
|
|
value={password}
|
|
onChange={(e): void => {
|
|
setPassword(e.target.value);
|
|
if (passwordError) {
|
|
validatePassword(e.target.value);
|
|
}
|
|
}}
|
|
disabled={isLoading || disabled}
|
|
autoComplete="current-password"
|
|
className={[
|
|
"w-full rounded-lg border px-3.5 py-2.5 text-sm",
|
|
"bg-[#f8faff]/90 text-[#0f141d] placeholder:text-[#5a6a87]",
|
|
"transition-colors focus:outline-none focus:ring-2 focus:ring-[#56a0ff]/25",
|
|
"dark:bg-[#0f141d]/80 dark:text-[#eef3ff] dark:placeholder:text-[#8f9db7]",
|
|
passwordError
|
|
? "border-[#f06a6f] focus:border-[#e5484d]"
|
|
: "border-[#b8c4de] focus:border-[#2f80ff] dark:border-[#2f3b52] dark:focus:border-[#56a0ff]",
|
|
isLoading || disabled ? "opacity-50" : "",
|
|
]
|
|
.filter(Boolean)
|
|
.join(" ")}
|
|
aria-invalid={passwordError ? "true" : "false"}
|
|
aria-describedby={passwordError ? "login-password-error" : undefined}
|
|
/>
|
|
{passwordError && (
|
|
<p
|
|
id="login-password-error"
|
|
className="mt-1 text-sm text-[#b91c1c] dark:text-[#fda4af]"
|
|
role="alert"
|
|
>
|
|
{passwordError}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<button
|
|
type="submit"
|
|
disabled={isLoading || disabled}
|
|
className={[
|
|
"w-full inline-flex items-center justify-center gap-2 rounded-lg px-4 py-3 text-sm font-semibold text-white",
|
|
"bg-[linear-gradient(135deg,#2f80ff,#8b5cf6)]",
|
|
"transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-[#56a0ff]/60",
|
|
"hover:-translate-y-0.5 hover:shadow-[0_10px_30px_rgba(47,128,255,0.38)]",
|
|
isLoading || disabled ? "opacity-50 pointer-events-none" : "",
|
|
]
|
|
.filter(Boolean)
|
|
.join(" ")}
|
|
>
|
|
{isLoading ? (
|
|
<>
|
|
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
|
|
<span>Signing in...</span>
|
|
</>
|
|
) : (
|
|
<span>Continue</span>
|
|
)}
|
|
</button>
|
|
</form>
|
|
);
|
|
}
|