/* eslint-disable react-refresh/only-export-components */
import {
isInElectronWebView,
isInMobileWebView,
type EmbeddedFrameWindow,
} from "@/lib/embedded-frame";
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,
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";
}
function storeAuth(username: string) {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({ loggedIn: true, username }),
);
}
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 [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((result) => {
if (!result.enabled || !result.success) 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));
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;
if (attemptNumber >= maxAttempts) {
setDbConnectionFailed(true);
setDbHealthChecking(false);
return;
}
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;
}
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;
}
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" && (
)}
{resetStep === "code" && (
)}
{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" && (
{externalMethods.map((method) =>
renderLoginMethod(method),
)}
{inlineMethods.map((method) => renderLoginMethod(method))}
)}
{view === "login" && (
)}
{view === "register" && (
)}
{view === "login" && registrationAllowed ? (
<>
{t("auth.noAccount", "Don't have an account?")}{" "}
>
) : view === "register" &&
passwordLoginAllowed &&
!firstUser ? (
<>
{t("auth.hasAccount", "Already have an account?")}{" "}
>
) : null}
)}
);
}