feat(#415): theme fix, AuthDivider, SessionExpiryWarning components
- AUTH-014: Fix theme storage key (jarvis-theme -> mosaic-theme) - AUTH-016: Create AuthDivider component with customizable text - AUTH-019: Create SessionExpiryWarning floating banner (PDA-friendly, blue) - Fix lint errors in LoginForm, OAuthButton from parallel agents - Sync pnpm-lock.yaml for recharts dependency Refs #415 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
172
apps/web/src/components/auth/LoginForm.tsx
Normal file
172
apps/web/src/components/auth/LoginForm.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
"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;
|
||||
}
|
||||
|
||||
export function LoginForm({
|
||||
onSubmit,
|
||||
isLoading = false,
|
||||
error = null,
|
||||
}: 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="block text-sm font-medium text-gray-700 mb-1">
|
||||
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}
|
||||
autoComplete="email"
|
||||
className={[
|
||||
"w-full px-3 py-2 border rounded-md",
|
||||
"focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors",
|
||||
emailError ? "border-blue-400" : "border-gray-300",
|
||||
isLoading ? "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-blue-600" role="alert">
|
||||
{emailError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="login-password" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
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}
|
||||
autoComplete="current-password"
|
||||
className={[
|
||||
"w-full px-3 py-2 border rounded-md",
|
||||
"focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors",
|
||||
passwordError ? "border-blue-400" : "border-gray-300",
|
||||
isLoading ? "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-blue-600" role="alert">
|
||||
{passwordError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className={[
|
||||
"w-full inline-flex items-center justify-center gap-2",
|
||||
"rounded-md px-4 py-2 text-base font-medium",
|
||||
"bg-blue-600 text-white hover:bg-blue-700",
|
||||
"transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500",
|
||||
isLoading ? "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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user