/* eslint-disable react-refresh/only-export-components */ import { isEmbeddedAuthFrame, isInElectronWebView, isInMobileWebView, type EmbeddedFrameWindow, } from "@/lib/embedded-frame"; import { handOffToApp, pendingAppReturn } from "@/lib/app-return"; 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, } from "lucide-react"; import { useTranslation } from "react-i18next"; import { loginUser, registerUser, getUserInfo, getRegistrationAllowed, getPasswordLoginAllowed, getPasswordResetAllowed, getSetupRequired, initiatePasswordReset, verifyPasswordResetCode, completePasswordReset, isElectron, getCurrentToken, requestDesktopAutoSession, requestTrustedProxyLogin, } from "@/main-axios"; import { getEmbeddedServerFailure, type EmbeddedServerFailure, } from "@/lib/embedded-server-status"; import { challengeSecondFactor, getLoginMethods, getRememberDeviceAllowed, startLoginRedirect, submitLoginMethod, verifySecondFactor, type LoginResponse, type PublicLoginMethod, type SecondFactorRef, } from "@/api/auth-methods-api"; import { useLoginMethods, useSecondFactors } from "@/plugin-host/auth-registry"; import { startPreLoginPlugins } from "@/plugin-host/loader"; import { Checkbox } from "@/components/checkbox"; import { useBranding } from "@/contexts/BrandingContext"; import { BrandPanel, CompactBrand } from "./auth-bits"; import { changeAppLanguage, normalizeLanguageCode, rememberLoginLanguage, } from "@/i18n/i18n"; import { LANGUAGES } from "@/i18n/languages"; import { removeSilentSigninFromSearch, shouldTriggerSilentSignin, } from "./silent-signin"; import { Select2 } from "@/components/select2"; import { cn } from "@/lib/utils"; function LanguageRow({ label, value, onChange, className, }: { label: string; value: string; onChange: (code: string) => void; className?: string; }) { return (
{label} onChange(e.target.value)} aria-label={label} align="end" contentClassName="w-56" className="h-8 w-40 shrink-0 px-2.5 text-xs" > {LANGUAGES.map((lang) => ( ))}
); } const STORAGE_KEY = "termix_auth"; /** An empty list means "whichever factors this screen has UI for". */ function parseSecondFactorIds(value: string | null): SecondFactorRef[] { const ids = (value ?? "").split(",").filter(Boolean); return ids.map((id) => ({ id, pluginId: "", labelKey: "" })); } const DESKTOP_MANUAL_LOGOUT_KEY = "termix_desktop_manual_logout"; 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); } export function markDesktopManualLogout() { localStorage.setItem(DESKTOP_MANUAL_LOGOUT_KEY, "true"); } export function clearDesktopManualLogout() { localStorage.removeItem(DESKTOP_MANUAL_LOGOUT_KEY); } function hasDesktopManualLogout() { return localStorage.getItem(DESKTOP_MANUAL_LOGOUT_KEY) === "true"; } export function storeAuth(username: string) { localStorage.setItem( STORAGE_KEY, JSON.stringify({ loggedIn: true, username }), ); } /** A browser login the mobile app asked for goes back to the app. */ async function handOffLogin(token?: string | null): Promise { if (!pendingAppReturn() || isEmbeddedAuthFrame()) return false; const value = token || (await getCurrentToken()); return handOffToApp(value ? { token: value } : { error: "no_token" }); } type AuthView = "login" | "register" | "reset" | "second-factor" | "external"; type ResetStep = "email" | "code" | "newpass"; interface AuthProps { onLogin: (username: string, userId?: string, isAdmin?: boolean) => void; } 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 branding = useBranding(); const localDesktopAuth = isElectron() && !isInElectronWebView(); const [view, setView] = useState("login"); const [loading, setLoading] = useState(false); const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); const [rememberAllowed, setRememberAllowed] = useState(true); const [rememberMe, setRememberMe] = useState(() => { try { return localStorage.getItem("rememberMe") === "true"; } catch { return false; } }); // An empty token means the server holds the pending login in a cookie, // which is how a redirect login hands over to the second factor step. const [pendingToken, setPendingToken] = useState(""); const [secondFactors, setSecondFactors] = useState([]); const [activeFactorId, setActiveFactorId] = useState(""); const [authPluginsLoading, setAuthPluginsLoading] = useState(true); const retryAuthPlugins = async () => { setAuthPluginsLoading(true); try { await startPreLoginPlugins({ retryFailed: true }); } finally { setAuthPluginsLoading(false); } }; const loginMethodUIs = useLoginMethods(); const secondFactorUIs = useSecondFactors(); // A server that did not list its factors gets every factor with a UI here. const shownFactors: SecondFactorRef[] = secondFactors.length > 0 ? secondFactors : secondFactorUIs.map((ui) => ({ id: ui.id, pluginId: ui.pluginId ?? "", labelKey: ui.titleKey, })); const currentFactorId = activeFactorId || shownFactors[0]?.id || ""; 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(() => normalizeLanguageCode(localStorage.getItem("i18nextLng")), ); function handleLanguageChange(code: string) { const language = rememberLoginLanguage(code); void changeAppLanguage(language) .then((language) => setLanguage(language)) .catch(() => {}); } const [registrationAllowed, setRegistrationAllowed] = useState(true); const [passwordLoginAllowed, setPasswordLoginAllowed] = useState(true); const [passwordResetAllowed, setPasswordResetAllowed] = useState(true); const [authMethods, setAuthMethods] = useState([]); const [authMethodsLoaded, setAuthMethodsLoaded] = useState(false); const inlineMethodIds = new Set( loginMethodUIs.filter((ui) => ui.placement === "inline").map((ui) => ui.id), ); const inlineMethods = authMethods.filter((method) => inlineMethodIds.has(method.id), ); const externalMethods = authMethods.filter( (method) => method.id !== "password" && !inlineMethodIds.has(method.id), ); const silentSigninHandledRef = useRef(false); const proxySigninHandledRef = useRef(false); const [firstUser, setFirstUser] = useState(false); const [dbConnectionFailed, setDbConnectionFailed] = useState(false); const [dbHealthChecking, setDbHealthChecking] = useState( () => !localDesktopAuth || !hasDesktopManualLogout(), ); const [webviewAuthSuccess, setWebviewAuthSuccess] = useState(false); const [desktopManualLogoutActive, setDesktopManualLogoutActive] = useState( () => localDesktopAuth && hasDesktopManualLogout(), ); // Electron, non-iframed only: the desktop app owns an embedded local // backend with an auto-provisioned local user, so it should never show // username/password registration for that local surface. null means the // auto-session probe is still in flight; true means the probe settled // without a session and the local recovery panel can render. const [desktopAutoSessionDone, setDesktopAutoSessionDone] = useState< boolean | null >(!localDesktopAuth || hasDesktopManualLogout() ? true : null); const [desktopAutoSessionRetries, setDesktopAutoSessionRetries] = useState(0); // Set only when the Electron main process reports that the embedded // backend died for good, which is the one case where retrying the // auto-session forever is wrong. const [embeddedServerFailure, setEmbeddedServerFailure] = useState(null); useEffect(() => { if (proxySigninHandledRef.current || isElectron()) return; proxySigninHandledRef.current = true; requestTrustedProxyLogin() .then(async (result) => { if (!result.enabled || !result.success) return; if (await handOffLogin()) return; storeAuth(result.username || ""); onLogin( result.username || "", result.userId || undefined, !!result.is_admin, ); }) .catch(() => { // Leave the login screen visible. The backend logs the reason without // exposing trusted proxy configuration to an untrusted client. }); }, [onLogin]); useEffect(() => { try { localStorage.setItem("rememberMe", rememberMe.toString()); } catch { // Ignore storage failures; auth state still works for the current session. } }, [rememberMe]); useEffect(() => { if (!isInElectronWebView()) return; const handleExternalBrowserResult = (event: MessageEvent) => { if (event.source !== window.parent) return; if (!event.data || typeof event.data !== "object") return; if (event.data.type !== "OIDC_SYSTEM_BROWSER_AUTH_RESULT") return; if (event.data.success) return; setLoading(false); if (event.data.secondFactor) { enterSecondFactorStep( event.data.tempToken ?? "", parseSecondFactorIds(event.data.factors ?? null), ); return; } toast.error(event.data.error || t("errors.failedExternalLogin")); }; window.addEventListener("message", handleExternalBrowserResult); return () => window.removeEventListener("message", handleExternalBrowserResult); }, [t]); useEffect(() => { if (localDesktopAuth) { setAuthMethodsLoaded(true); return; } getRegistrationAllowed() .then((res) => setRegistrationAllowed(res.allowed)) .catch(() => {}); getPasswordLoginAllowed() .then((res) => setPasswordLoginAllowed(res.allowed)) .catch(() => {}); getPasswordResetAllowed() .then((allowed) => setPasswordResetAllowed(allowed)) .catch(() => setPasswordResetAllowed(false)); // Plugins that draw login or second-factor UI load before sign-in. void startPreLoginPlugins() .catch(() => {}) .finally(() => setAuthPluginsLoading(false)); getRememberDeviceAllowed() .then((allowed) => { setRememberAllowed(allowed); if (!allowed) setRememberMe(false); }) .catch(() => {}); getLoginMethods() .then((methods) => setAuthMethods(methods)) .catch(() => setAuthMethods([])) .finally(() => setAuthMethodsLoaded(true)); }, [localDesktopAuth]); useEffect(() => { // Runs once the auto-session probe has settled (immediately outside // Electron, since it starts at true there; after the probe resolves in // Electron). Waiting avoids flashing a login screen the user is about // to skip past via auto-login. if (desktopAutoSessionDone !== true) return; if (localDesktopAuth) { setDbHealthChecking(false); return; } let cancelled = false; let retryTimer: ReturnType | null = null; // Right after a server update/restart (or behind a reverse proxy that's // still warming up), the very first request can transiently fail even // though the backend/database is fine seconds later. A single failure // here used to permanently show the "could not connect to the database" // screen, forcing users to manually reload -- sometimes repeatedly. // Retry a few times with backoff before treating it as a real failure. const maxAttempts = 5; const attempt = (attemptNumber: number) => { getSetupRequired() .then((res) => { if (cancelled) return; if (res.setup_required) { setFirstUser(true); setView("register"); } setDbConnectionFailed(false); setDbHealthChecking(false); }) .catch(() => { if (cancelled) return; // Past the first few tries, show the error but keep checking, so // the page moves on by itself once the server is back. if (attemptNumber >= maxAttempts) { setDbConnectionFailed(true); setDbHealthChecking(false); } const delay = Math.min(500 * 2 ** attemptNumber, 5000); retryTimer = setTimeout(() => { if (!cancelled) attempt(attemptNumber + 1); }, delay); }); }; setDbHealthChecking(true); attempt(0); return () => { cancelled = true; if (retryTimer) clearTimeout(retryTimer); }; }, [desktopAutoSessionDone, localDesktopAuth]); // A cold first launch spawns the embedded backend as a separate process // that can take anywhere from a couple seconds to much longer to finish // booting (DB init, SSL, antivirus scanning a freshly-unpacked binary, // slow disks, etc.) -- well after the renderer has already mounted. The // embedded backend is bundled, always-on infrastructure, not something // that can be "not there" -- it always eventually comes up. So a // "retry" outcome (connection error, not a real verdict) is retried // forever with capped backoff rather than ever giving up and falling // through to the login form: that form is not a valid destination for a // standalone install with no remote sync configured. Only a definitive // "declined" stops retrying and shows a local recovery panel. useEffect(() => { if (desktopAutoSessionDone !== null) return; if (embeddedServerFailure) return; let cancelled = false; let retryTimer: ReturnType | null = null; // "Retry forever" holds only while the backend could still be booting. // If the main process reports that it exited -- a port conflict being // by far the most common cause -- there is nothing left to wait for, so // the reason is shown instead of an unending spinner. const retryUnlessBackendIsGone = async () => { const failure = await getEmbeddedServerFailure(); if (cancelled) return; if (failure) { setEmbeddedServerFailure(failure); return; } const delay = Math.min(1000 * 2 ** desktopAutoSessionRetries, 10000); retryTimer = setTimeout(() => { if (!cancelled) setDesktopAutoSessionRetries((c) => c + 1); }, delay); }; requestDesktopAutoSession() .then((outcome) => { if (cancelled) return; if (outcome.kind === "success") { storeAuth(outcome.data.username || ""); clearDesktopManualLogout(); onLogin( outcome.data.username || "", outcome.data.userId || undefined, !!outcome.data.is_admin, ); return; } if (outcome.kind === "retry") { void retryUnlessBackendIsGone(); return; } setDesktopAutoSessionDone(true); }) .catch(() => { if (cancelled) return; void retryUnlessBackendIsGone(); }); return () => { cancelled = true; if (retryTimer) clearTimeout(retryTimer); }; }, [ desktopAutoSessionDone, desktopAutoSessionRetries, embeddedServerFailure, onLogin, ]); useEffect(() => { if (!authMethodsLoaded) return; if ( !passwordLoginAllowed && externalMethods.length > 0 && view === "login" ) { setView("external"); } }, [authMethodsLoaded, passwordLoginAllowed, externalMethods.length, view]); useEffect(() => { const urlParams = new URLSearchParams(window.location.search); const success = urlParams.get("success"); const error = urlParams.get("error"); if (urlParams.get("second_factor") === "1") { enterSecondFactorStep( urlParams.get("temp_token") ?? "", parseSecondFactorIds(urlParams.get("second_factors")), ); window.history.replaceState({}, document.title, window.location.pathname); return; } if (error) { if (error === "registration_disabled") toast.error(t("messages.registrationDisabled")); else if (error === "user_not_allowed") toast.error(t("messages.userNotAllowed")); else if (error === "second_factor_unavailable") toast.error(t("auth.secondFactorUnavailable")); else toast.error(`${t("errors.externalAuthFailed")}: ${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 EmbeddedFrameWindow).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()) { // Electron Remote Sync embeds the remote server in an iframe. The // OIDC callback authenticates by setting the remote origin's // HttpOnly cookie, so read the JWT back before notifying the parent. const postToken = (token: string | null) => { window.parent.postMessage( { type: "AUTH_SUCCESS", source: "oidc_callback", platform: "desktop", token, timestamp: Date.now(), }, "*", ); 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 ?? null)) .catch(() => postToken(null)); } return; } void handOffLogin(urlParams.get("token")).then((handedOff) => { if (handedOff) return; getUserInfo() .then((meRes) => { storeAuth(meRes.username || ""); clearDesktopManualLogout(); 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]); function resetAll() { setUsername(""); setPassword(""); setConfirmPassword(""); setResetStep("email"); setResetCode(""); setNewPassword(""); setConfirmNewPassword(""); setResetTempToken(""); setPendingToken(""); } function switchView(v: AuthView) { const currentUsername = username; resetAll(); if (v === "reset") setUsername(currentUsername); setView(v); } function continueLocalDesktopSession() { clearDesktopManualLogout(); setDesktopManualLogoutActive(false); setDesktopAutoSessionRetries(0); setDesktopAutoSessionDone(null); } function enterSecondFactorStep( tempToken: string, factors: SecondFactorRef[] = [], ) { setPendingToken(tempToken); setSecondFactors(factors); setActiveFactorId(factors[0]?.id ?? ""); setLoading(false); setView("second-factor"); } function showLoginError(err: unknown, fallbackKey: string) { 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; } if (error?.response?.data?.code === "second_factor_unavailable") { toast.error(t("auth.secondFactorUnavailable")); return; } if (error?.response?.data?.code === "external_login_required") { toast.error(t("auth.externalLoginRequired")); return; } toast.error( error?.response?.data?.error || error?.message || t(fallbackKey), ); } /** * Every login ends here: the second-factor step, a hand-off to the mobile * or desktop shell, or signing in this window. */ async function finishLogin( res: LoginResponse, source: string, options: { fallbackUsername?: string; successKey?: string } = {}, ) { if (res?.requires_totp || res?.requires_second_factor) { enterSecondFactorStep(res.temp_token ?? "", res.second_factors); 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 EmbeddedFrameWindow).ReactNativeWebView?.postMessage( JSON.stringify({ type: "AUTH_SUCCESS", token }), ); setWebviewAuthSuccess(true); return; } if (isInElectronWebView()) { // The iframe never sends X-Electron-App, so the JWT only lands in an // HttpOnly cookie on this origin. Read it back for the parent window. const token = res?.token ?? (await getCurrentToken()); window.parent.postMessage( { type: "AUTH_SUCCESS", source, platform: "desktop", token: token ?? null, timestamp: Date.now(), }, "*", ); setWebviewAuthSuccess(true); return; } if (await handOffLogin(res?.token)) return; const meRes = await getUserInfo(); const name = meRes.username || res.username || options.fallbackUsername || ""; storeAuth(name); clearDesktopManualLogout(); toast.success(t(options.successKey ?? "messages.loginSuccess")); onLogin(name, meRes.userId || undefined, !!meRes.is_admin); } 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); await finishLogin(res, "auth_component", { fallbackUsername: username.trim(), }); } catch (err: unknown) { showLoginError(err, "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); await finishLogin(res, "auth_component", { fallbackUsername: username.trim(), successKey: "messages.registrationSuccess", }); } catch (err: unknown) { showLoginError(err, "errors.unknownError"); } finally { setLoading(false); } } /** Form login for any method: LDAP, a plugin's form, ... */ async function submitMethod( methodId: string, body: Record, instanceId?: string, ) { setLoading(true); try { const res = await submitLoginMethod( methodId, { ...body, rememberMe }, instanceId, ); await finishLogin(res, "method_auth_component"); } catch (err: unknown) { showLoginError(err, "errors.loginFailed"); } finally { setLoading(false); } } async function completeMethodResponse( response: Record, source = "method_auth_component", ) { await finishLogin(response as LoginResponse, source); } async function verifyActiveFactor(body: Record) { setLoading(true); try { const res = await verifySecondFactor(currentFactorId, { ...body, rememberMe, ...(pendingToken ? { temp_token: pendingToken } : {}), }); await finishLogin(res, "second_factor_auth_component", { fallbackUsername: username, }); } catch (err: unknown) { showLoginError(err, "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); } } /** * Sends the browser to a redirect login method. Inside the desktop app * the system browser does it instead, so captcha stages render and the * callback comes back to a local port. */ const startRedirect = useCallback( async (methodId: string, instanceId?: string) => { setLoading(true); try { const callbackPort = 17832 + Math.floor(Math.random() * 100); if (isInElectronWebView()) { const authUrl = await startLoginRedirect(methodId, { instanceId, rememberMe, desktopCallbackPort: callbackPort, }); if (!authUrl) throw new Error(t("errors.invalidAuthUrl")); window.parent.postMessage( { type: "OIDC_SYSTEM_BROWSER_AUTH", source: "oidc_request", authUrl, callbackPort, providerId: -1, }, "*", ); return; } if (isElectron()) { const electronAPI = ( window as unknown as { electronAPI?: { externalBrowserLogin?: ( authUrl: string, port: number, ) => Promise<{ success: boolean; token?: string; error?: string; secondFactor?: boolean; tempToken?: string; factors?: string; }>; }; } ).electronAPI; if (electronAPI?.externalBrowserLogin) { const authUrl = await startLoginRedirect(methodId, { instanceId, rememberMe, desktopCallbackPort: callbackPort, }); if (!authUrl) throw new Error(t("errors.invalidAuthUrl")); const result = await electronAPI.externalBrowserLogin( authUrl, callbackPort, ); if (result.success && result.token) { localStorage.setItem("jwt", result.token); window.location.reload(); return; } if (result.secondFactor) { enterSecondFactorStep( result.tempToken ?? "", parseSecondFactorIds(result.factors ?? null), ); return; } throw new Error(result.error || "Authentication failed"); } } const authUrl = await startLoginRedirect(methodId, { instanceId, rememberMe, }); if (!authUrl || authUrl === "undefined") throw new Error(t("errors.invalidAuthUrl")); window.location.replace(authUrl); } catch (err: unknown) { showLoginError(err, "errors.failedExternalLogin"); setLoading(false); } }, // eslint-disable-next-line react-hooks/exhaustive-deps [rememberMe, t], ); /** Draws a login method with the UI its plugin (or core) registered. */ function renderLoginMethod(method: PublicLoginMethod) { const ui = loginMethodUIs.find((candidate) => candidate.id === method.id); const props = { methodId: method.id, instances: method.instances, rememberMe, disabled: loading, username, submit: (body: Record, instanceId?: string) => submitMethod(method.id, body, instanceId), startRedirect: (instanceId?: string) => startRedirect(method.id, instanceId), complete: (response: Record) => completeMethodResponse(response).catch((err) => showLoginError(err, "errors.loginFailed"), ), }; if (ui) { const Component = ui.component; return ; } // A redirect method needs no UI of its own: one button per instance. if (method.kind === "redirect") { return method.instances.map((instance) => ( )); } return null; } useEffect(() => { if (!authMethodsLoaded || silentSigninHandledRef.current) return; // A login method may ask to start without a click (the sso plugin's // silent sign-in setting); ?silent_signin asks for it once. const autoStart = externalMethods .filter((method) => method.kind === "redirect") .flatMap((method) => method.instances .filter((instance) => instance.autoStart) .map((instance) => ({ method, instance })), )[0]; const urlTriggered = shouldTriggerSilentSignin(window.location.search); if (!urlTriggered && !autoStart) return; if (urlTriggered) { const nextSearch = removeSilentSigninFromSearch(window.location.search); window.history.replaceState( {}, document.title, `${window.location.pathname}${nextSearch}${window.location.hash}`, ); } silentSigninHandledRef.current = true; const redirectMethod = externalMethods.find( (method) => method.kind === "redirect" && method.instances.length > 0, ); const target = autoStart ?? (redirectMethod ? { method: redirectMethod, instance: redirectMethod.instances[0] } : null); if (target && !isElectron()) { void startRedirect(target.method.id, target.instance.id); return; } if (urlTriggered) { toast.info(t("errors.silentSigninExternalUnavailable")); } }, [startRedirect, authMethodsLoaded, externalMethods, t]); // Electron, non-iframed: wait for the auto-session probe before rendering // anything, so a standalone desktop install never flashes a login form // it's about to skip past. if (embeddedServerFailure) { const detail = embeddedServerFailure.reason === "port-in-use" ? embeddedServerFailure.port !== null ? t("messages.embeddedServerPortInUse", { port: embeddedServerFailure.port, }) : t("messages.embeddedServerPortInUseUnknownPort") : embeddedServerFailure.reason === "old-database" ? t("messages.embeddedServerOldDatabase") : t("messages.embeddedServerCrashed"); return (

