import { KeyRound } from "lucide-react"; import { type FormEvent, useCallback, useEffect, useRef, useState } from "react"; import { useSearchParams } from "react-router-dom"; import { useTranslation } from "@/contexts/i18n-context"; import { useAuth } from "@/hooks/use-auth"; import { setToken } from "@/lib/api"; import { format } from "@/lib/format"; function RotatingPhrase() { const { t } = useTranslation(); const phrases = t.auth.rotatingPhrases; const [index, setIndex] = useState(0); const [visible, setVisible] = useState(true); const advance = useCallback(() => { setVisible(false); setTimeout(() => { setIndex((i) => (i + 1) % phrases.length); setVisible(true); }, 300); }, [phrases.length]); useEffect(() => { const timer = setInterval(advance, 3000); return () => clearInterval(timer); }, [advance]); return ( {phrases[index]} ); } function LanguageSelector() { const { t, locale, setLocale, supportedLocales } = useTranslation(); const [open, setOpen] = useState(false); const ref = useRef(null); useEffect(() => { function handleClickOutside(e: MouseEvent) { if (ref.current && !ref.current.contains(e.target as Node)) { setOpen(false); } } if (open) { document.addEventListener("mousedown", handleClickOutside); } return () => document.removeEventListener("mousedown", handleClickOutside); }, [open]); const current = supportedLocales.find((l) => l.code === locale); return (
{open && (
{supportedLocales.map((l) => ( ))}
)}
); } export function LoginPage() { const { t } = useTranslation(); const { oidcEnabled, oidcProviderName, samlEnabled, samlProviderName, ssoEnforced } = useAuth(); const [searchParams] = useSearchParams(); const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const [error, setError] = useState(""); const [loading, setLoading] = useState(false); const [showMfaPrompt, setShowMfaPrompt] = useState(false); const [mfaToken, setMfaToken] = useState(""); const [mfaCode, setMfaCode] = useState(""); const [mfaLoading, setMfaLoading] = useState(false); const mfaInputRef = useRef(null); useEffect(() => { const authError = searchParams.get("error"); if (authError) { const errorMessages: Record = { oidc_auth_failed: t.auth.oidcAuthFailed, oidc_provider_unreachable: t.auth.oidcProviderUnreachable, oidc_session_expired: t.auth.oidcSessionExpired, oidc_user_not_authorized: t.auth.oidcUserNotAuthorized, oidc_user_limit_reached: t.auth.oidcUserLimitReached, saml_auth_failed: t.auth.samlAuthFailed, saml_user_not_authorized: t.auth.samlUserNotAuthorized, saml_user_limit_reached: t.auth.samlUserLimitReached, }; setError(errorMessages[authError] || t.auth.oidcGenericError); } }, [searchParams, t]); const handleSubmit = async (e: FormEvent) => { e.preventDefault(); setLoading(true); setError(""); try { const res = await fetch("/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password }), }); if (!res.ok) { const failure = await res.json().catch(() => null); setError( failure?.code === "MFA_ENROLLMENT_REQUIRED" ? t.auth.mfaEnrollmentRequired : t.auth.invalidCredentials, ); return; } const data = await res.json(); if (data.requiresMfa) { setMfaToken(data.mfaToken); setShowMfaPrompt(true); setTimeout(() => mfaInputRef.current?.focus(), 100); return; } setToken(data.token); localStorage.setItem("snapotter-username", data.user?.username || username); if (data.user?.mustChangePassword) { window.location.href = "/change-password"; } else { window.location.href = "/"; } } catch { setError(t.auth.connectionError); } finally { setLoading(false); } }; const handleMfaComplete = async () => { setMfaLoading(true); setError(""); try { const res = await fetch("/api/auth/mfa/complete", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ mfaToken, code: mfaCode }), }); if (!res.ok) { setError(t.auth.mfaInvalidCode); setMfaCode(""); return; } const data = await res.json(); setToken(data.token); localStorage.setItem("snapotter-username", data.user?.username || username); if (data.user?.mustChangePassword) { window.location.href = "/change-password"; } else { window.location.href = "/"; } } catch { setError(t.auth.connectionError); } finally { setMfaLoading(false); } }; return (

SnapOtter

{t.auth.login}

{ssoEnforced && (oidcEnabled || samlEnabled) && (
{oidcEnabled && ( )} {samlEnabled && ( )}
{t.auth.or}

{t.auth.ssoEnforcedLocalRestricted}

)} {showMfaPrompt ? (

{t.auth.mfaRequired}

setMfaCode(e.target.value.replace(/[^0-9]/g, ""))} onKeyDown={(e) => { if (e.key === "Enter" && mfaCode.length >= 6) handleMfaComplete(); }} className="w-full px-4 py-3 rounded-lg border border-border bg-background text-foreground text-center text-2xl font-mono tracking-[0.5em] focus:outline-none focus:ring-2 focus:ring-primary/20" /> {error &&

{error}

}

{t.auth.mfaRecoveryHint}

) : (
setUsername(e.target.value)} placeholder={t.auth.enterUsername} className="w-full px-4 py-3 rounded-lg border border-border bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20" required />
setPassword(e.target.value)} placeholder={t.auth.enterPassword} className="w-full px-4 py-3 rounded-lg border border-border bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20" required />
{error &&

{error}

}
)} {!ssoEnforced && (oidcEnabled || samlEnabled) && ( <>

{t.auth.heroTitle}

{t.auth.heroSubtitle}

); }