// ABOUTME: Login page for user authentication. // ABOUTME: Provides OIDC (Pocket-ID) login with email/password fallback. "use client"; import { useRouter } from "next/navigation"; import { type FormEvent, useCallback, useEffect, useState } from "react"; import { pb } from "@/lib/pocketbase"; interface AuthProvider { name: string; displayName: string; state: string; codeVerifier: string; codeChallenge: string; codeChallengeMethod: string; authURL: string; } const RATE_LIMIT_MAX_ATTEMPTS = 5; const RATE_LIMIT_WINDOW_MS = 60000; // 1 minute export default function LoginPage() { const router = useRouter(); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [error, setError] = useState(null); const [isLoading, setIsLoading] = useState(false); const [isCheckingAuth, setIsCheckingAuth] = useState(true); const [oidcProvider, setOidcProvider] = useState(null); const [loginAttempts, setLoginAttempts] = useState([]); const [isRateLimited, setIsRateLimited] = useState(false); // Get recent attempts within the rate limit window const getRecentAttempts = useCallback(() => { const now = Date.now(); return loginAttempts.filter( (timestamp) => now - timestamp < RATE_LIMIT_WINDOW_MS, ); }, [loginAttempts]); // Calculate remaining attempts const getRemainingAttempts = useCallback(() => { const recentAttempts = getRecentAttempts(); return Math.max(0, RATE_LIMIT_MAX_ATTEMPTS - recentAttempts.length); }, [getRecentAttempts]); // Check if rate limited const checkRateLimited = useCallback(() => { const recentAttempts = getRecentAttempts(); return recentAttempts.length >= RATE_LIMIT_MAX_ATTEMPTS; }, [getRecentAttempts]); // Record a login attempt const recordAttempt = useCallback(() => { const now = Date.now(); setLoginAttempts((prev) => [ ...prev.filter((t) => now - t < RATE_LIMIT_WINDOW_MS), now, ]); }, []); // Check available auth methods on mount useEffect(() => { const checkAuthMethods = async () => { try { const authMethods = await pb.collection("users").listAuthMethods(); const oidc = authMethods.oauth2?.providers?.find( (p: AuthProvider) => p.name === "oidc", ); if (oidc) { setOidcProvider(oidc); } } catch { // If listAuthMethods fails, fall back to email/password } finally { setIsCheckingAuth(false); } }; checkAuthMethods(); }, []); // Check rate limit status and set up cooldown timer useEffect(() => { if (checkRateLimited()) { setIsRateLimited(true); setError("Too many login attempts. Please try again in 1 minute."); // Set a timer to clear rate limit after window expires const oldestAttempt = loginAttempts[0]; const timeUntilReset = oldestAttempt ? RATE_LIMIT_WINDOW_MS - (Date.now() - oldestAttempt) : RATE_LIMIT_WINDOW_MS; const timer = setTimeout( () => { setIsRateLimited(false); setError(null); // Clean up old attempts setLoginAttempts((prev) => prev.filter((t) => Date.now() - t < RATE_LIMIT_WINDOW_MS), ); }, Math.max(0, timeUntilReset), ); return () => clearTimeout(timer); } setIsRateLimited(false); }, [loginAttempts, checkRateLimited]); const handleOidcLogin = async () => { // Check rate limit before attempting if (checkRateLimited()) { setError("Too many login attempts. Please try again in 1 minute."); return; } setIsLoading(true); setError(null); try { await pb.collection("users").authWithOAuth2({ provider: "oidc" }); // Reset attempts on successful login setLoginAttempts([]); router.push("/"); } catch (err) { // Record the failed attempt recordAttempt(); const message = err instanceof Error ? err.message : "Authentication failed"; const remaining = getRemainingAttempts() - 1; // -1 because we just recorded an attempt if (remaining > 0 && remaining <= 2) { setError( `${message}. ${remaining} attempt${remaining === 1 ? "" : "s"} remaining.`, ); } else { setError(message); } setIsLoading(false); } }; const handleSubmit = async (e: FormEvent) => { e.preventDefault(); // Basic validation - don't submit with empty fields if (!email.trim() || !password.trim()) { return; } // Check rate limit before attempting if (checkRateLimited()) { setError("Too many login attempts. Please try again in 1 minute."); return; } setIsLoading(true); setError(null); try { await pb.collection("users").authWithPassword(email, password); // Reset attempts on successful login setLoginAttempts([]); router.push("/"); } catch (err) { // Record the failed attempt recordAttempt(); const message = err instanceof Error ? err.message : "Invalid credentials"; const remaining = getRemainingAttempts() - 1; // -1 because we just recorded an attempt if (remaining > 0 && remaining <= 2) { setError( `${message}. ${remaining} attempt${remaining === 1 ? "" : "s"} remaining.`, ); } else { setError(message); } setIsLoading(false); } }; const handleInputChange = ( setter: React.Dispatch>, value: string, ) => { setter(value); // Clear error when user starts typing again (but not rate limit errors) if (error && !isRateLimited) { setError(null); } }; // Show loading state while checking auth methods if (isCheckingAuth) { return (

PhaseFlow

Loading...
); } return (

PhaseFlow

{error && (
{error}
)} {oidcProvider ? ( // OIDC login button ) : ( // Email/password form fallback
handleInputChange(setEmail, e.target.value)} disabled={isLoading} className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed" required />
handleInputChange(setPassword, e.target.value)} disabled={isLoading} className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed" required />
)}
); }