/* eslint-disable react-refresh/only-export-components */ import { useState, useEffect, useCallback, useRef } from "react"; import { Button } from "@/components/button"; import { Input } from "@/components/input"; import { Separator } from "@/components/separator"; import { toast } from "sonner"; import { Eye, EyeOff, User, KeyRound, ArrowLeft, Shield, CheckCircle2, ChevronDown, ChevronUp, } from "lucide-react"; import { useTranslation } from "react-i18next"; import { loginUser, registerUser, getUserInfo, getRegistrationAllowed, getPasswordLoginAllowed, getPasswordResetAllowed, getSetupRequired, initiatePasswordReset, verifyPasswordResetCode, completePasswordReset, getOIDCAuthorizeUrl, verifyTOTPLogin, getServerConfig, saveServerConfig, isElectron, getEmbeddedServerStatus, getCurrentToken, } from "@/main-axios"; import { getSSOProviders, ldapLogin } from "@/api/sso-provider-api"; import type { SSOProviderPublic } from "@/types/index"; import { ElectronServerConfig as ServerConfigComponent } from "@/auth/ElectronServerConfig"; import { ElectronLoginForm } from "@/auth/ElectronLoginForm"; import { Checkbox } from "@/components/checkbox"; import i18n from "@/i18n/i18n"; import { removeSilentSigninFromSearch, shouldTriggerSilentSignin, } from "./silent-signin"; const LANGUAGES = [ { code: "en", label: "English" }, { code: "af", label: "Afrikaans" }, { code: "ar", label: "العربية" }, { code: "bn", label: "বাংলা" }, { code: "bg", label: "Български" }, { code: "ca", label: "Català" }, { code: "zh-CN", label: "中文 (简体)" }, { code: "zh-TW", label: "中文 (繁體)" }, { code: "cs", label: "Čeština" }, { code: "da", label: "Dansk" }, { code: "nl", label: "Nederlands" }, { code: "fi", label: "Suomi" }, { code: "fr", label: "Français" }, { code: "de", label: "Deutsch" }, { code: "el", label: "Ελληνικά" }, { code: "he", label: "עברית" }, { code: "hi", label: "हिन्दी" }, { code: "hu", label: "Magyar" }, { code: "id", label: "Indonesia" }, { code: "it", label: "Italiano" }, { code: "ja", label: "日本語" }, { code: "ko", label: "한국어" }, { code: "no", label: "Norsk" }, { code: "pl", label: "Polski" }, { code: "pt-PT", label: "Português (PT)" }, { code: "pt-BR", label: "Português (BR)" }, { code: "ro", label: "Română" }, { code: "ru", label: "Русский" }, { code: "sr", label: "Српски" }, { code: "es-ES", label: "Español" }, { code: "sv-SE", label: "Svenska" }, { code: "th", label: "ไทย" }, { code: "tr", label: "Türkçe" }, { code: "uk", label: "Українська" }, { code: "vi", label: "Tiếng Việt" }, ]; const STORAGE_KEY = "termix_auth"; export function getStoredAuth(): { loggedIn: boolean; username: string; } | null { try { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return null; return JSON.parse(raw); } catch { return null; } } export function clearStoredAuth() { localStorage.removeItem(STORAGE_KEY); } function storeAuth(username: string) { localStorage.setItem( STORAGE_KEY, JSON.stringify({ loggedIn: true, username }), ); } type AuthView = "login" | "register" | "reset" | "totp" | "external"; type ResetStep = "email" | "code" | "newpass"; interface AuthProps { onLogin: (username: string, userId?: string, isAdmin?: boolean) => void; } interface ExtendedWindow extends Window { IS_ELECTRON_WEBVIEW?: boolean; ReactNativeWebView?: { postMessage: (msg: string) => void }; } const isInMobileWebView = () => /Termix-Mobile\/(Android|iOS)/.test(navigator.userAgent) || !!(window as ExtendedWindow).ReactNativeWebView; const isInElectronWebView = () => { if (isInMobileWebView()) return false; if ((window as ExtendedWindow).IS_ELECTRON_WEBVIEW) return true; try { if (window.self !== window.top) return true; } catch { return true; } return false; }; function PasswordInput({ value, onChange, placeholder, disabled, id, }: { value: string; onChange: (v: string) => void; placeholder?: string; disabled?: boolean; id?: string; }) { const [show, setShow] = useState(false); return (
onChange(e.target.value)} placeholder={placeholder ?? "••••••••"} disabled={disabled} className="pr-9 font-mono" />
); } function FieldLabel({ htmlFor, children, }: { htmlFor?: string; children: React.ReactNode; }) { return ( ); } function Field({ label, htmlFor, children, }: { label: string; htmlFor?: string; children: React.ReactNode; }) { return (
{label} {children}
); } export function Auth({ onLogin }: AuthProps) { const { t } = useTranslation(); const [view, setView] = useState("login"); const [loading, setLoading] = useState(false); const [providerLoading, setProviderLoading] = useState< Record >({}); const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); const [rememberMe, setRememberMe] = useState(() => { try { return localStorage.getItem("rememberMe") === "true"; } catch { return false; } }); const [totpCode, setTotpCode] = useState(""); const [totpTempToken, setTotpTempToken] = useState(""); const totpInputRef = useRef(null); const [resetStep, setResetStep] = useState("email"); const [resetCode, setResetCode] = useState(""); const [newPassword, setNewPassword] = useState(""); const [confirmNewPassword, setConfirmNewPassword] = useState(""); const [resetTempToken, setResetTempToken] = useState(""); const [language, setLanguage] = useState( () => localStorage.getItem("i18nextLng") ?? "en", ); function handleLanguageChange(code: string) { setLanguage(code); localStorage.setItem("i18nextLng", code); i18n.changeLanguage(code); } const [registrationAllowed, setRegistrationAllowed] = useState(true); const [passwordLoginAllowed, setPasswordLoginAllowed] = useState(true); const [passwordResetAllowed, setPasswordResetAllowed] = useState(true); const [ssoProviders, setSsoProviders] = useState([]); const [ssoProvidersLoaded, setSsoProvidersLoaded] = useState(false); const [expandedLdapId, setExpandedLdapId] = useState(null); const [ldapUsername, setLdapUsername] = useState(""); const [ldapPassword, setLdapPassword] = useState(""); const silentSigninHandledRef = useRef(false); const [firstUser, setFirstUser] = useState(false); const [dbConnectionFailed, setDbConnectionFailed] = useState(false); const [dbHealthChecking, setDbHealthChecking] = useState(true); const [showServerConfig, setShowServerConfig] = useState( null, ); const [currentServerUrl, setCurrentServerUrl] = useState(""); const [webviewAuthSuccess, setWebviewAuthSuccess] = useState(false); useEffect(() => { try { localStorage.setItem("rememberMe", rememberMe.toString()); } catch { // Ignore storage failures; auth state still works for the current session. } }, [rememberMe]); useEffect(() => { getRegistrationAllowed() .then((res) => setRegistrationAllowed(res.allowed)) .catch(() => {}); getPasswordLoginAllowed() .then((res) => setPasswordLoginAllowed(res.allowed)) .catch(() => {}); getPasswordResetAllowed() .then((allowed) => setPasswordResetAllowed(allowed)) .catch(() => setPasswordResetAllowed(false)); getSSOProviders() .then((providers) => setSsoProviders(providers || [])) .catch(() => setSsoProviders([])) .finally(() => setSsoProvidersLoaded(true)); }, []); useEffect(() => { if (showServerConfig) return; setDbHealthChecking(true); getSetupRequired() .then((res) => { if (res.setup_required) { setFirstUser(true); setView("register"); } setDbConnectionFailed(false); }) .catch(() => setDbConnectionFailed(true)) .finally(() => setDbHealthChecking(false)); }, [showServerConfig]); useEffect(() => { const checkElectron = async () => { if (isInElectronWebView()) { setShowServerConfig(false); return; } if (isElectron()) { try { const [config, status] = await Promise.all([ getServerConfig(), getEmbeddedServerStatus(), ]); if ( status?.embedded && status?.running && config && !config.serverUrl ) { setShowServerConfig(false); setCurrentServerUrl(""); return; } setCurrentServerUrl(config?.serverUrl || ""); setShowServerConfig(!config || !config.serverUrl); } catch { setShowServerConfig(true); } } else { setShowServerConfig(false); } }; checkElectron(); }, []); useEffect(() => { if (view === "totp" && totpInputRef.current) totpInputRef.current.focus(); }, [view]); useEffect(() => { setLdapUsername(""); setLdapPassword(""); }, [expandedLdapId]); useEffect(() => { if (!ssoProvidersLoaded) return; if (!passwordLoginAllowed && ssoProviders.length > 0 && view === "login") { setView("external"); } }, [ssoProvidersLoaded, passwordLoginAllowed, ssoProviders.length, view]); useEffect(() => { const urlParams = new URLSearchParams(window.location.search); const success = urlParams.get("success"); const error = urlParams.get("error"); if (error) { if (error === "registration_disabled") toast.error(t("messages.registrationDisabled")); else if (error === "user_not_allowed") toast.error(t("messages.userNotAllowed")); else toast.error(`${t("errors.oidcAuthFailed")}: ${error}`); window.history.replaceState({}, document.title, window.location.pathname); return; } if (success) { if (isInMobileWebView()) { // The OIDC callback authenticated via an HttpOnly cookie on this origin, // so the token isn't in localStorage. Prefer a token passed in the URL // (termix-mobile:-origin callbacks include one), otherwise read it back // from the cookie via /users/me/token before handing it to the app. const postToken = (token: string) => { (window as ExtendedWindow).ReactNativeWebView?.postMessage( JSON.stringify({ type: "AUTH_SUCCESS", token }), ); setWebviewAuthSuccess(true); window.history.replaceState( {}, document.title, window.location.pathname, ); }; const urlToken = urlParams.get("token"); if (urlToken) { postToken(urlToken); } else { getCurrentToken() .then((token) => postToken(token ?? "")) .catch(() => postToken("")); } return; } if (isInElectronWebView()) { window.parent.postMessage( { type: "AUTH_SUCCESS", source: "oidc_callback", platform: "desktop", timestamp: Date.now(), }, "*", ); setWebviewAuthSuccess(true); window.history.replaceState( {}, document.title, window.location.pathname, ); return; } getUserInfo() .then((meRes) => { storeAuth(meRes.username || ""); onLogin( meRes.username || "", meRes.userId || undefined, !!meRes.is_admin, ); window.history.replaceState( {}, document.title, window.location.pathname, ); }) .catch(() => { toast.error(t("errors.failedUserInfo")); }); } }, [onLogin, t]); const handleElectronAuthSuccess = useCallback( async (token: string | null) => { try { if (!token) { // No token in postMessage — fall back to waiting for the HttpOnly cookie const cookieReady = await window.electronAPI?.waitForSessionCookie?.( "jwt", currentServerUrl, null, 5000, ); if (cookieReady && !cookieReady.success) throw new Error(cookieReady.error || "Auth cookie not ready"); } const meRes = await getUserInfo(); if (!meRes) throw new Error("Failed to get user info"); storeAuth(meRes.username || ""); onLogin( meRes.username || "", meRes.userId || undefined, !!meRes.is_admin, ); toast.success(t("messages.loginSuccess")); } catch { toast.error(t("errors.failedUserInfo")); } }, [onLogin, currentServerUrl, t], ); function resetAll() { setUsername(""); setPassword(""); setConfirmPassword(""); setResetStep("email"); setResetCode(""); setNewPassword(""); setConfirmNewPassword(""); setResetTempToken(""); setTotpCode(""); setTotpTempToken(""); } function switchView(v: AuthView) { const currentUsername = username; resetAll(); if (v === "reset") setUsername(currentUsername); setView(v); } async function handleLogin(e: React.FormEvent) { e.preventDefault(); if (!username.trim()) { toast.error(t("errors.requiredField")); return; } setLoading(true); try { const res = await loginUser(username.trim(), password, rememberMe); if (res.requires_totp) { setTotpTempToken(res.temp_token); setView("totp"); return; } if (!res?.success) throw new Error(t("errors.loginFailed")); if (isInMobileWebView()) { // Native-app requests get the JWT in the login response body. const token = res?.token ?? ""; (window as ExtendedWindow).ReactNativeWebView?.postMessage( JSON.stringify({ type: "AUTH_SUCCESS", token }), ); setWebviewAuthSuccess(true); return; } if (isInElectronWebView()) { window.parent.postMessage( { type: "AUTH_SUCCESS", source: "auth_component", platform: "desktop", timestamp: Date.now(), }, "*", ); setWebviewAuthSuccess(true); return; } const meRes = await getUserInfo(); storeAuth(meRes.username || username.trim()); toast.success(t("messages.loginSuccess")); onLogin( meRes.username || username.trim(), meRes.userId || undefined, !!meRes.is_admin, ); } catch (err: unknown) { const error = err as { message?: string; response?: { data?: { error?: string } }; }; toast.error( error?.response?.data?.error || error?.message || t("errors.unknownError"), ); } finally { setLoading(false); } } async function handleRegister(e: React.FormEvent) { e.preventDefault(); if (!username.trim()) { toast.error(t("errors.requiredField")); return; } if (password.length < 6) { toast.error(t("errors.minLength", { min: 6 })); return; } if (password !== confirmPassword) { toast.error(t("errors.passwordMismatch")); return; } setLoading(true); try { await registerUser(username.trim(), password); const res = await loginUser(username.trim(), password, rememberMe); if (res.requires_totp) { setTotpTempToken(res.temp_token); setView("totp"); return; } const meRes = await getUserInfo(); storeAuth(meRes.username || username.trim()); toast.success(t("messages.registrationSuccess")); onLogin( meRes.username || username.trim(), meRes.userId || undefined, !!meRes.is_admin, ); } catch (err: unknown) { const error = err as { message?: string; response?: { data?: { error?: string } }; }; toast.error( error?.response?.data?.error || error?.message || t("errors.unknownError"), ); } finally { setLoading(false); } } async function handleTOTP(e: React.FormEvent) { e.preventDefault(); if (totpCode.length !== 6) { toast.error(t("auth.enterCode")); return; } setLoading(true); try { const res = await verifyTOTPLogin(totpTempToken, totpCode, rememberMe); if (!res?.success) throw new Error(t("errors.loginFailed")); if (isInMobileWebView()) { // Native-app requests get the JWT in the login response body. const token = res?.token ?? ""; (window as ExtendedWindow).ReactNativeWebView?.postMessage( JSON.stringify({ type: "AUTH_SUCCESS", token }), ); setWebviewAuthSuccess(true); return; } if (isInElectronWebView()) { window.parent.postMessage( { type: "AUTH_SUCCESS", source: "totp_auth_component", platform: "desktop", timestamp: Date.now(), }, "*", ); setWebviewAuthSuccess(true); return; } storeAuth(res.username || username); toast.success(t("messages.loginSuccess")); onLogin( res.username || username, res.userId || undefined, !!res.is_admin, ); } catch (err: unknown) { const error = err as { message?: string; response?: { data?: { error?: string; code?: string } }; }; if (error?.response?.data?.code === "SESSION_EXPIRED") { setView("login"); toast.error(t("errors.sessionExpired")); return; } toast.error( error?.response?.data?.error || error?.message || t("errors.invalidTotpCode"), ); } finally { setLoading(false); } } async function handleResetInitiate(e: React.FormEvent) { e.preventDefault(); if (!username.trim()) { toast.error(t("errors.requiredField")); return; } setLoading(true); try { await initiatePasswordReset(username.trim()); setResetStep("code"); toast.success(t("messages.resetCodeSent")); } catch (err: unknown) { const error = err as { message?: string; response?: { data?: { error?: string } }; }; toast.error( error?.response?.data?.error || error?.message || t("errors.failedPasswordReset"), ); } finally { setLoading(false); } } async function handleResetVerify(e: React.FormEvent) { e.preventDefault(); setLoading(true); try { const res = await verifyPasswordResetCode(username.trim(), resetCode); setResetTempToken(res.tempToken as string); setResetStep("newpass"); toast.success(t("messages.codeVerified")); } catch (err: unknown) { const error = err as { message?: string; response?: { data?: { error?: string } }; }; toast.error( error?.response?.data?.error || error?.message || t("errors.failedVerifyCode"), ); } finally { setLoading(false); } } async function handleResetComplete(e: React.FormEvent) { e.preventDefault(); if (newPassword.length < 6) { toast.error(t("errors.minLength", { min: 6 })); return; } if (newPassword !== confirmNewPassword) { toast.error(t("errors.passwordMismatch")); return; } setLoading(true); try { await completePasswordReset(username.trim(), resetTempToken, newPassword); toast.success(t("messages.passwordResetSuccess")); switchView("login"); } catch (err: unknown) { const error = err as { message?: string; response?: { data?: { error?: string } }; }; toast.error( error?.response?.data?.error || error?.message || t("errors.failedCompleteReset"), ); } finally { setLoading(false); } } const handleOIDCLogin = useCallback( async (providerId?: number) => { const loadingKey = providerId ?? -1; setProviderLoading((prev) => ({ ...prev, [loadingKey]: true })); try { if (isElectron()) { const electronAPI = ( window as unknown as { electronAPI?: { oidcSystemBrowserAuth?: ( authUrl: string, port: number, ) => Promise<{ success: boolean; token?: string; error?: string; }>; }; } ).electronAPI; if (electronAPI?.oidcSystemBrowserAuth) { const callbackPort = 17832 + Math.floor(Math.random() * 100); const authResponse = await getOIDCAuthorizeUrl( rememberMe, callbackPort, providerId, ); const { auth_url: authUrl } = authResponse; if (!authUrl) throw new Error(t("errors.invalidAuthUrl")); const result = await electronAPI.oidcSystemBrowserAuth( authUrl, callbackPort, ); if (result.success && result.token) { localStorage.setItem("jwt", result.token); window.location.reload(); return; } throw new Error(result.error || "Authentication failed"); } } const authResponse = await getOIDCAuthorizeUrl( rememberMe, undefined, providerId, ); const { auth_url: authUrl } = authResponse; if (!authUrl || authUrl === "undefined") throw new Error(t("errors.invalidAuthUrl")); window.location.replace(authUrl); } catch (err: unknown) { const error = err as { message?: string; response?: { data?: { error?: string } }; }; toast.error( error?.response?.data?.error || error?.message || t("errors.failedOidcLogin"), ); setProviderLoading((prev) => ({ ...prev, [loadingKey]: false })); } }, [rememberMe, t], ); const handleLDAPLogin = useCallback( async (providerId: number) => { if (!ldapUsername.trim() || !ldapPassword) { toast.error(t("errors.requiredField")); return; } setProviderLoading((prev) => ({ ...prev, [providerId]: true })); try { await ldapLogin( providerId, ldapUsername.trim(), ldapPassword, rememberMe, ); const meRes = await getUserInfo(); storeAuth(meRes.username || ""); toast.success(t("messages.loginSuccess")); onLogin( meRes.username || "", meRes.userId || undefined, !!meRes.is_admin, ); } catch (err: unknown) { const error = err as { response?: { data?: { error?: string } }; message?: string; }; toast.error( error?.response?.data?.error || error?.message || t("auth.ldapLoginFailed"), ); } finally { setProviderLoading((prev) => ({ ...prev, [providerId]: false })); } }, [ldapUsername, ldapPassword, rememberMe, onLogin, t], ); useEffect(() => { if (!ssoProvidersLoaded || silentSigninHandledRef.current) return; if (!shouldTriggerSilentSignin(window.location.search)) return; const nextSearch = removeSilentSigninFromSearch(window.location.search); window.history.replaceState( {}, document.title, `${window.location.pathname}${nextSearch}${window.location.hash}`, ); silentSigninHandledRef.current = true; const oidcProvider = ssoProviders.find( (p) => p.type === "oidc" || p.type === "github" || p.type === "google", ); if (oidcProvider && !isElectron()) { handleOIDCLogin(oidcProvider.id); return; } toast.info(t("errors.silentSigninOidcUnavailable")); }, [handleOIDCLogin, ssoProvidersLoaded, ssoProviders, t]); // Electron server config / webview auth success screens if (isElectron() && !isInElectronWebView()) { if (showServerConfig === null) return (
); if (showServerConfig) return (
window.location.reload()} onUseEmbedded={async () => { await saveServerConfig({ serverUrl: "", lastUpdated: new Date().toISOString(), }); setShowServerConfig(false); setCurrentServerUrl(""); }} onCancel={() => setShowServerConfig(false)} isFirstTime={!currentServerUrl} />
); if (!webviewAuthSuccess && showServerConfig === false && currentServerUrl) return (
setShowServerConfig(true)} />
); } if (webviewAuthSuccess || (isInElectronWebView() && webviewAuthSuccess)) return (

{t("auth.redirectingToApp")}

); if (dbConnectionFailed) return (

{t("errors.databaseConnection")}

{t("messages.databaseConnectionFailed")}

{t("common.language")}
{isElectron() && currentServerUrl && (
{t("serverConfig.serverUrl")} {currentServerUrl}
)}
); if (dbHealthChecking && showServerConfig === false) return (
); const TAB_ITEMS: { id: AuthView; label: string; show: boolean }[] = [ { id: "login", label: t("common.login"), show: passwordLoginAllowed && !firstUser, }, { id: "register", label: t("common.register"), show: (passwordLoginAllowed || firstUser) && registrationAllowed, }, { id: "external", label: t("auth.external"), show: ssoProviders.length > 0, }, ]; return (
{isElectron() && !isInElectronWebView() && showServerConfig === false && (
{t("serverConfig.localServer")}
)}
{/* Left decorative panel */}
TERMIX
{t("auth.tagline")}
{/* Right panel */}
{/* TOTP view */} {view === "totp" && (

{t("auth.twoFactorAuth")}

{t("auth.enterCode")}

setTotpCode(e.target.value.replace(/\D/g, "")) } disabled={loading} className="text-center text-2xl tracking-widest font-mono" autoComplete="one-time-code" />
)} {/* Reset password view */} {view === "reset" && (

{t("auth.forgotPassword")}

{t("auth.resetCodeDesc")}

{(["email", "code", "newpass"] as ResetStep[]).map( (step, i) => { const stepIdx = ["email", "code", "newpass"].indexOf( resetStep, ); const done = i < stepIdx; const active = i === stepIdx; return (
{done ? : i + 1}
{i < 2 && (
)}
); }, )}
{resetStep === "email" && (
setUsername(e.target.value)} placeholder="your_username" disabled={loading} />
)} {resetStep === "code" && (
setResetCode(e.target.value.replace(/\D/g, "")) } placeholder="000000" maxLength={6} className="text-center font-mono text-lg tracking-widest" disabled={loading} />
)} {resetStep === "newpass" && (
)}
)} {/* Login / Register / External */} {(view === "login" || view === "register" || view === "external") && (
{TAB_ITEMS.filter((item) => item.show).map((item) => ( ))}

{view === "login" ? t("auth.loginTitle") : view === "register" ? t("auth.registerTitle") : t("auth.loginWithExternal")}

{view === "login" ? t("auth.loginSubtitle", "") : view === "register" ? t("auth.registerSubtitle", "") : t("auth.loginWithExternalDesc")}

{view === "external" && (
setRememberMe(v === true)} />
{ssoProviders.map((provider) => { const isLoading = !!providerLoading[provider.id]; if (provider.type === "ldap") { const isExpanded = expandedLdapId === provider.id; return (
{isExpanded && (
{ e.preventDefault(); handleLDAPLogin(provider.id); }} className="flex flex-col gap-3 p-3 border-t border-border" > setLdapUsername(e.target.value) } disabled={isLoading} autoFocus />
)}
); } return ( ); })}
)} {view === "login" && (
setUsername(e.target.value)} placeholder="username" className="pl-8" disabled={loading} autoFocus />
setRememberMe(v === true)} disabled={loading} />
{passwordResetAllowed && ( )}
)} {view === "register" && (
setUsername(e.target.value)} placeholder="choose_a_username" className="pl-8" disabled={loading} autoFocus />
)}

{view === "login" && registrationAllowed ? ( <> {t("auth.noAccount", "Don't have an account?")}{" "} ) : view === "register" && passwordLoginAllowed && !firstUser ? ( <> {t("auth.hasAccount", "Already have an account?")}{" "} ) : null}

{t("common.language")}
)}
); }