{t("errors.embeddedServerFailed")}

{detail}

); } if ( isElectron() && !isInElectronWebView() && desktopAutoSessionDone === null ) { return (
); } if (webviewAuthSuccess || (isInElectronWebView() && webviewAuthSuccess)) return (

{t("auth.redirectingToApp")}

); if (dbConnectionFailed) return (

{t("errors.databaseConnection")}

{t("messages.databaseConnectionFailed")}

); if (dbHealthChecking) return (
); if (localDesktopAuth) return (

{desktopManualLogoutActive ? t("auth.localDesktopSignedOut") : t("auth.localDesktopUnavailable")}

{desktopManualLogoutActive ? t("auth.localDesktopSignedOutDesc") : t("auth.localDesktopUnavailableDesc")}

); 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: externalMethods.length > 0, }, ]; return (
{/* Right panel */}
{/* TOTP view */} {view === "second-factor" && (

{t("auth.twoFactorAuth")}

{t("auth.enterCode")}

{shownFactors.length > 1 && (
{shownFactors.map((factor) => { const ui = secondFactorUIs.find( (candidate) => candidate.id === factor.id, ); return ( ); })}
)} {(() => { const ui = secondFactorUIs.find( (candidate) => candidate.id === currentFactorId, ); if (!ui) { return (

{t( authPluginsLoading ? "common.loading" : "auth.secondFactorNoUI", )}

); } const Component = ui.component; return ( challengeSecondFactor( currentFactorId, pendingToken || undefined, ) } cancel={() => switchView("login")} /> ); })()}
)} {/* 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" && (
{rememberAllowed && (
setRememberMe(v === true)} />
)}
{externalMethods.map((method) => renderLoginMethod(method), )} {inlineMethods.map((method) => renderLoginMethod(method))}
)} {view === "login" && (
setUsername(e.target.value)} placeholder="username" className="pl-8" disabled={loading} autoFocus />
{rememberAllowed ? (
setRememberMe(v === true)} disabled={loading} />
) : ( )} {passwordResetAllowed && ( )}
{inlineMethods.map((method) => renderLoginMethod(method))}
)} {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}

)}
); }