"use client"; import { useState, useEffect, useRef, useCallback } from "react"; import { useRouter } from "@/i18n/navigation"; import { useParams, useSearchParams } from "next/navigation"; import { useTranslations } from "next-intl"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { useAuthStore } from "@/stores/auth-store"; import { useAccountStore } from "@/stores/account-store"; import { useThemeStore } from "@/stores/theme-store"; import { useShallow } from "zustand/react/shallow"; import { useConfig } from "@/hooks/use-config"; import { apiFetch, getPathPrefix, withBasePath } from "@/lib/browser-navigation"; import { cn } from "@/lib/utils"; import { AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield, Play, Copy } from "lucide-react"; import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery"; import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce"; import { useUpdateStore, selectBanner } from "@/stores/update-store"; import type { PublicJmapServerEntry } from "@/lib/admin/jmap-servers"; function findServerByDomain(servers: PublicJmapServerEntry[], email: string | undefined): PublicJmapServerEntry | undefined { if (!email || !email.includes("@")) return undefined; const domain = email.split("@")[1]?.trim().toLowerCase(); if (!domain) return undefined; return servers.find((s) => (s.domains ?? []).some((d) => d.toLowerCase() === domain)); } const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0"; const GIT_COMMIT = process.env.NEXT_PUBLIC_GIT_COMMIT || "unknown"; const THEME_OPTIONS = [ { value: "light" as const, icon: Sun, label: "Light" }, { value: "dark" as const, icon: Moon, label: "Dark" }, { value: "system" as const, icon: Monitor, label: "System" }, ]; function VersionBadge() { const [copied, setCopied] = useState(false); const banner = useUpdateStore(useShallow(selectBanner)); const startPolling = useUpdateStore((s) => s.startPolling); useEffect(() => { startPolling(); }, [startPolling]); const versionInfo = `Version: ${APP_VERSION}\nBuild: ${GIT_COMMIT}${banner?.latest ? `\nLatest: ${banner.latest}` : ""}`; const handleCopy = () => { navigator.clipboard.writeText(versionInfo).then(() => { setCopied(true); setTimeout(() => setCopied(false), 2000); }); }; const isRed = banner?.variant === "red"; const triggerText = !banner ? `v${APP_VERSION}` : banner.severity === "security" ? "Security update available" : banner.severity === "deprecated" ? "Version no longer supported" : "New version available"; const triggerColor = !banner ? "text-muted-foreground/40" : isRed ? "text-red-600/80 dark:text-red-400/80 hover:text-red-600 dark:hover:text-red-400" : "text-amber-600/80 dark:text-amber-400/80 hover:text-amber-600 dark:hover:text-amber-400"; const triggerClass = cn( "peer text-center text-xs transition-colors", triggerColor, banner?.url ? "cursor-pointer underline-offset-2 hover:underline" : "cursor-default", ); const trigger = banner?.url ? ( {triggerText} ) : (

