"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 (
{/* 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}

)}
)}
{/* OIDC */} {oidcExpanded && (

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.

{[ { value: "password", title: "Password only", desc: "Keep the legacy password login.", }, { value: "oidc", title: "OIDC only", desc: "Require OIDC for dashboard access.", }, { value: "both", title: "Both", desc: "Allow either password or OIDC.", }, ].map((option) => { const active = oidcForm.authMode === option.value; return ( ); })}
updateOidcForm("oidcIssuerUrl", e.target.value)} disabled={loading || oidcLoading} />
updateOidcForm("oidcClientId", e.target.value)} disabled={loading || oidcLoading} />
setOidcClientSecret(e.target.value)} disabled={loading || oidcLoading} />

This value is write-only after saving.

updateOidcForm("oidcScopes", e.target.value)} disabled={loading || oidcLoading} />
updateOidcForm("oidcLoginLabel", e.target.value)} disabled={loading || oidcLoading} />

Redirect URI

{oidcRedirectUri}
{oidcTestStatus.message && (

{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.

)}
)}
{/* Routing Preferences */}
route

Routing Strategy

Round Robin

Cycle through accounts to distribute load

updateFallbackStrategy(settings.fallbackStrategy === "round-robin" ? "fill-first" : "round-robin")} disabled={loading} />
{/* Sticky Round Robin Limit */} {settings.fallbackStrategy === "round-robin" && (

Sticky Limit

Calls per account before switching

updateStickyLimit(e.target.value)} disabled={loading} className="w-16 sm:w-20 text-center shrink-0" />
)} {/* Combo Round Robin */}

Combo Round Robin

Cycle through providers in combos instead of always starting with first

updateComboStrategy(settings.comboStrategy === "round-robin" ? "fallback" : "round-robin")} disabled={loading} />
{/* Combo Sticky Round Robin Limit */} {settings.comboStrategy === "round-robin" && (

Combo Sticky Limit

Calls per combo model before switching

updateComboStickyLimit(e.target.value)} disabled={loading} className="w-20 text-center" />
)}

{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."}

{/* Timeouts */}
timer

Timeouts

Stream Stall Timeout

Seconds without upstream data before abort (per-provider config can override)

updateStreamStallTimeout(e.target.value)} disabled={loading} className="w-16 sm:w-20 text-center shrink-0" />

Fetch Connect Timeout

Seconds to wait for upstream response headers

updateFetchConnectTimeout(e.target.value)} disabled={loading} className="w-16 sm:w-20 text-center shrink-0" />
{/* Network */}
wifi

Network

Outbound Proxy

Enable proxy for OAuth + provider outbound requests.

updateOutboundProxyEnabled(!(settings.outboundProxyEnabled === true))} disabled={loading || proxyLoading} />
{settings.outboundProxyEnabled === true && (
setProxyForm((prev) => ({ ...prev, outboundProxyUrl: e.target.value }))} disabled={loading || proxyLoading} />

Leave empty to inherit existing env proxy (if any).

setProxyForm((prev) => ({ ...prev, outboundNoProxy: e.target.value }))} disabled={loading || proxyLoading} />

Comma-separated hostnames/domains to bypass the proxy.

)} {proxyStatus.message && (

{proxyStatus.message}

)}
{/* Observability Settings */}
monitoring

Observability

Enable Observability

Record request details for inspection in the logs view

{/* Account actions */}
{/* App Info */}

{APP_CONFIG.name} v{APP_CONFIG.version}

Local Mode - All data stored on your machine

{ setLangOpen(false); setLocale(next); }} /> setShutdownOpen(false)} onConfirm={handleShutdown} title="Close Proxy" message="Are you sure you want to close the proxy server?" confirmText="Close" cancelText="Cancel" variant="danger" loading={isShuttingDown} /> setDbAuth({ open: false, mode: "", password: "" })} title="Confirm Password" size="sm" footer={ <> } >

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 />
); }