From 15a3f5e7427e141c77ce7cfbb92c5149c196bb7e Mon Sep 17 00:00:00 2001 From: Loi Phan Date: Sun, 12 Jul 2026 19:14:15 +0700 Subject: [PATCH] fix: restrict the setting options for user role --- src/app/(dashboard)/dashboard/profile/page.js | 754 +----------------- src/app/api/auth/oidc/test/route.js | 84 +- src/app/api/combos/[id]/strategy/route.js | 48 +- src/app/api/settings/proxy-test/route.js | 23 +- src/app/api/settings/require-login/route.js | 13 +- src/app/api/settings/route.js | 32 +- src/dashboardGuard.js | 21 +- src/shared/components/NineRemoteButton.js | 23 - src/shared/components/NineRemotePromoModal.js | 99 --- src/shared/components/Sidebar.js | 120 ++- src/shared/components/index.js | 1 - 11 files changed, 113 insertions(+), 1105 deletions(-) delete mode 100644 src/shared/components/NineRemoteButton.js delete mode 100644 src/shared/components/NineRemotePromoModal.js diff --git a/src/app/(dashboard)/dashboard/profile/page.js b/src/app/(dashboard)/dashboard/profile/page.js index 834db0f5..57e0ea46 100644 --- a/src/app/(dashboard)/dashboard/profile/page.js +++ b/src/app/(dashboard)/dashboard/profile/page.js @@ -1,7 +1,7 @@ "use client"; import { useState, useEffect, useRef } from "react"; -import { Card, Button, Toggle, Input } from "@/shared/components"; +import { Card, Button, Input } from "@/shared/components"; import Modal, { ConfirmModal } from "@/shared/components/Modal"; import LanguageSwitcher from "@/shared/components/LanguageSwitcher"; import { useTheme } from "@/shared/hooks/useTheme"; @@ -21,7 +21,7 @@ function getLocaleFromCookie() { } export default function ProfilePage() { - const { theme, setTheme, isDark } = useTheme(); + const { theme, setTheme } = useTheme(); const user = useUserStore((state) => state.user); const fetchCurrentUser = useUserStore((state) => state.fetchCurrentUser); const [locale, setLocale] = useState("en"); @@ -37,29 +37,7 @@ export default function ProfilePage() { 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(() => { if (!user) fetchCurrentUser(); @@ -74,20 +52,6 @@ export default function ProfilePage() { .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) => { @@ -96,109 +60,6 @@ export default function ProfilePage() { }); }, []); - 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) { @@ -234,239 +95,6 @@ export default function ProfilePage() { } }; - 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, - 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"); @@ -564,8 +192,6 @@ export default function ProfilePage() { else if (mode === "import") await runImportDatabase(password); }; - const observabilityEnabled = settings.enableObservability === true; - const handleShutdown = async () => { setIsShuttingDown(true); try { @@ -689,30 +315,15 @@ export default function ProfilePage() { - {/* Security */} + {/* Password */}
- shield + password
-

Security

+

Password

-
-
-
-

Require login

-

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

-
- updateRequireLogin(!settings.requireLogin)} - disabled={loading} - /> -
- {settings.requireLogin === true && ( -
+ {settings.hasPassword && (
@@ -766,358 +377,7 @@ export default function ProfilePage() { {settings.hasPassword ? "Update Password" : "Set Password"}
-
- )} -
-
- - {/* 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."} -

-
-
- - {/* 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 */} diff --git a/src/app/api/auth/oidc/test/route.js b/src/app/api/auth/oidc/test/route.js index 85f0aaf6..a56a8bce 100644 --- a/src/app/api/auth/oidc/test/route.js +++ b/src/app/api/auth/oidc/test/route.js @@ -1,84 +1,4 @@ import { NextResponse } from "next/server"; -import { cookies } from "next/headers"; -import { getSettings } from "@/lib/localDb"; -import { fetchOidcDiscovery, getPublicOrigin, probeOidcClientSecret } from "@/lib/auth/oidc"; -import { verifyDashboardAuthToken } from "@/lib/auth/dashboardSession"; - -async function canAccessTestRoute() { - const settings = await getSettings(); - if (settings.requireLogin === false) return true; - - const cookieStore = await cookies(); - const token = cookieStore.get("auth_token")?.value; - return await verifyDashboardAuthToken(token); -} - -export async function POST(request) { - try { - if (!(await canAccessTestRoute())) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const body = await request.json().catch(() => ({})); - const settings = await getSettings(); - - const issuerUrl = String(body.issuerUrl || settings.oidcIssuerUrl || "").trim(); - const clientId = String(body.clientId || settings.oidcClientId || "").trim(); - const scopes = String(body.scopes || settings.oidcScopes || "openid profile email").trim() || "openid profile email"; - const clientSecret = String( - Object.prototype.hasOwnProperty.call(body, "clientSecret") - ? body.clientSecret - : settings.oidcClientSecret || "" - ).trim(); - - if (!issuerUrl) { - return NextResponse.json({ error: "Issuer URL is required" }, { status: 400 }); - } - if (!clientId) { - return NextResponse.json({ error: "Client ID is required" }, { status: 400 }); - } - - const discovery = await fetchOidcDiscovery(issuerUrl); - const redirectUri = `${getPublicOrigin(request)}/api/auth/oidc/callback`; - const secretProbe = await probeOidcClientSecret({ - tokenEndpoint: discovery.token_endpoint, - clientId, - clientSecret, - redirectUri, - }); - - if (secretProbe.tested && secretProbe.valid === false) { - return NextResponse.json({ - ok: false, - discoveryOk: true, - clientSecretTested: true, - clientSecretValid: false, - issuerUrl, - clientId, - scopes, - redirectUri, - authorizationEndpoint: discovery.authorization_endpoint || "", - tokenEndpoint: discovery.token_endpoint || "", - jwksUri: discovery.jwks_uri || "", - error: `Discovery loaded, but the client secret is not valid: ${secretProbe.message}`, - }); - } - - return NextResponse.json({ - ok: true, - discoveryOk: true, - clientSecretTested: secretProbe.tested, - clientSecretValid: secretProbe.valid, - issuerUrl, - clientId, - scopes, - redirectUri, - authorizationEndpoint: discovery.authorization_endpoint || "", - tokenEndpoint: discovery.token_endpoint || "", - jwksUri: discovery.jwks_uri || "", - message: secretProbe.message, - }); - } catch (error) { - return NextResponse.json({ error: error.message || "OIDC test failed" }, { status: 500 }); - } +export async function POST() { + return NextResponse.json({ error: "OIDC settings are not available" }, { status: 403 }); } diff --git a/src/app/api/combos/[id]/strategy/route.js b/src/app/api/combos/[id]/strategy/route.js index 3af80d2a..9d820bc8 100644 --- a/src/app/api/combos/[id]/strategy/route.js +++ b/src/app/api/combos/[id]/strategy/route.js @@ -1,50 +1,6 @@ import { NextResponse } from "next/server"; -import { getComboById, updateComboStrategy } from "@/lib/localDb"; -import { requireUsageDashboardUser } from "@/lib/auth/currentUser"; -import { resetComboRotation } from "open-sse/services/combo.js"; - export const dynamic = "force-dynamic"; -const STRATEGIES = new Set(["fallback", "round-robin", "fusion"]); - -function normalizeStrategy(strategy) { - const normalized = {}; - if (strategy.fallbackStrategy !== undefined) { - if (!STRATEGIES.has(strategy.fallbackStrategy)) return null; - normalized.fallbackStrategy = strategy.fallbackStrategy; - } - if (strategy.judgeModel !== undefined) { - if (typeof strategy.judgeModel !== "string" || strategy.judgeModel.length > 256) return null; - normalized.judgeModel = strategy.judgeModel.trim(); - } - return normalized; -} - -export async function PATCH(request, { params }) { - try { - const user = await requireUsageDashboardUser(); - const { id } = await params; - const ownerId = user.role === "admin" ? undefined : user.id; - const combo = await getComboById(id, ownerId); - if (!combo) return NextResponse.json({ error: "Combo not found" }, { status: 404 }); - - const { strategy } = await request.json(); - if (!strategy || typeof strategy !== "object" || Array.isArray(strategy)) { - return NextResponse.json({ error: "Strategy must be an object" }, { status: 400 }); - } - const normalizedStrategy = normalizeStrategy(strategy); - if (!normalizedStrategy) { - return NextResponse.json({ error: "Invalid combo strategy" }, { status: 400 }); - } - - const settings = await updateComboStrategy(combo.id, normalizedStrategy); - resetComboRotation(combo.id); - return NextResponse.json({ strategy: settings.comboStrategies[combo.id] || {} }); - } catch (error) { - if (error.message === "Unauthorized") { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - console.log("Error updating combo strategy:", error); - return NextResponse.json({ error: "Failed to update combo strategy" }, { status: 500 }); - } +export async function PATCH() { + return NextResponse.json({ error: "Routing strategy settings are not available" }, { status: 403 }); } \ No newline at end of file diff --git a/src/app/api/settings/proxy-test/route.js b/src/app/api/settings/proxy-test/route.js index 8d390d2c..a7aaa7de 100644 --- a/src/app/api/settings/proxy-test/route.js +++ b/src/app/api/settings/proxy-test/route.js @@ -1,23 +1,4 @@ import { NextResponse } from "next/server"; -import { testProxyUrl } from "@/lib/network/proxyTest"; - -export async function POST(request) { - try { - const body = await request.json(); - const result = await testProxyUrl({ - proxyUrl: body?.proxyUrl, - testUrl: body?.testUrl, - timeoutMs: body?.timeoutMs, - }); - - if (result?.ok) { - return NextResponse.json(result); - } - - const status = typeof result?.status === "number" ? result.status : 500; - return NextResponse.json({ ok: false, error: result?.error || "Proxy test failed" }, { status }); - } catch (err) { - const message = err?.name === "AbortError" ? "Proxy test timed out" : (err?.message || String(err)); - return NextResponse.json({ ok: false, error: message }, { status: 500 }); - } +export async function POST() { + return NextResponse.json({ error: "Network settings are not available" }, { status: 403 }); } diff --git a/src/app/api/settings/require-login/route.js b/src/app/api/settings/require-login/route.js index 66227218..2e3ec692 100644 --- a/src/app/api/settings/require-login/route.js +++ b/src/app/api/settings/require-login/route.js @@ -1,15 +1,4 @@ import { NextResponse } from "next/server"; -import { getSettings } from "@/lib/localDb"; - export async function GET() { - try { - const settings = await getSettings(); - const requireLogin = settings.requireLogin !== false; - const tunnelDashboardAccess = settings.tunnelDashboardAccess !== false; - const tunnelUrl = settings.tunnelUrl || ""; - const tailscaleUrl = settings.tailscaleUrl || ""; - return NextResponse.json({ requireLogin, tunnelDashboardAccess, tunnelUrl, tailscaleUrl }); - } catch (error) { - return NextResponse.json({ requireLogin: true }, { status: 200 }); - } + return NextResponse.json({ error: "Login settings are not available" }, { status: 403 }); } diff --git a/src/app/api/settings/route.js b/src/app/api/settings/route.js index 1c774d80..6c774963 100644 --- a/src/app/api/settings/route.js +++ b/src/app/api/settings/route.js @@ -16,6 +16,28 @@ const SETTINGS_RESPONSE_HEADERS = { // Secrets must never be mass-assigned from request body (CWE-915) const PROTECTED_SETTING_KEYS = ["password", "mitmSudoEncrypted"]; +// These capabilities are intentionally not configurable through the dashboard. +// Keep the server-side policy here so callers cannot bypass the hidden UI. +const RESTRICTED_SETTING_KEYS = [ + "requireLogin", + "authMode", + "oidcIssuerUrl", + "oidcClientId", + "oidcClientSecret", + "oidcScopes", + "oidcLoginLabel", + "oidcConfigured", + "fallbackStrategy", + "stickyRoundRobinLimit", + "comboStrategy", + "comboStickyRoundRobinLimit", + "comboStrategies", + "outboundProxyEnabled", + "outboundProxyUrl", + "outboundNoProxy", + "enableObservability", +]; + // Token savers change gateway-wide request processing and can start or manage // local helper processes. They are therefore administrator-only settings. const TOKEN_SAVER_SETTING_KEYS = [ @@ -38,6 +60,7 @@ export async function GET() { try { const settings = await getSettings(); const { password, oidcClientSecret, ...safeSettings } = settings; + for (const key of RESTRICTED_SETTING_KEYS) delete safeSettings[key]; const user = await requireUsageDashboardUser(); if (user.role !== "admin") { const ownedComboIds = new Set((await getCombos(user.id)).map((combo) => combo.id)); @@ -46,8 +69,6 @@ export async function GET() { ); for (const key of TOKEN_SAVER_SETTING_KEYS) delete safeSettings[key]; } - safeSettings.oidcConfigured = !!(safeSettings.oidcIssuerUrl && safeSettings.oidcClientId && oidcClientSecret); - const enableRequestLogs = process.env.ENABLE_REQUEST_LOGS === "true"; const enableTranslator = process.env.ENABLE_TRANSLATOR === "true"; @@ -67,10 +88,13 @@ export async function PATCH(request) { try { const body = await request.json(); + if (RESTRICTED_SETTING_KEYS.some((key) => Object.prototype.hasOwnProperty.call(body, key))) { + return NextResponse.json({ error: "This setting is not available" }, { status: 403 }); + } + if ( Object.prototype.hasOwnProperty.call(body, "requireApiKey") || Object.prototype.hasOwnProperty.call(body, "tunnelDashboardAccess") || - Object.prototype.hasOwnProperty.call(body, "comboStrategies") || TOKEN_SAVER_SETTING_KEYS.some((key) => Object.prototype.hasOwnProperty.call(body, key)) ) { let user; @@ -144,7 +168,7 @@ export async function PATCH(request) { } const { password, oidcClientSecret, ...safeSettings } = settings; - safeSettings.oidcConfigured = !!(safeSettings.oidcIssuerUrl && safeSettings.oidcClientId && oidcClientSecret); + for (const key of RESTRICTED_SETTING_KEYS) delete safeSettings[key]; return NextResponse.json(safeSettings, { headers: SETTINGS_RESPONSE_HEADERS }); } catch (error) { console.log("Error updating settings:", error); diff --git a/src/dashboardGuard.js b/src/dashboardGuard.js index 74487c16..0016828c 100644 --- a/src/dashboardGuard.js +++ b/src/dashboardGuard.js @@ -28,7 +28,6 @@ const PUBLIC_API_PATHS = [ "/api/auth/status", "/api/auth/oidc", "/api/version", - "/api/settings/require-login", ]; // Public top-level prefixes (LLM API endpoints with their own API key auth). @@ -48,11 +47,27 @@ const ALWAYS_PROTECTED = [ // is disabled for local single-user deployments. CLI Tools directly read and // mutate the account running 9Router's local CLI configuration, so they are // host administration rather than per-user dashboard preferences. -const ADMIN_ONLY_PATHS = ["/api/users", "/api/tunnel", "/api/headroom", "/api/pxpipe", "/api/cli-tools"]; +const ADMIN_ONLY_PATHS = [ + "/api/users", + "/api/tunnel", + "/api/headroom", + "/api/pxpipe", + "/api/cli-tools", + "/api/media-providers", + "/api/proxy-pools", + "/api/translator/console-logs", +]; // Dashboard paths requiring an administrator. Combo access is handled by its // owner-scoped API routes and is available to authenticated users. -const ADMIN_ONLY_DASHBOARD_PATHS = ["/dashboard/token-saver", "/dashboard/pxpipe", "/dashboard/cli-tools"]; +const ADMIN_ONLY_DASHBOARD_PATHS = [ + "/dashboard/token-saver", + "/dashboard/pxpipe", + "/dashboard/cli-tools", + "/dashboard/media-providers", + "/dashboard/proxy-pools", + "/dashboard/console-log", +]; // Require auth, but allow through if requireLogin is disabled const PROTECTED_API_PATHS = [ diff --git a/src/shared/components/NineRemoteButton.js b/src/shared/components/NineRemoteButton.js deleted file mode 100644 index c71ed8e6..00000000 --- a/src/shared/components/NineRemoteButton.js +++ /dev/null @@ -1,23 +0,0 @@ -"use client"; - -import { useState } from "react"; -import NineRemotePromoModal from "./NineRemotePromoModal"; - -export default function NineRemoteButton() { - const [isOpen, setIsOpen] = useState(false); - - return ( - <> - - - setIsOpen(false)} /> - - ); -} diff --git a/src/shared/components/NineRemotePromoModal.js b/src/shared/components/NineRemotePromoModal.js deleted file mode 100644 index 27baf79e..00000000 --- a/src/shared/components/NineRemotePromoModal.js +++ /dev/null @@ -1,99 +0,0 @@ -"use client"; - -import { useEffect } from "react"; -import { createPortal } from "react-dom"; - -const FEATURES = [ - { icon: "terminal", label: "Terminal", desc: "Full shell access" }, - { icon: "cast", label: "Desktop", desc: "Screen sharing" }, - { icon: "folder_open", label: "Files", desc: "Browse & edit files" }, -]; - -const BULLETS = [ - { icon: "qr_code_scanner", text: "Scan QR to connect instantly" }, - { icon: "wifi_off", text: "No port forwarding needed" }, - { icon: "devices", text: "Works on any device" }, -]; - -const NINE_REMOTE_URL = "https://9remote.cc"; - -export default function NineRemotePromoModal({ isOpen, onClose }) { - useEffect(() => { - if (!isOpen) return; - document.body.style.overflow = "hidden"; - const onEsc = (e) => { if (e.key === "Escape") onClose(); }; - document.addEventListener("keydown", onEsc); - return () => { document.body.style.overflow = ""; document.removeEventListener("keydown", onEsc); }; - }, [isOpen, onClose]); - - if (!isOpen) return null; - - return createPortal( -
-
- -
- {/* Header */} -
-
-
- terminal -
- 9Remote -
- -
- - {/* Body */} -
- {/* Hero */} -
-
- terminal -
-

9Remote

-

- Access your terminal, desktop & files from anywhere -

-
- - {/* Feature cards */} -
- {FEATURES.map(({ icon, label, desc }) => ( -
- {icon} -

{label}

-

{desc}

-
- ))} -
- - {/* Bullets */} -
- {BULLETS.map(({ icon, text }) => ( -
- {icon} - {text} -
- ))} -
- - {/* CTA */} - -
-
-
, - document.body - ); -} diff --git a/src/shared/components/Sidebar.js b/src/shared/components/Sidebar.js index 713a1317..194597aa 100644 --- a/src/shared/components/Sidebar.js +++ b/src/shared/components/Sidebar.js @@ -11,7 +11,6 @@ import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; import useUserStore from "@/store/userStore"; import Button from "./Button"; import { ConfirmModal } from "./Modal"; -import NineRemotePromoModal from "./NineRemotePromoModal"; // const VISIBLE_MEDIA_KINDS = ["embedding", "image", "imageToText", "tts", "stt", "webSearch", "webFetch", "video", "music"]; const VISIBLE_MEDIA_KINDS = ["embedding", "image", "tts", "stt"]; @@ -32,19 +31,18 @@ const navItems = [ ]; const debugItems = [ - { href: "/dashboard/console-log", label: "Console Log", icon: "terminal" }, + { href: "/dashboard/console-log", label: "Console Log", icon: "terminal", adminOnly: true }, { href: "/dashboard/translator", label: "Translator", icon: "translate" }, ]; const systemItems = [ - { href: "/dashboard/proxy-pools", label: "Proxy Pools", icon: "lan" }, + { href: "/dashboard/proxy-pools", label: "Proxy Pools", icon: "lan", adminOnly: true }, { href: "/dashboard/skills", label: "Skills", icon: "extension" }, ]; export default function Sidebar({ onClose }) { const pathname = usePathname(); const [mediaOpen, setMediaOpen] = useState(false); - const [showRemoteModal, setShowRemoteModal] = useState(false); const [isDisconnected, setIsDisconnected] = useState(false); const [updateInfo, setUpdateInfo] = useState(null); const [showUpdateModal, setShowUpdateModal] = useState(false); @@ -196,58 +194,62 @@ export default function Sidebar({ onClose }) { System

- {/* Media Providers accordion */} - - {mediaOpen && ( -
- {MEDIA_PROVIDER_KINDS.filter((k) => VISIBLE_MEDIA_KINDS.includes(k.id)).map((kind) => ( - - {kind.icon} - {kind.label} - - ))} - +
+ perm_media + Media Providers + + expand_more + + + {mediaOpen && ( +
+ {MEDIA_PROVIDER_KINDS.filter((k) => VISIBLE_MEDIA_KINDS.includes(k.id)).map((kind) => ( + + {kind.icon} + {kind.label} + + ))} + + {COMBINED_WEB_ITEM.icon} + {COMBINED_WEB_ITEM.label} + +
+ )} + )} - {systemItems.map((item) => ( + {systemItems.filter((item) => !item.adminOnly || user?.role === "admin").map((item) => ( { - const show = item.href !== "/dashboard/translator" || enableTranslator; + const show = (!item.adminOnly || user?.role === "admin") && + (item.href !== "/dashboard/translator" || enableTranslator); return show ? ( setShowRemoteModal(true)} - className={cn( - "flex items-center gap-3 px-3 py-1 rounded-lg transition-all group w-full", - "text-text-muted hover:bg-surface-2 hover:text-text-main" - )} - > - - computer - - Remote - - {/* Settings */} - {/* Remote Promo Modal */} - setShowRemoteModal(false)} /> - {/* Update Confirmation Modal */}