feat(#415): theme fix, AuthDivider, SessionExpiryWarning components
All checks were successful
ci/woodpecker/push/orchestrator Pipeline was successful
ci/woodpecker/push/web Pipeline was successful
ci/woodpecker/push/api Pipeline was successful

- 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:
Jason Woltje
2026-02-16 11:37:31 -06:00
parent 9623a3be97
commit 81b5204258
13 changed files with 899 additions and 4 deletions

View 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>
);
}