"use client"; import { useState, useEffect, useRef } from "react"; import { Card, Button, Toggle, Input } from "@/shared/components"; import Modal, { ConfirmModal } from "@/shared/components/Modal"; import LanguageSwitcher from "@/shared/components/LanguageSwitcher"; import { useTheme } from "@/shared/hooks/useTheme"; import { cn } from "@/shared/utils/cn"; import { APP_CONFIG } from "@/shared/constants/config"; import { LOCALE_COOKIE, normalizeLocale } from "@/i18n/config"; import { LOCALE_FLAGS } from "@/shared/constants/locales"; function getLocaleFromCookie() { if (typeof document === "undefined") return "en"; const cookie = document.cookie .split(";") .find((c) => c.trim().startsWith(`${LOCALE_COOKIE}=`)); const value = cookie ? decodeURIComponent(cookie.split("=")[1]) : "en"; return normalizeLocale(value); } export default function ProfilePage() { const { theme, setTheme, isDark } = useTheme(); const [locale, setLocale] = useState(() => getLocaleFromCookie()); const [langOpen, setLangOpen] = useState(false); const [shutdownOpen, setShutdownOpen] = useState(false); const [isShuttingDown, setIsShuttingDown] = useState(false); const [settings, setSettings] = useState({ fallbackStrategy: "fill-first" }); const [loading, setLoading] = useState(true); const [passwords, setPasswords] = useState({ current: "", new: "", confirm: "" }); const [passStatus, setPassStatus] = useState({ type: "", message: "" }); const [passLoading, setPassLoading] = useState(false); const [dbLoading, setDbLoading] = useState(false); const [dbStatus, setDbStatus] = useState({ type: "", message: "" }); const [dbAuth, setDbAuth] = useState({ open: false, mode: "", password: "" }); const pendingImportRef = useRef(null); const [oidcForm, setOidcForm] = useState({ authMode: "password", oidcIssuerUrl: "", oidcClientId: "", oidcScopes: "openid profile email", oidcLoginLabel: "Sign in with OIDC", }); const [oidcClientSecret, setOidcClientSecret] = useState(""); const [oidcStatus, setOidcStatus] = useState({ type: "", message: "" }); const [oidcLoading, setOidcLoading] = useState(false); const [oidcTestLoading, setOidcTestLoading] = useState(false); const [oidcTestStatus, setOidcTestStatus] = useState({ type: "", message: "" }); const [oidcExpanded, setOidcExpanded] = useState(false); const origin = typeof window !== "undefined" ? window.location.origin : ""; const oidcRedirectUri = origin ? `${origin}/api/auth/oidc/callback` : "/api/auth/oidc/callback"; const samlAcsUrl = origin ? `${origin}/api/auth/saml/acs` : "/api/auth/saml/acs"; const samlMetadataUrl = origin ? `${origin}/api/auth/saml/metadata` : "/api/auth/saml/metadata"; // SAML State const [ssoTypeTab, setSsoTypeTab] = useState("saml"); const [samlForm, setSamlForm] = useState({ samlEntryPoint: "", samlIssuer: "urn:9router:sp", samlCert: "", samlLoginLabel: "Sign in with SAML SSO", samlAttributeEmail: "email", samlAttributeName: "name", }); const [samlStatus, setSamlStatus] = useState({ type: "", message: "" }); const [samlLoading, setSamlLoading] = useState(false); const [samlTestLoading, setSamlTestLoading] = useState(false); const [samlTestStatus, setSamlTestStatus] = useState({ type: "", message: "" }); const [showSamlGuide, setShowSamlGuide] = useState(false); const idpMetadataFileRef = useRef(null); const certFileRef = useRef(null); const importFileRef = useRef(null); const [proxyForm, setProxyForm] = useState({ outboundProxyEnabled: false, outboundProxyUrl: "", outboundNoProxy: "", }); const [proxyStatus, setProxyStatus] = useState({ type: "", message: "" }); const [proxyLoading, setProxyLoading] = useState(false); const [proxyTestLoading, setProxyTestLoading] = useState(false); useEffect(() => { fetch("/api/settings") .then((res) => res.json()) .then((data) => { setSettings(data); setOidcForm({ authMode: data?.authMode || "password", oidcIssuerUrl: data?.oidcIssuerUrl || "", oidcClientId: data?.oidcClientId || "", oidcScopes: data?.oidcScopes || "openid profile email", oidcLoginLabel: data?.oidcLoginLabel || "Sign in with OIDC", }); setOidcClientSecret(""); setSsoTypeTab(data?.ssoType || "saml"); setSamlForm({ samlEntryPoint: data?.samlEntryPoint || "", samlIssuer: data?.samlIssuer || "urn:9router:sp", samlCert: data?.samlCert || "", samlLoginLabel: data?.samlLoginLabel || "Sign in with SAML SSO", samlAttributeEmail: data?.samlAttributeEmail || "email", samlAttributeName: data?.samlAttributeName || "name", }); if ( data?.authMode === "sso" || data?.authMode === "saml" || data?.authMode === "oidc" || data?.authMode === "both" ) { setOidcExpanded(true); } setProxyForm({ outboundProxyEnabled: data?.outboundProxyEnabled === true, outboundProxyUrl: data?.outboundProxyUrl || "", outboundNoProxy: data?.outboundNoProxy || "", }); setLoading(false); }) .catch((err) => { console.error("Failed to fetch settings:", err); setLoading(false); }); }, []); const updateOutboundProxy = async (e) => { e.preventDefault(); if (settings.outboundProxyEnabled !== true) return; setProxyLoading(true); setProxyStatus({ type: "", message: "" }); try { const res = await fetch("/api/settings", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ outboundProxyUrl: proxyForm.outboundProxyUrl, outboundNoProxy: proxyForm.outboundNoProxy, }), }); const data = await res.json(); if (res.ok) { setSettings((prev) => ({ ...prev, ...data })); setProxyStatus({ type: "success", message: "Proxy settings applied" }); } else { setProxyStatus({ type: "error", message: data.error || "Failed to update proxy settings" }); } } catch (err) { setProxyStatus({ type: "error", message: "An error occurred" }); } finally { setProxyLoading(false); } }; const testOutboundProxy = async () => { if (settings.outboundProxyEnabled !== true) return; const proxyUrl = (proxyForm.outboundProxyUrl || "").trim(); if (!proxyUrl) { setProxyStatus({ type: "error", message: "Please enter a Proxy URL to test" }); return; } setProxyTestLoading(true); setProxyStatus({ type: "", message: "" }); try { const res = await fetch("/api/settings/proxy-test", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ proxyUrl }), }); const data = await res.json(); if (res.ok && data?.ok) { setProxyStatus({ type: "success", message: `Proxy test OK (${data.status}) in ${data.elapsedMs}ms`, }); } else { setProxyStatus({ type: "error", message: data?.error || "Proxy test failed", }); } } catch (err) { setProxyStatus({ type: "error", message: "An error occurred" }); } finally { setProxyTestLoading(false); } }; const updateOutboundProxyEnabled = async (outboundProxyEnabled) => { setProxyLoading(true); setProxyStatus({ type: "", message: "" }); try { const res = await fetch("/api/settings", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ outboundProxyEnabled }), }); const data = await res.json(); if (res.ok) { setSettings((prev) => ({ ...prev, ...data })); setProxyForm((prev) => ({ ...prev, outboundProxyEnabled: data?.outboundProxyEnabled === true })); setProxyStatus({ type: "success", message: outboundProxyEnabled ? "Proxy enabled" : "Proxy disabled", }); } else { setProxyStatus({ type: "error", message: data.error || "Failed to update proxy settings" }); } } catch (err) { setProxyStatus({ type: "error", message: "An error occurred" }); } finally { setProxyLoading(false); } }; const handlePasswordChange = async (e) => { e.preventDefault(); if (passwords.new !== passwords.confirm) { setPassStatus({ type: "error", message: "Passwords do not match" }); return; } setPassLoading(true); setPassStatus({ type: "", message: "" }); try { const res = await fetch("/api/settings", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ currentPassword: passwords.current, newPassword: passwords.new, }), }); const data = await res.json(); if (res.ok) { setPassStatus({ type: "success", message: "Password updated successfully" }); setPasswords({ current: "", new: "", confirm: "" }); } else { setPassStatus({ type: "error", message: data.error || "Failed to update password" }); } } catch (err) { setPassStatus({ type: "error", message: "An error occurred" }); } finally { setPassLoading(false); } }; const updateFallbackStrategy = async (strategy) => { try { const res = await fetch("/api/settings", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ fallbackStrategy: strategy }), }); if (res.ok) { setSettings(prev => ({ ...prev, fallbackStrategy: strategy })); } } catch (err) { console.error("Failed to update settings:", err); } }; const updateComboStrategy = async (strategy) => { try { const res = await fetch("/api/settings", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ comboStrategy: strategy }), }); if (res.ok) { setSettings(prev => ({ ...prev, comboStrategy: strategy })); } } catch (err) { console.error("Failed to update combo strategy:", err); } }; const updateStickyLimit = async (limit) => { const numLimit = parseInt(limit); if (isNaN(numLimit) || numLimit < 1) return; try { const res = await fetch("/api/settings", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ stickyRoundRobinLimit: numLimit }), }); if (res.ok) { setSettings(prev => ({ ...prev, stickyRoundRobinLimit: numLimit })); } } catch (err) { console.error("Failed to update sticky limit:", err); } }; const updateComboStickyLimit = async (limit) => { const numLimit = parseInt(limit); if (isNaN(numLimit) || numLimit < 1) return; try { const res = await fetch("/api/settings", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ comboStickyRoundRobinLimit: numLimit }), }); if (res.ok) { setSettings(prev => ({ ...prev, comboStickyRoundRobinLimit: numLimit })); } } catch (err) { console.error("Failed to update combo sticky limit:", err); } }; const updateRequireLogin = async (requireLogin) => { try { const res = await fetch("/api/settings", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ requireLogin }), }); if (res.ok) { setSettings(prev => ({ ...prev, requireLogin })); } } catch (err) { console.error("Failed to update require login:", err); } }; const updateOidcForm = (field, value) => { setOidcForm((prev) => ({ ...prev, [field]: value })); }; const saveOidcSettings = async (authMode = oidcForm.authMode || "password") => { const issuerUrl = oidcForm.oidcIssuerUrl.trim(); const clientId = oidcForm.oidcClientId.trim(); const scopes = oidcForm.oidcScopes.trim(); const loginLabel = oidcForm.oidcLoginLabel.trim(); const secret = oidcClientSecret.trim(); if (authMode !== "password" && (!issuerUrl || !clientId || !secret) && !settings.oidcConfigured) { setOidcStatus({ type: "error", message: "Issuer URL, client ID, and client secret are required to enable OIDC." }); return; } setOidcLoading(true); setOidcStatus({ type: "", message: "" }); setOidcTestStatus({ type: "", message: "" }); try { const payload = { authMode, ssoType: "oidc", oidcIssuerUrl: issuerUrl, oidcClientId: clientId, oidcScopes: scopes || "openid profile email", oidcLoginLabel: loginLabel || "Sign in with OIDC", }; if (secret) { payload.oidcClientSecret = secret; } const res = await fetch("/api/settings", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); const data = await res.json(); if (res.ok) { setSettings((prev) => ({ ...prev, ...data })); setOidcForm({ authMode: data?.authMode || authMode, oidcIssuerUrl: data?.oidcIssuerUrl || issuerUrl, oidcClientId: data?.oidcClientId || clientId, oidcScopes: data?.oidcScopes || scopes || "openid profile email", oidcLoginLabel: data?.oidcLoginLabel || loginLabel || "Sign in with OIDC", }); setOidcClientSecret(""); setOidcStatus({ type: "success", message: authMode === "oidc" ? "OIDC login enabled" : authMode === "both" ? "Password and OIDC login enabled" : "OIDC settings saved", }); } else { setOidcStatus({ type: "error", message: data.error || "Failed to save OIDC settings" }); } } catch (err) { setOidcStatus({ type: "error", message: "An error occurred" }); } finally { setOidcLoading(false); } }; const testOidcConnection = async () => { const issuerUrl = oidcForm.oidcIssuerUrl.trim(); const clientId = oidcForm.oidcClientId.trim(); const scopes = oidcForm.oidcScopes.trim(); const secret = oidcClientSecret.trim(); if (!issuerUrl || !clientId) { setOidcTestStatus({ type: "error", message: "Issuer URL and client ID are required to test the connection." }); return; } setOidcTestLoading(true); setOidcStatus({ type: "", message: "" }); setOidcTestStatus({ type: "", message: "" }); try { const saveRes = await fetch("/api/settings", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ authMode: oidcForm.authMode || settings.authMode || "password", oidcIssuerUrl: issuerUrl, oidcClientId: clientId, oidcScopes: scopes || "openid profile email", oidcLoginLabel: oidcForm.oidcLoginLabel.trim() || "Sign in with OIDC", ...(secret ? { oidcClientSecret: secret } : {}), }), }); const saved = await saveRes.json().catch(() => ({})); if (!saveRes.ok) { setOidcTestStatus({ type: "error", message: saved.error || "Failed to save OIDC settings before testing", }); return; } const res = await fetch("/api/auth/oidc/test", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ issuerUrl: saved.oidcIssuerUrl || issuerUrl, clientId: saved.oidcClientId || clientId, scopes: saved.oidcScopes || scopes || "openid profile email", }), }); const data = await res.json().catch(() => ({})); if (res.ok && data?.ok) { const statusMessage = data.clientSecretTested ? data.clientSecretValid === true ? `Connection OK. Discovery loaded from ${data.issuerUrl}. Client secret validated too.` : `Connection OK. Discovery loaded from ${data.issuerUrl}. Client secret was not checked.` : `Connection OK. Discovery loaded from ${data.issuerUrl}.`; setOidcTestStatus({ type: "success", message: statusMessage, }); } else { setOidcTestStatus({ type: "error", message: data.error || "OIDC connection test failed" }); } } catch (err) { setOidcTestStatus({ type: "error", message: "An error occurred" }); } finally { setOidcTestLoading(false); } }; const updateSamlForm = (field, value) => { setSamlForm((prev) => ({ ...prev, [field]: value })); }; const handleIdpMetadataUpload = (event) => { const file = event.target.files?.[0]; if (idpMetadataFileRef.current) idpMetadataFileRef.current.value = ""; if (!file) return; const reader = new FileReader(); reader.onload = (e) => { try { const xmlText = e.target?.result || ""; const parser = new DOMParser(); const doc = parser.parseFromString(xmlText, "text/xml"); const parserError = doc.querySelector("parsererror"); if (parserError) { setSamlStatus({ type: "error", message: "Unable to parse valid SAML IdP metadata from XML file" }); return; } const entityID = doc.documentElement.getAttribute("entityID") || ""; const ssoNodes = Array.from(doc.querySelectorAll("SingleSignOnService, *|SingleSignOnService")); let ssoUrl = ""; for (const node of ssoNodes) { const binding = node.getAttribute("Binding") || ""; const location = node.getAttribute("Location") || ""; if (location) { ssoUrl = location; if (binding.includes("HTTP-Redirect")) break; } } const certNodes = Array.from(doc.querySelectorAll("X509Certificate, *|X509Certificate")); let certStr = ""; if (certNodes.length > 0) { certStr = certNodes[0].textContent.trim(); } setSamlForm((prev) => ({ ...prev, samlEntryPoint: ssoUrl || prev.samlEntryPoint, samlIssuer: prev.samlIssuer || "urn:9router:sp", samlCert: certStr || prev.samlCert, })); setSamlStatus({ type: "success", message: `IdP Metadata imported! (SSO URL: ${ssoUrl ? "found" : "not found"}, EntityID: ${entityID ? "found" : "not found"}, Cert: ${certStr ? "found" : "not found"})`, }); } catch (err) { setSamlStatus({ type: "error", message: "Error reading IdP Metadata XML file" }); } }; reader.readAsText(file); }; const handleCertFileUpload = (event) => { const file = event.target.files?.[0]; if (certFileRef.current) certFileRef.current.value = ""; if (!file) return; const reader = new FileReader(); reader.onload = (e) => { const text = e.target?.result || ""; setSamlForm((prev) => ({ ...prev, samlCert: text.trim() })); setSamlStatus({ type: "success", message: "Certificate file loaded into configuration." }); }; reader.readAsText(file); }; const saveSamlSettings = async (targetAuthMode = oidcForm.authMode || "password") => { setSamlLoading(true); setSamlStatus({ type: "", message: "" }); setSamlTestStatus({ type: "", message: "" }); try { const payload = { authMode: targetAuthMode, ssoType: "saml", samlEntryPoint: samlForm.samlEntryPoint.trim(), samlIssuer: samlForm.samlIssuer.trim() || "urn:9router:sp", samlCert: samlForm.samlCert.trim(), samlLoginLabel: samlForm.samlLoginLabel.trim() || "Sign in with SAML SSO", samlAttributeEmail: samlForm.samlAttributeEmail.trim() || "email", samlAttributeName: samlForm.samlAttributeName.trim() || "name", }; const res = await fetch("/api/settings", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); const data = await res.json(); if (res.ok) { setSettings((prev) => ({ ...prev, ...data })); setSamlForm({ samlEntryPoint: data?.samlEntryPoint || payload.samlEntryPoint, samlIssuer: data?.samlIssuer || payload.samlIssuer, samlCert: data?.samlCert || payload.samlCert, samlLoginLabel: data?.samlLoginLabel || payload.samlLoginLabel, samlAttributeEmail: data?.samlAttributeEmail || payload.samlAttributeEmail, samlAttributeName: data?.samlAttributeName || payload.samlAttributeName, }); setSamlStatus({ type: "success", message: targetAuthMode === "sso" || targetAuthMode === "saml" ? "SAML SSO login enabled" : targetAuthMode === "both" ? "Password and SAML SSO login enabled" : "SAML 2.0 settings saved", }); } else { setSamlStatus({ type: "error", message: data.error || "Failed to save SAML settings" }); } } catch { setSamlStatus({ type: "error", message: "An error occurred while saving SAML settings" }); } finally { setSamlLoading(false); } }; const testSamlConnection = async () => { setSamlTestLoading(true); setSamlStatus({ type: "", message: "" }); setSamlTestStatus({ type: "", message: "" }); try { const res = await fetch("/api/auth/saml/test", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ samlEntryPoint: samlForm.samlEntryPoint.trim(), samlIssuer: samlForm.samlIssuer.trim(), samlCert: samlForm.samlCert.trim(), }), }); const data = await res.json(); if (res.ok && data.ok) { setSamlTestStatus({ type: "success", message: data.message || "SAML configuration verified!" }); } else { setSamlTestStatus({ type: "error", message: data.error || "SAML configuration test failed" }); } } catch { setSamlTestStatus({ type: "error", message: "An error occurred while testing SAML configuration" }); } finally { setSamlTestLoading(false); } }; const updateObservabilityEnabled = async (enabled) => { try { const res = await fetch("/api/settings", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ enableObservability: enabled }), }); if (res.ok) { setSettings(prev => ({ ...prev, enableObservability: enabled })); } } catch (err) { console.error("Failed to update enableObservability:", err); } }; const reloadSettings = async () => { try { const res = await fetch("/api/settings"); if (!res.ok) return; const data = await res.json(); setSettings(data); } catch (err) { console.error("Failed to reload settings:", err); } }; const handleExportDatabase = async (password) => { setDbLoading(true); setDbStatus({ type: "", message: "" }); try { const res = await fetch("/api/settings/database", { headers: { "x-9r-password": password }, }); if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error(data.error || "Failed to export database"); } const payload = await res.json(); const content = JSON.stringify(payload, null, 2); const blob = new Blob([content], { type: "application/json" }); const url = URL.createObjectURL(blob); const anchor = document.createElement("a"); const stamp = new Date().toISOString().replace(/[.:]/g, "-"); anchor.href = url; anchor.download = `9router-backup-${stamp}.json`; document.body.appendChild(anchor); anchor.click(); document.body.removeChild(anchor); URL.revokeObjectURL(url); setDbStatus({ type: "success", message: "Database backup downloaded" }); } catch (err) { setDbStatus({ type: "error", message: err.message || "Failed to export database" }); } finally { setDbLoading(false); } }; const handleImportDatabase = (event) => { const file = event.target.files?.[0]; if (importFileRef.current) importFileRef.current.value = ""; if (!file) return; pendingImportRef.current = file; setDbStatus({ type: "", message: "" }); setDbAuth({ open: true, mode: "import", password: "" }); }; const runImportDatabase = async (password) => { const file = pendingImportRef.current; if (!file) return; setDbLoading(true); try { const raw = await file.text(); const payload = JSON.parse(raw); const res = await fetch("/api/settings/database", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ...payload, password }), }); const data = await res.json().catch(() => ({})); if (!res.ok) { throw new Error(data.error || "Failed to import database"); } await reloadSettings(); setDbStatus({ type: "success", message: "Database imported successfully" }); } catch (err) { setDbStatus({ type: "error", message: err.message || "Invalid backup file" }); } finally { pendingImportRef.current = null; setDbLoading(false); } }; // Confirm password modal, then run export or import. const handleDbAuthConfirm = async () => { const { mode, password } = dbAuth; setDbAuth({ open: false, mode: "", password: "" }); if (mode === "export") await handleExportDatabase(password); else if (mode === "import") await runImportDatabase(password); }; const observabilityEnabled = settings.enableObservability === true; const handleShutdown = async () => { setIsShuttingDown(true); try { await fetch("/api/version/shutdown", { method: "POST" }); } catch (e) { // Expected to fail as server shuts down; ignore error } setIsShuttingDown(false); setShutdownOpen(false); }; const handleLogout = async () => { try { const res = await fetch("/api/auth/logout", { method: "POST" }); if (res.ok) { window.location.assign("/login"); } } catch (err) { console.error("Failed to logout:", err); } }; return (
{/* Local Mode Info */}
computer

Local Mode

Running on your machine

{["light", "dark", "system"].map((option) => ( ))}

Database Location

~/.9router/db/data.sqlite

{dbStatus.message && (

{dbStatus.message}

)}
{/* Language */}
language

Language

{/* Security */}
shield

Security

Require login

When ON, dashboard requires password. When OFF, access without login.

updateRequireLogin(!settings.requireLogin)} disabled={loading} />
{settings.requireLogin === true && (
{settings.hasPassword && (
setPasswords({ ...passwords, current: e.target.value })} required />
)} {/* {!settings.hasPassword && (

Setting password for the first time. Leave current password empty or use default: 123456

)} */}
setPasswords({ ...passwords, new: e.target.value })} required />
setPasswords({ ...passwords, confirm: e.target.value })} required />
{passStatus.message && (

{passStatus.message}

)}
)}
{/* Single Sign-On (SSO) */} {oidcExpanded && (

Configure enterprise Single Sign-On (SSO) for dashboard access using SAML 2.0 or OIDC.

{/* SSO Protocol Switcher Tabs */}
{/* Auth Mode selection */}
{[ { value: "password", title: "Password only", desc: "Keep legacy password login.", }, { value: "sso", title: `${ssoTypeTab === "saml" ? "SAML" : "OIDC"} only`, desc: "Require SSO for dashboard access.", }, { value: "both", title: "Both", desc: "Allow password or SSO login.", }, ].map((option) => { const currentMode = oidcForm.authMode; const active = option.value === "password" ? currentMode === "password" : option.value === "sso" ? currentMode === "sso" || currentMode === "saml" || currentMode === "oidc" : currentMode === "both"; return ( ); })}
{ssoTypeTab === "saml" ? ( /* SAML Configuration Panel */
{/* IdP Setup Guidelines Banner & Collapsible Drawer */}
{showSamlGuide && (

🔑 Required Service Provider (SP) Values for your IdP Setup:

  • Assertion Consumer Service (ACS) URL:{" "} {samlAcsUrl}
  • SP Entity ID / Audience URI:{" "} {samlForm.samlIssuer || "urn:9router:sp"}
  • NameID Format:{" "} EmailAddress or Unspecified

☁️ AWS IAM Identity Center

  1. Applications → Add application → Select Add custom SAML 2.0 application.
  2. Set Application ACS URL to {samlAcsUrl}.
  3. Set Application SAML audience to {samlForm.samlIssuer || "urn:9router:sp"}.
  4. Under Attribute mappings, map Subject or email to ${`{user:email}`}.
  5. Download IAM Identity Center SAML metadata XML file and use 1-Click Import below!

🔷 Microsoft Entra ID (Azure AD)

  1. Enterprise Applications → New application → Create your own application.
  2. Select Single sign-on → SAML.
  3. Identifier (Entity ID): {samlForm.samlIssuer || "urn:9router:sp"}
  4. Reply URL (ACS): {samlAcsUrl}
  5. Download Federation Metadata XML and import or copy X.509 Certificate.

🟢 Okta / Auth0

  1. Applications → Create App Integration → Select SAML 2.0.
  2. Single Sign-On URL: {samlAcsUrl}
  3. Audience URI (SP Entity ID): {samlForm.samlIssuer || "urn:9router:sp"}
  4. Name ID format: EmailAddress.
  5. Download Identity Provider metadata XML or copy the X.509 cert.

🛡️ Keycloak / Authentik

  1. Clients → Create client → Select SAML.
  2. Client ID: {samlForm.samlIssuer || "urn:9router:sp"}
  3. Master SAML Processing URL: {samlAcsUrl}
  4. Export SAML Descriptor XML or copy IDP Certificate PEM.
)}
{/* Quick Import Card */}

1-Click IdP Metadata XML Import

Auto-fill SSO URL, Issuer & Cert from XML metadata

updateSamlForm("samlEntryPoint", e.target.value)} disabled={loading || samlLoading} />
updateSamlForm("samlIssuer", e.target.value)} disabled={loading || samlLoading} />