"use client"; import { useState, useEffect, useRef } from "react"; import { useRouter } from "next/navigation"; 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 router = useRouter(); const { theme, setTheme, isDark } = useTheme(); const [locale, setLocale] = useState("en"); 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 [oidcRedirectUri, setOidcRedirectUri] = useState("/api/auth/oidc/callback"); const [oidcExpanded, setOidcExpanded] = useState(false); 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(() => { setLocale(getLocaleFromCookie()); }, [langOpen]); 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(""); if (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); }); }, []); useEffect(() => { if (typeof window !== "undefined") { setOidcRedirectUri(`${window.location.origin}/api/auth/oidc/callback`); } }, []); 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 updateStreamStallTimeout = async (val) => { const num = parseInt(val); if (isNaN(num) || num < 1) return; try { const res = await fetch("/api/settings", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ streamStallTimeoutMs: num }), }); if (res.ok) setSettings(prev => ({ ...prev, streamStallTimeoutMs: num })); } catch (err) { console.error("Failed to update stream stall timeout:", err); } }; const updateFetchConnectTimeout = async (val) => { const num = parseInt(val); if (isNaN(num) || num < 1) return; try { const res = await fetch("/api/settings", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ fetchConnectTimeoutMs: num }), }); if (res.ok) setSettings(prev => ({ ...prev, fetchConnectTimeoutMs: num })); } catch (err) { console.error("Failed to update fetch connect timeout:", 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, 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 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) { router.push("/login"); router.refresh(); } } catch (err) { console.error("Failed to logout:", err); } }; return (
Running on your machine
Database Location
~/.9router/db/data.sqlite
{dbStatus.message}
)}Require login
When ON, dashboard requires password. When OFF, access without login.
Use Authentik or any OIDC provider to sign in to the dashboard. You can enable password-only, OIDC-only, or both for the dashboard; model API access still uses API keys.
This value is write-only after saving.
Redirect URI
{oidcRedirectUri}
{oidcTestStatus.message}
)} {oidcStatus.message && ({oidcStatus.message}
)} {settings.authMode === "oidc" && (OIDC login is currently active. Password login is disabled until you switch back.
)} {settings.authMode === "both" && (Password and OIDC login are both active.
)}Round Robin
Cycle through accounts to distribute load
Sticky Limit
Calls per account before switching
Combo Round Robin
Cycle through providers in combos instead of always starting with first
Combo Sticky Limit
Calls per combo model before switching
{settings.fallbackStrategy === "round-robin" ? `Currently distributing requests across all available accounts with ${settings.stickyRoundRobinLimit || 3} calls per account.` : "Currently using accounts in priority order (Fill First)."} {settings.comboStrategy === "round-robin" ? ` Combos rotate after ${settings.comboStickyRoundRobinLimit || 1} call${(settings.comboStickyRoundRobinLimit || 1) === 1 ? "" : "s"} per model.` : " Combos always start with their first model."}
Stream Stall Timeout
Seconds without upstream data before abort (per-provider config can override)
Fetch Connect Timeout
Seconds to wait for upstream response headers
Outbound Proxy
Enable proxy for OAuth + provider outbound requests.
{proxyStatus.message}
)}Enable Observability
Record request details for inspection in the logs view
{APP_CONFIG.name} v{APP_CONFIG.version}
Local Mode - All data stored on your machine
Enter your current password to {dbAuth.mode === "export" ? "export" : "import"} the database.
setDbAuth((s) => ({ ...s, password: e.target.value }))} onKeyDown={(e) => { if (e.key === "Enter" && dbAuth.password) handleDbAuthConfirm(); }} placeholder="Current password" autoFocus />