{triggerText}

); return (
{trigger}

Version: {APP_VERSION}

Build: {GIT_COMMIT}

{banner?.latest && (

Latest: {banner.latest}

)} {banner?.advisory && (

{banner.advisory}

)}
); } // Only redirect targets matching this scheme are honored by the mobile // handoff path. Without the check the login page becomes an open redirector // that funnels password and token material to any caller-supplied URL. const MOBILE_REDIRECT_SCHEME = "bulwarkmobile://"; export default function LoginPage() { const router = useRouter(); const t = useTranslations("login"); const params = useParams(); const searchParams = useSearchParams(); const isAddAccountMode = searchParams.get("mode") === "add-account"; // When the mobile app launches the webmail in a browser tab it tacks on // these params. We grab them once at mount and stash them in a ref so any // login path that completes (password or OAuth) can hand control back to // the app instead of routing into /mail. const rawMobileRedirectUri = searchParams.get("mobile_redirect_uri") ?? ""; const rawMobileState = searchParams.get("mobile_state") ?? ""; const mobileRedirectUri = rawMobileRedirectUri.startsWith(MOBILE_REDIRECT_SCHEME) ? rawMobileRedirectUri : ""; const mobileState = mobileRedirectUri ? rawMobileState : ""; const isMobileHandoff = Boolean(mobileRedirectUri); const { login, loginDemo, isLoading, error, clearError, isAuthenticated } = useAuthStore(); const { theme, setTheme, initializeTheme } = useThemeStore(useShallow((s) => ({ theme: s.theme, setTheme: s.setTheme, initializeTheme: s.initializeTheme }))); const { appName, jmapServerUrl: configuredServerUrl, oauthEnabled, oauthOnly, oauthClientId: globalOauthClientId, oauthIssuerUrl: globalOauthIssuerUrl, oauthScopes, rememberMeEnabled, devMode, demoMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, isLoading: configLoading, error: configError, autoSsoEnabled, embeddedMode: _embeddedMode, allowCustomJmapEndpoint, jmapServers, jmapServerAutoPickByDomain } = useConfig(); const resolvedTheme = useThemeStore((s) => s.resolvedTheme); const [formData, setFormData] = useState({ username: "", password: "", }); const [jmapEndpoint, setJmapEndpoint] = useState(""); const [selectedServerId, setSelectedServerId] = useState(null); const [domainAutoLocked, setDomainAutoLocked] = useState(false); const hasServerList = jmapServers.length > 0; const selectedServer = hasServerList ? jmapServers.find((s) => s.id === selectedServerId) ?? jmapServers[0] : undefined; // Effective values: per-server overrides win, then global config. const serverUrl = selectedServer?.url || configuredServerUrl; const effectiveOauthClientId = selectedServer?.oauth?.clientId || globalOauthClientId; const effectiveOauthIssuerUrl = selectedServer?.oauth?.issuerUrl || globalOauthIssuerUrl; const [totpCode, setTotpCode] = useState(""); const [showTotpField, setShowTotpField] = useState(false); const [rememberMe, setRememberMe] = useState(false); const [sessionExpired, setSessionExpired] = useState(false); const [showPassword, setShowPassword] = useState(false); const [shakeError, setShakeError] = useState(false); const [showThemeMenu, setShowThemeMenu] = useState(false); const [savedUsernames, setSavedUsernames] = useState([]); const [showSuggestions, setShowSuggestions] = useState(false); const [filteredSuggestions, setFilteredSuggestions] = useState([]); const [selectedSuggestionIndex, setSelectedSuggestionIndex] = useState(-1); const [oauthMetadata, setOauthMetadata] = useState(null); const [oauthDiscoveryDone, setOauthDiscoveryDone] = useState(false); const [oauthLoading, setOauthLoading] = useState(false); const [demoLoading, setDemoLoading] = useState(false); const suggestionsRef = useRef(null); const inputRef = useRef(null); const justSelectedSuggestion = useRef(false); const totpInputRef = useRef(null); const prevError = useRef(null); const themeMenuRef = useRef(null); // Captured by handleSubmit when in mobile handoff mode; consumed by the // isAuthenticated effect to build the deep-link fragment. const mobileHandoffPayloadRef = useRef<{ server_url: string; username: string; password: string } | null>(null); useEffect(() => { initializeTheme(); }, [initializeTheme]); useEffect(() => { if (serverUrl) { document.title = appName; } }, [appName, serverUrl]); useEffect(() => { if (serverUrl && !jmapEndpoint) { setJmapEndpoint(serverUrl); } }, [serverUrl, jmapEndpoint]); // Initialize selected server when the server list arrives. Picks the first // entry; the auto-pick effect below may override based on the email domain. useEffect(() => { if (!hasServerList) return; if (selectedServerId && jmapServers.some((s) => s.id === selectedServerId)) return; setSelectedServerId(jmapServers[0].id); }, [hasServerList, jmapServers, selectedServerId]); // Auto-pick by email domain. Locks the dropdown to the matched server until // the user clears the email or types a domain we don't recognize. useEffect(() => { if (!jmapServerAutoPickByDomain || !hasServerList) return; const match = findServerByDomain(jmapServers, formData.username); if (match) { if (selectedServerId !== match.id) setSelectedServerId(match.id); setDomainAutoLocked(true); } else { setDomainAutoLocked(false); } }, [jmapServerAutoPickByDomain, hasServerList, jmapServers, formData.username, selectedServerId]); useEffect(() => { try { if (sessionStorage.getItem('session_expired') === 'true') { setSessionExpired(true); sessionStorage.removeItem('session_expired'); } } catch { /* sessionStorage unavailable */ } }, []); useEffect(() => { if (error && error !== prevError.current) { setShakeError(true); const timer = setTimeout(() => setShakeError(false), 400); return () => clearTimeout(timer); } prevError.current = error; }, [error]); // Auto-show and focus TOTP field when server requires it useEffect(() => { if (error === 'totp_required') { setShowTotpField(true); setTimeout(() => totpInputRef.current?.focus(), 100); } }, [error]); useEffect(() => { if (!serverUrl) return; const saved = localStorage.getItem("webmail_usernames"); if (saved) { try { const usernames = JSON.parse(saved); setSavedUsernames(usernames); } catch { console.error("Failed to parse saved usernames"); } } }, [serverUrl]); useEffect(() => { if (isAuthenticated && !isAddAccountMode) { // Mobile handoff: the password path completes here once the auth store // flips isAuthenticated. Hand the verified credentials back to the // mobile app instead of pushing to /mail. handleSubmit captured the // values needed for the fragment. if (isMobileHandoff && mobileHandoffPayloadRef.current) { const fragment = new URLSearchParams({ flow: "password", ...mobileHandoffPayloadRef.current, state: mobileState, }); window.location.replace(`${mobileRedirectUri}#${fragment.toString()}`); return; } let redirectTo = '/'; try { const saved = sessionStorage.getItem('redirect_after_login'); if (saved) { sessionStorage.removeItem('redirect_after_login'); redirectTo = saved; } } catch { /* ignore */ } router.push(redirectTo); } }, [isAuthenticated, router, isAddAccountMode, isMobileHandoff, mobileRedirectUri, mobileState]); useEffect(() => { clearError(); }, [formData, clearError]); useEffect(() => { if (!serverUrl) return; if (justSelectedSuggestion.current) { justSelectedSuggestion.current = false; return; } if (formData.username && savedUsernames.length > 0) { const filtered = savedUsernames.filter(username => username.toLowerCase().includes(formData.username.toLowerCase()) ); setFilteredSuggestions(filtered); setShowSuggestions(filtered.length > 0); } else if (formData.username === "" && savedUsernames.length > 0) { setFilteredSuggestions(savedUsernames); setShowSuggestions(false); } else { setShowSuggestions(false); } setSelectedSuggestionIndex(-1); }, [formData.username, savedUsernames, serverUrl]); useEffect(() => { if (!serverUrl) return; const handleClickOutside = (event: MouseEvent) => { if (suggestionsRef.current && !suggestionsRef.current.contains(event.target as Node) && inputRef.current && !inputRef.current.contains(event.target as Node)) { setShowSuggestions(false); } if (themeMenuRef.current && !themeMenuRef.current.contains(event.target as Node)) { setShowThemeMenu(false); } }; document.addEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside); }, [serverUrl]); useEffect(() => { if (!oauthEnabled || !serverUrl) return; setOauthDiscoveryDone(false); setOauthMetadata(null); discoverOAuth(effectiveOauthIssuerUrl || serverUrl) .then((metadata) => { setOauthMetadata(metadata); setOauthDiscoveryDone(true); }) .catch(() => { setOauthMetadata(null); setOauthDiscoveryDone(true); }); }, [oauthEnabled, serverUrl, effectiveOauthIssuerUrl]); // Auto-SSO: when enabled with OAUTH_ONLY, skip the login page entirely const ssoError = searchParams.get("sso_error"); const autoSsoTriggered = useRef(false); const startServerSideSso = useCallback(async () => { setOauthLoading(true); try { const prefix = getPathPrefix(params.locale as string); const redirectUri = `${window.location.origin}${prefix}/${params.locale}/auth/callback`; // In mobile-handoff mode the callback page needs to know it should // redirect into the app rather than into /mail. Stash the params in // sessionStorage so the same-tab callback can read them - the SSO // pending cookie carries the authoritative copy server-side too. if (isMobileHandoff) { try { sessionStorage.setItem("mobile_redirect_uri", mobileRedirectUri); sessionStorage.setItem("mobile_state", mobileState); } catch { /* sessionStorage unavailable */ } } const res = await apiFetch('/api/auth/sso/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ redirect_uri: redirectUri, locale: params.locale, server_id: selectedServer?.id, ...(isMobileHandoff ? { mobile_redirect_uri: mobileRedirectUri, mobile_state: mobileState } : {}), }), }); if (!res.ok) { setOauthLoading(false); return; } const { authorize_url } = await res.json(); // Navigate to the authorize URL const isIframe = (() => { try { return window.self !== window.top; } catch { return true; } })(); if (isIframe) { // In an iframe, try top-level navigation try { window.top!.location.href = authorize_url; } catch { // Cross-origin restriction - fall back to current frame window.location.href = authorize_url; } } else { window.location.href = authorize_url; } } catch { setOauthLoading(false); } }, [params.locale, selectedServer?.id, isMobileHandoff, mobileRedirectUri, mobileState]); useEffect(() => { if (!autoSsoEnabled || !oauthOnly || !oauthDiscoveryDone || !oauthMetadata) return; if (ssoError || isAddAccountMode || isAuthenticated) return; if (autoSsoTriggered.current) return; // Guard against redirect loops try { if (sessionStorage.getItem("sso_attempted")) return; sessionStorage.setItem("sso_attempted", "1"); // Clear the flag after 30 seconds so retries are possible setTimeout(() => { try { sessionStorage.removeItem("sso_attempted"); } catch { /* ignore */ } }, 30000); } catch { /* sessionStorage unavailable */ } autoSsoTriggered.current = true; startServerSideSso(); }, [autoSsoEnabled, oauthOnly, oauthDiscoveryDone, oauthMetadata, ssoError, isAddAccountMode, isAuthenticated, startServerSideSso]); const handleThemeSelect = useCallback((newTheme: "light" | "dark" | "system") => { setTheme(newTheme); setShowThemeMenu(false); }, [setTheme]); if (configLoading) { return (
{t("loading")}
); } if (configError) { return (

{t("config_error.title")}

{t("config_error.fetch_failed")}

); } if (!serverUrl && !demoMode && !allowCustomJmapEndpoint) { return (

{t("config_error.title")}

{t("config_error.server_not_configured")}

); } const saveUsername = (username: string) => { const saved = localStorage.getItem("webmail_usernames"); let usernames: string[] = []; if (saved) { try { usernames = JSON.parse(saved); } catch { console.error("Failed to parse saved usernames"); } } if (!usernames.includes(username)) { usernames = [username, ...usernames].slice(0, 5); localStorage.setItem("webmail_usernames", JSON.stringify(usernames)); setSavedUsernames(usernames); } }; const removeUsername = (username: string, e: React.MouseEvent) => { e.stopPropagation(); const updated = savedUsernames.filter(u => u !== username); localStorage.setItem("webmail_usernames", JSON.stringify(updated)); setSavedUsernames(updated); setFilteredSuggestions(updated.filter(u => u.toLowerCase().includes(formData.username.toLowerCase()) )); }; const handleUsernameChange = (e: React.ChangeEvent) => { setFormData({ ...formData, username: e.target.value }); }; const handleUsernameFocus = () => { if (savedUsernames.length > 0 && formData.username === "") { setFilteredSuggestions(savedUsernames); setShowSuggestions(true); } else if (filteredSuggestions.length > 0) { setShowSuggestions(true); } }; const selectSuggestion = (username: string) => { justSelectedSuggestion.current = true; setFormData({ ...formData, username }); setShowSuggestions(false); document.getElementById("password")?.focus(); }; const handleKeyDown = (e: React.KeyboardEvent) => { if (!showSuggestions || filteredSuggestions.length === 0) return; if (e.key === "ArrowDown") { e.preventDefault(); setSelectedSuggestionIndex(prev => prev < filteredSuggestions.length - 1 ? prev + 1 : prev ); } else if (e.key === "ArrowUp") { e.preventDefault(); setSelectedSuggestionIndex(prev => prev > 0 ? prev - 1 : -1); } else if (e.key === "Enter" && selectedSuggestionIndex >= 0) { e.preventDefault(); selectSuggestion(filteredSuggestions[selectedSuggestionIndex]); } else if (e.key === "Escape") { setShowSuggestions(false); setSelectedSuggestionIndex(-1); } }; const handleOAuthLogin = async () => { if (!oauthMetadata || !effectiveOauthClientId) return; // In mobile-handoff mode the client-side PKCE flow doesn't help us: // tokens would land in sessionStorage on the webmail origin and the // mobile app couldn't read them. Route through the server-side SSO // path instead, which has the mobile-aware /api/auth/sso/complete // branch. if (isMobileHandoff) { await startServerSideSso(); return; } setOauthLoading(true); const verifier = generateCodeVerifier(); const challenge = await generateCodeChallenge(verifier); const state = generateState(); const prefix = getPathPrefix(params.locale as string); const redirectUri = `${window.location.origin}${prefix}/${params.locale}/auth/callback`; // Resolve the JMAP URL to send to the callback. Server-list entries win // over the custom-endpoint input, which wins over the global server URL. const oauthServerUrl = selectedServer?.url || (allowCustomJmapEndpoint ? jmapEndpoint : configuredServerUrl); sessionStorage.setItem("oauth_code_verifier", verifier); sessionStorage.setItem("oauth_state", state); sessionStorage.setItem("oauth_server_url", oauthServerUrl!); if (selectedServer?.id) { sessionStorage.setItem("oauth_server_id", selectedServer.id); } else { sessionStorage.removeItem("oauth_server_id"); } if (isAddAccountMode) { sessionStorage.setItem("oauth_add_account_mode", "true"); } // Persist the next-free cookie slot so loginWithOAuth (in stores/auth-store.ts) // writes the refresh token to the correct per-account jmap_rt_ cookie. // loginWithOAuth reads this key but it was previously never written, so every // OAuth account collapsed onto slot 0 and clobbered earlier accounts' refresh // tokens. getNextCookieSlot() returns 0 when no accounts exist (correct for // first sign-in) and the lowest unused slot otherwise (correct for "+ Add // Account"). const nextSlot = useAccountStore.getState().getNextCookieSlot(); sessionStorage.setItem("oauth_cookie_slot", nextSlot.toString()); const authUrl = new URL(oauthMetadata.authorization_endpoint); authUrl.searchParams.set("response_type", "code"); authUrl.searchParams.set("client_id", effectiveOauthClientId); authUrl.searchParams.set("redirect_uri", redirectUri); authUrl.searchParams.set("scope", oauthScopes || "openid email profile"); authUrl.searchParams.set("state", state); authUrl.searchParams.set("code_challenge", challenge); authUrl.searchParams.set("code_challenge_method", "S256"); window.location.href = authUrl.toString(); }; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); // Server-list entries always win - `allowCustomJmapEndpoint` is only honored // when the admin hasn't configured a server list. const effectiveServerUrl = selectedServer?.url || (allowCustomJmapEndpoint ? jmapEndpoint : serverUrl); // Capture before login() so the isAuthenticated effect can build the // deep-link fragment with values the user actually typed (formData may // be cleared by the auth store on success). if (isMobileHandoff) { mobileHandoffPayloadRef.current = { server_url: effectiveServerUrl, username: formData.username, password: formData.password, }; } const success = await login( effectiveServerUrl, formData.username, formData.password, totpCode || undefined, rememberMe ); if (success) { saveUsername(formData.username); if (isMobileHandoff) { // The isAuthenticated effect handles the redirect; nothing else to // do here. Don't push to / - that would race the deep link. return; } router.push('/'); } else if (isMobileHandoff) { // Stale payload should never feed into a later retry's redirect. mobileHandoffPayloadRef.current = null; } }; const handleDevLogin = async () => { const success = await login(serverUrl, "dev@localhost", "dev"); if (success) { let redirectTo = '/'; try { const saved = sessionStorage.getItem('redirect_after_login'); if (saved) { sessionStorage.removeItem('redirect_after_login'); redirectTo = saved; } } catch { /* ignore */ } router.push(redirectTo); } }; const handleDemoLogin = async () => { setDemoLoading(true); const success = await loginDemo(); if (success) { router.push('/'); } setDemoLoading(false); }; const currentThemeOption = THEME_OPTIONS.find(o => o.value === theme) || THEME_OPTIONS[2]; const CurrentThemeIcon = currentThemeOption.icon; // Demo-only mode: show only a large demo login button if (demoMode && !isAddAccountMode) { return (
{/* Theme toggle */}
{showThemeMenu && (
{THEME_OPTIONS.map((option) => { const Icon = option.icon; const isActive = theme === option.value; return ( ); })}
)}
{/* Header with logo */}
{appName}

{appName}

{t("demo_tagline")}

{/* Large demo button */}
{error && (

{t(`error.${error}`) || t("error.generic")}

)}

{t("demo_no_signup")}

{/* Footer */}
{loginCompanyName && (

{loginCompanyName}

)} {(loginImprintUrl || loginPrivacyPolicyUrl || loginWebsiteUrl) && (
{loginWebsiteUrl && ( {t("website")} )} {loginImprintUrl && ( {t("imprint")} )} {loginPrivacyPolicyUrl && ( {t("privacy_policy")} )}
)}
); } return (
{/* Theme toggle - top right, dropdown style */}
{showThemeMenu && (
{THEME_OPTIONS.map((option) => { const Icon = option.icon; const isActive = theme === option.value; return ( ); })}
)}
{/* Card container */}
{/* Header section with logo */}
{appName}

{isAddAccountMode ? t("add_account_title") : appName}

{isAddAccountMode ? t("add_account_subtitle") : (t("title") !== appName ? t("title") : "Sign in to your account")}

{/* Form section */}
{/* Session Expired Banner */} {sessionExpired && (

{t("session_expired")}

)} {/* Error Message */} {error && (

{error === 'invalid_credentials' && showTotpField && totpCode ? t('error.totp_invalid') : t(`error.${error}`) || t("error.generic")}

)} {/* Dev Mode: One-click login */} {devMode ? (

Dev mode - logging in as dev@localhost

) : oauthOnly ? ( /* OAuth-only mode: show SSO button only */
{oauthMetadata ? ( ) : oauthDiscoveryDone ? (

{t("error.oauth_discovery_failed")}

) : (
)}
) : ( /* Login Form */
{/* Server picker (when admin has configured a server list) */} {hasServerList && jmapServers.length > 1 && (
{domainAutoLocked && (

{t("jmap_server_auto_picked")}

)}
)} {/* JMAP Endpoint field (only when no server list and custom endpoints are allowed) */} {!hasServerList && allowCustomJmapEndpoint && (
setJmapEndpoint(e.target.value)} className="h-11 px-3.5 bg-muted/40 border-border/60 rounded-xl focus:bg-background focus:border-primary/50 transition-all duration-200" placeholder={t("jmap_endpoint_placeholder")} required />

{t("jmap_endpoint_cors_hint")}

)} {/* Username field */}
{/* Custom autocomplete dropdown */} {showSuggestions && filteredSuggestions.length > 0 && (
{filteredSuggestions.map((username, index) => (
selectSuggestion(username)} > {username}
))}
)}
{/* Password field */}
setFormData({ ...formData, password: e.target.value })} className="h-11 px-3.5 pr-11 bg-muted/40 border-border/60 rounded-xl focus:bg-background focus:border-primary/50 transition-all duration-200" placeholder={t("password_placeholder")} required autoComplete="current-password" />
{/* 2FA toggle / field */} {!showTotpField ? ( ) : (
setTotpCode(e.target.value.replace(/\D/g, ''))} className={cn( "h-11 px-3.5 bg-muted/40 border-border/60 rounded-xl focus:bg-background focus:border-primary/50 transition-all duration-200 text-center font-mono tracking-widest", error === 'totp_required' && "border-primary ring-2 ring-primary/30" )} placeholder={t("totp_placeholder")} autoComplete="one-time-code" aria-label={t("totp_label")} />
)} {/* Remember me */} {rememberMeEnabled && ( )}
{oauthMetadata && ( <>
{t("or")}
)} {oauthEnabled && oauthDiscoveryDone && !oauthMetadata && (

{t("error.oauth_discovery_failed")}

)}
)} {isAddAccountMode && (
)} {/* Demo Mode Button */} {demoMode && !isAddAccountMode && (

{t("demo_description")}

)}
{/* Company name & links - below card */}
{loginCompanyName && (

{loginCompanyName}

)} {(loginImprintUrl || loginPrivacyPolicyUrl || loginWebsiteUrl) && (
{loginWebsiteUrl && ( {t("website")} )} {loginImprintUrl && ( {t("imprint")} )} {loginPrivacyPolicyUrl && ( {t("privacy_policy")} )}
)}
); }