mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 13:38:31 +00:00
fix: import the behavior of the web
This commit is contained in:
@@ -26,7 +26,6 @@ export default function APIPageClient({ machineId, isAdmin }) {
|
||||
const [confirmState, setConfirmState] = useState(null);
|
||||
|
||||
const [requireApiKey, setRequireApiKey] = useState(false);
|
||||
const [requireLogin, setRequireLogin] = useState(true);
|
||||
const [hasPassword, setHasPassword] = useState(true);
|
||||
const [tunnelDashboardAccess, setTunnelDashboardAccess] = useState(false);
|
||||
|
||||
@@ -86,11 +85,9 @@ export default function APIPageClient({ machineId, isAdmin }) {
|
||||
|
||||
const { copied, copy } = useCopyToClipboard();
|
||||
|
||||
// Security gate: block remote exposure while dashboard uses default password or login is off.
|
||||
const isLoginUnsafe = !requireLogin || !hasPassword;
|
||||
const unsafeReason = !requireLogin
|
||||
? "Enable \"Require login\" and set a custom password before activating the tunnel."
|
||||
: "Change the default dashboard password before activating the tunnel.";
|
||||
// Security gate: block remote exposure while dashboard uses the default password.
|
||||
const isLoginUnsafe = !hasPassword;
|
||||
const unsafeReason = "Change the default dashboard password before activating the tunnel.";
|
||||
|
||||
// Auto-scroll install log
|
||||
useEffect(() => {
|
||||
@@ -201,7 +198,6 @@ export default function APIPageClient({ machineId, isAdmin }) {
|
||||
if (settingsRes.ok) {
|
||||
const data = await settingsRes.json();
|
||||
setRequireApiKey(data.requireApiKey || false);
|
||||
setRequireLogin(data.requireLogin !== false);
|
||||
setHasPassword(data.hasPassword || false);
|
||||
setTunnelDashboardAccess(data.tunnelDashboardAccess || false);
|
||||
}
|
||||
@@ -925,15 +921,11 @@ export default function APIPageClient({ machineId, isAdmin }) {
|
||||
action={{ label: "Enable", href: "#require-api-key" }}
|
||||
/>
|
||||
)}
|
||||
{(!requireLogin || !hasPassword) && (
|
||||
{!hasPassword && (
|
||||
<SecurityWarning
|
||||
message={
|
||||
!requireLogin
|
||||
? "Require login is disabled — anyone can access your dashboard via tunnel."
|
||||
: "Dashboard uses the default password — change it in Profile settings."
|
||||
}
|
||||
message="Dashboard uses the default password — change it in Profile settings."
|
||||
action={{
|
||||
label: !requireLogin ? "Enable" : "Change password",
|
||||
label: "Change password",
|
||||
href: "/dashboard/profile",
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Card, Button, Input } from "@/shared/components";
|
||||
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";
|
||||
@@ -23,6 +23,9 @@ function getLocaleFromCookie() {
|
||||
export default function ProfilePage() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const user = useUserStore((state) => state.user);
|
||||
// Only standard user accounts are restricted. Local mode has no dashboard
|
||||
// session, so it must retain administrator-level settings controls.
|
||||
const isAdmin = user?.role !== "user";
|
||||
const fetchCurrentUser = useUserStore((state) => state.fetchCurrentUser);
|
||||
const [locale, setLocale] = useState("en");
|
||||
const [langOpen, setLangOpen] = useState(false);
|
||||
@@ -37,7 +40,18 @@ 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 [oidcExpanded, setOidcExpanded] = useState(false);
|
||||
const importFileRef = useRef(null);
|
||||
const [proxyForm, setProxyForm] = useState({ outboundProxyUrl: "", outboundNoProxy: "" });
|
||||
const [proxyStatus, setProxyStatus] = useState({ type: "", message: "" });
|
||||
const [proxyLoading, setProxyLoading] = useState(false);
|
||||
const [proxyTestLoading, setProxyTestLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) fetchCurrentUser();
|
||||
@@ -52,6 +66,8 @@ 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" });
|
||||
setProxyForm({ outboundProxyUrl: data?.outboundProxyUrl || "", outboundNoProxy: data?.outboundNoProxy || "" });
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
@@ -60,6 +76,55 @@ export default function ProfilePage() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const updateAdminSettings = async (changes) => {
|
||||
const res = await fetch("/api/settings", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(changes) });
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || "Failed to update settings");
|
||||
setSettings((previous) => ({ ...previous, ...data }));
|
||||
return data;
|
||||
};
|
||||
|
||||
const saveOidcSettings = async () => {
|
||||
setOidcLoading(true);
|
||||
setOidcStatus({ type: "", message: "" });
|
||||
try {
|
||||
const payload = { ...oidcForm, oidcIssuerUrl: oidcForm.oidcIssuerUrl.trim(), oidcClientId: oidcForm.oidcClientId.trim(), oidcScopes: oidcForm.oidcScopes.trim() || "openid profile email", oidcLoginLabel: oidcForm.oidcLoginLabel.trim() || "Sign in with OIDC" };
|
||||
if (oidcClientSecret.trim()) payload.oidcClientSecret = oidcClientSecret.trim();
|
||||
const data = await updateAdminSettings(payload);
|
||||
setOidcForm((previous) => ({ ...previous, authMode: data.authMode || previous.authMode }));
|
||||
setOidcClientSecret("");
|
||||
setOidcStatus({ type: "success", message: "OIDC settings saved" });
|
||||
} catch (error) { setOidcStatus({ type: "error", message: error.message }); } finally { setOidcLoading(false); }
|
||||
};
|
||||
|
||||
const testOidcConnection = async () => {
|
||||
setOidcTestLoading(true);
|
||||
setOidcTestStatus({ type: "", message: "" });
|
||||
try {
|
||||
const res = await fetch("/api/auth/oidc/test", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ issuerUrl: oidcForm.oidcIssuerUrl, clientId: oidcForm.oidcClientId, scopes: oidcForm.oidcScopes }) });
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data.ok) throw new Error(data.error || "OIDC connection test failed");
|
||||
setOidcTestStatus({ type: "success", message: "OIDC connection successful" });
|
||||
} catch (error) { setOidcTestStatus({ type: "error", message: error.message }); } finally { setOidcTestLoading(false); }
|
||||
};
|
||||
|
||||
const saveProxySettings = async (event) => {
|
||||
event.preventDefault();
|
||||
setProxyLoading(true);
|
||||
try { await updateAdminSettings(proxyForm); setProxyStatus({ type: "success", message: "Proxy settings applied" }); }
|
||||
catch (error) { setProxyStatus({ type: "error", message: error.message }); } finally { setProxyLoading(false); }
|
||||
};
|
||||
|
||||
const testOutboundProxy = async () => {
|
||||
setProxyTestLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/settings/proxy-test", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ proxyUrl: proxyForm.outboundProxyUrl }) });
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data.ok) throw new Error(data.error || "Proxy test failed");
|
||||
setProxyStatus({ type: "success", message: "Proxy test successful" });
|
||||
} catch (error) { setProxyStatus({ type: "error", message: error.message }); } finally { setProxyTestLoading(false); }
|
||||
};
|
||||
|
||||
const handlePasswordChange = async (e) => {
|
||||
e.preventDefault();
|
||||
if (passwords.new !== passwords.confirm) {
|
||||
@@ -315,6 +380,51 @@ export default function ProfilePage() {
|
||||
</button>
|
||||
</Card>
|
||||
|
||||
{isAdmin && (
|
||||
<>
|
||||
<Card>
|
||||
<button type="button" onClick={() => setOidcExpanded((value) => !value)} className="w-full flex items-center gap-3 text-left">
|
||||
<div className="p-2 rounded-lg bg-indigo-500/10 text-indigo-500 shrink-0"><span className="material-symbols-outlined text-[20px]">lock_open</span></div>
|
||||
<div className="flex-1"><h3 className="text-base sm:text-lg font-semibold">OIDC Dashboard Login</h3><p className="text-xs text-text-muted">Configure OIDC single sign-on for the dashboard.</p></div>
|
||||
<span className="material-symbols-outlined text-text-muted">{oidcExpanded ? "expand_less" : "expand_more"}</span>
|
||||
</button>
|
||||
{oidcExpanded && (
|
||||
<div className="flex flex-col gap-4 mt-4 pt-4 border-t border-border/50">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
{["password", "oidc", "both"].map((mode) => <Button key={mode} type="button" variant={oidcForm.authMode === mode ? "primary" : "outline"} onClick={() => setOidcForm((previous) => ({ ...previous, authMode: mode }))}>{mode}</Button>)}
|
||||
</div>
|
||||
<Input placeholder="Issuer URL" value={oidcForm.oidcIssuerUrl} onChange={(event) => setOidcForm((previous) => ({ ...previous, oidcIssuerUrl: event.target.value }))} />
|
||||
<Input placeholder="Client ID" value={oidcForm.oidcClientId} onChange={(event) => setOidcForm((previous) => ({ ...previous, oidcClientId: event.target.value }))} />
|
||||
<Input type="password" placeholder="Client Secret (leave blank to keep)" value={oidcClientSecret} onChange={(event) => setOidcClientSecret(event.target.value)} />
|
||||
<Input placeholder="Scopes" value={oidcForm.oidcScopes} onChange={(event) => setOidcForm((previous) => ({ ...previous, oidcScopes: event.target.value }))} />
|
||||
<Input placeholder="Login button label" value={oidcForm.oidcLoginLabel} onChange={(event) => setOidcForm((previous) => ({ ...previous, oidcLoginLabel: event.target.value }))} />
|
||||
<div className="flex gap-2"><Button type="button" variant="primary" loading={oidcLoading} onClick={saveOidcSettings}>Save</Button><Button type="button" variant="outline" loading={oidcTestLoading} onClick={testOidcConnection}>Test connection</Button></div>
|
||||
{[oidcStatus, oidcTestStatus].filter((status) => status.message).map((status, index) => <p key={index} className={`text-sm ${status.type === "error" ? "text-red-500" : "text-green-500"}`}>{status.message}</p>)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div className="flex items-center gap-3 mb-4"><div className="p-2 rounded-lg bg-blue-500/10 text-blue-500"><span className="material-symbols-outlined text-[20px]">route</span></div><h3 className="text-base sm:text-lg font-semibold">Routing Strategy</h3></div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between gap-4"><div><p className="font-medium">Round Robin</p><p className="text-xs text-text-muted">Cycle through accounts to distribute load.</p></div><Toggle checked={settings.fallbackStrategy === "round-robin"} onChange={() => updateAdminSettings({ fallbackStrategy: settings.fallbackStrategy === "round-robin" ? "fill-first" : "round-robin" })} /></div>
|
||||
<div className="flex items-center justify-between gap-4"><div><p className="font-medium">Combo Round Robin</p><p className="text-xs text-text-muted">Cycle through providers in combos.</p></div><Toggle checked={settings.comboStrategy === "round-robin"} onChange={() => updateAdminSettings({ comboStrategy: settings.comboStrategy === "round-robin" ? "fallback" : "round-robin" })} /></div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div className="flex items-center gap-3 mb-4"><div className="p-2 rounded-lg bg-purple-500/10 text-purple-500"><span className="material-symbols-outlined text-[20px]">wifi</span></div><h3 className="text-base sm:text-lg font-semibold">Network</h3></div>
|
||||
<div className="flex items-center justify-between gap-4 mb-4"><div><p className="font-medium">Outbound Proxy</p><p className="text-xs text-text-muted">Proxy OAuth and provider outbound requests.</p></div><Toggle checked={settings.outboundProxyEnabled === true} onChange={() => updateAdminSettings({ outboundProxyEnabled: !settings.outboundProxyEnabled })} /></div>
|
||||
{settings.outboundProxyEnabled === true && <form onSubmit={saveProxySettings} className="flex flex-col gap-3 pt-4 border-t border-border/50"><Input placeholder="Proxy URL" value={proxyForm.outboundProxyUrl} onChange={(event) => setProxyForm((previous) => ({ ...previous, outboundProxyUrl: event.target.value }))} /><Input placeholder="No Proxy" value={proxyForm.outboundNoProxy} onChange={(event) => setProxyForm((previous) => ({ ...previous, outboundNoProxy: event.target.value }))} /><div className="flex gap-2"><Button type="button" variant="outline" loading={proxyTestLoading} onClick={testOutboundProxy}>Test proxy URL</Button><Button type="submit" variant="primary" loading={proxyLoading}>Apply</Button></div>{proxyStatus.message && <p className={`text-sm ${proxyStatus.type === "error" ? "text-red-500" : "text-green-500"}`}>{proxyStatus.message}</p>}</form>}
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div className="flex items-center gap-3 mb-4"><div className="p-2 rounded-lg bg-orange-500/10 text-orange-500"><span className="material-symbols-outlined text-[20px]">monitoring</span></div><h3 className="text-base sm:text-lg font-semibold">Observability</h3></div>
|
||||
<div className="flex items-center justify-between gap-4"><div><p className="font-medium">Enable Observability</p><p className="text-xs text-text-muted">Record request details for the logs view.</p></div><Toggle checked={settings.enableObservability === true} onChange={() => updateAdminSettings({ enableObservability: !settings.enableObservability })} /></div>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Password */}
|
||||
<Card>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
|
||||
@@ -1,4 +1,30 @@
|
||||
import { NextResponse } from "next/server";
|
||||
export async function POST() {
|
||||
return NextResponse.json({ error: "OIDC settings are not available" }, { status: 403 });
|
||||
import { getSettings } from "@/lib/localDb";
|
||||
import { fetchOidcDiscovery, getPublicOrigin, probeOidcClientSecret } from "@/lib/auth/oidc";
|
||||
import { requireUsageDashboardUser } from "@/lib/auth/currentUser";
|
||||
|
||||
export async function POST(request) {
|
||||
try {
|
||||
const user = await requireUsageDashboardUser();
|
||||
if (user.role !== "admin") throw new Error("Forbidden");
|
||||
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(body.clientSecret || settings.oidcClientSecret || "").trim();
|
||||
if (!issuerUrl || !clientId) return NextResponse.json({ error: "Issuer URL and client ID are 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,
|
||||
});
|
||||
return NextResponse.json({ ok: secretProbe.valid !== false, discoveryOk: true, clientSecretTested: secretProbe.tested, clientSecretValid: secretProbe.valid, issuerUrl, clientId, scopes, message: secretProbe.message });
|
||||
} catch (error) {
|
||||
const status = error.message === "Unauthorized" ? 401 : error.message === "Forbidden" ? 403 : 500;
|
||||
return NextResponse.json({ error: error.message || "OIDC test failed" }, { status });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ export async function GET() {
|
||||
const settings = await getSettings();
|
||||
const cookieStore = await cookies();
|
||||
const session = await getDashboardAuthSession(cookieStore.get("auth_token")?.value);
|
||||
const requireLogin = settings.requireLogin !== false;
|
||||
const authMode = settings.authMode || "password";
|
||||
const oidcName = String(session?.oidcName || "").trim();
|
||||
const oidcEmail = String(session?.oidcEmail || "").trim();
|
||||
@@ -20,7 +19,6 @@ export async function GET() {
|
||||
const loginMethod = session?.oidc ? "OIDC" : "Password";
|
||||
|
||||
return NextResponse.json({
|
||||
requireLogin,
|
||||
authMode,
|
||||
oidcConfigured: isOidcConfigured(settings),
|
||||
oidcLoginLabel: (settings.oidcLoginLabel || "Sign in with OIDC").trim() || "Sign in with OIDC",
|
||||
@@ -36,7 +34,6 @@ export async function GET() {
|
||||
});
|
||||
} catch {
|
||||
return NextResponse.json({
|
||||
requireLogin: true,
|
||||
authMode: "password",
|
||||
oidcConfigured: false,
|
||||
oidcLoginLabel: "Sign in with OIDC",
|
||||
|
||||
@@ -1,6 +1,31 @@
|
||||
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";
|
||||
|
||||
export async function PATCH() {
|
||||
return NextResponse.json({ error: "Routing strategy settings are not available" }, { status: 403 });
|
||||
const STRATEGIES = new Set(["fallback", "round-robin", "fusion"]);
|
||||
|
||||
export async function PATCH(request, { params }) {
|
||||
try {
|
||||
const user = await requireUsageDashboardUser();
|
||||
if (user.role !== "admin") throw new Error("Forbidden");
|
||||
const { id } = await params;
|
||||
const combo = await getComboById(id);
|
||||
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 });
|
||||
if (strategy.fallbackStrategy !== undefined && !STRATEGIES.has(strategy.fallbackStrategy)) return NextResponse.json({ error: "Invalid combo strategy" }, { status: 400 });
|
||||
if (strategy.judgeModel !== undefined && (typeof strategy.judgeModel !== "string" || strategy.judgeModel.length > 256)) return NextResponse.json({ error: "Invalid combo strategy" }, { status: 400 });
|
||||
const normalizedStrategy = {};
|
||||
if (strategy.fallbackStrategy !== undefined) normalizedStrategy.fallbackStrategy = strategy.fallbackStrategy;
|
||||
if (strategy.judgeModel !== undefined) normalizedStrategy.judgeModel = strategy.judgeModel.trim();
|
||||
const settings = await updateComboStrategy(combo.id, normalizedStrategy);
|
||||
resetComboRotation(combo.id);
|
||||
return NextResponse.json({ strategy: settings.comboStrategies[combo.id] || {} });
|
||||
} catch (error) {
|
||||
const status = error.message === "Unauthorized" ? 401 : error.message === "Forbidden" ? 403 : 500;
|
||||
return NextResponse.json({ error: error.message || "Failed to update combo strategy" }, { status });
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,17 @@
|
||||
import { NextResponse } from "next/server";
|
||||
export async function POST() {
|
||||
return NextResponse.json({ error: "Network settings are not available" }, { status: 403 });
|
||||
import { testProxyUrl } from "@/lib/network/proxyTest";
|
||||
import { requireUsageDashboardUser } from "@/lib/auth/currentUser";
|
||||
|
||||
export async function POST(request) {
|
||||
try {
|
||||
const user = await requireUsageDashboardUser();
|
||||
if (user.role !== "admin") throw new Error("Forbidden");
|
||||
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);
|
||||
return NextResponse.json({ ok: false, error: result?.error || "Proxy test failed" }, { status: result?.status || 500 });
|
||||
} catch (error) {
|
||||
const status = error.message === "Unauthorized" ? 401 : error.message === "Forbidden" ? 403 : 500;
|
||||
return NextResponse.json({ ok: false, error: error.message || "Proxy test failed" }, { status });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
export async function GET() {
|
||||
return NextResponse.json({ error: "Login settings are not available" }, { status: 403 });
|
||||
}
|
||||
@@ -16,17 +16,13 @@ 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",
|
||||
const USER_RESTRICTED_SETTING_KEYS = [
|
||||
"authMode",
|
||||
"oidcIssuerUrl",
|
||||
"oidcClientId",
|
||||
"oidcClientSecret",
|
||||
"oidcScopes",
|
||||
"oidcLoginLabel",
|
||||
"oidcConfigured",
|
||||
"fallbackStrategy",
|
||||
"stickyRoundRobinLimit",
|
||||
"comboStrategy",
|
||||
@@ -60,9 +56,9 @@ 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") {
|
||||
for (const key of USER_RESTRICTED_SETTING_KEYS) delete safeSettings[key];
|
||||
const ownedComboIds = new Set((await getCombos(user.id)).map((combo) => combo.id));
|
||||
safeSettings.comboStrategies = Object.fromEntries(
|
||||
Object.entries(safeSettings.comboStrategies || {}).filter(([comboId]) => ownedComboIds.has(comboId))
|
||||
@@ -88,8 +84,16 @@ 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 (USER_RESTRICTED_SETTING_KEYS.some((key) => Object.prototype.hasOwnProperty.call(body, key))) {
|
||||
let user;
|
||||
try {
|
||||
user = await requireUsageDashboardUser();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
if (user.role !== "admin") {
|
||||
return NextResponse.json({ error: "Administrator access required" }, { status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -168,7 +172,11 @@ export async function PATCH(request) {
|
||||
}
|
||||
|
||||
const { password, oidcClientSecret, ...safeSettings } = settings;
|
||||
for (const key of RESTRICTED_SETTING_KEYS) delete safeSettings[key];
|
||||
const user = await requireUsageDashboardUser();
|
||||
if (user.role !== "admin") {
|
||||
for (const key of USER_RESTRICTED_SETTING_KEYS) delete safeSettings[key];
|
||||
}
|
||||
safeSettings.oidcConfigured = !!(safeSettings.oidcIssuerUrl && safeSettings.oidcClientId && oidcClientSecret);
|
||||
return NextResponse.json(safeSettings, { headers: SETTINGS_RESPONSE_HEADERS });
|
||||
} catch (error) {
|
||||
console.log("Error updating settings:", error);
|
||||
|
||||
@@ -38,10 +38,6 @@ export default function LoginPage() {
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (data.requireLogin === false) {
|
||||
window.location.assign("/dashboard");
|
||||
return;
|
||||
}
|
||||
setHasPassword(!!data.hasPassword);
|
||||
setAuthMode(data.authMode || "password");
|
||||
setOidcConfigured(data.oidcConfigured === true);
|
||||
|
||||
+5
-13
@@ -33,7 +33,7 @@ const PUBLIC_API_PATHS = [
|
||||
// Public top-level prefixes (LLM API endpoints with their own API key auth).
|
||||
const PUBLIC_PREFIXES = ["/v1", "/v1beta", "/api/v1", "/api/v1beta", "/codex"];
|
||||
|
||||
// Always require JWT token regardless of requireLogin setting
|
||||
// Always require a JWT token.
|
||||
const ALWAYS_PROTECTED = [
|
||||
"/api/shutdown",
|
||||
"/api/settings/database",
|
||||
@@ -69,7 +69,7 @@ const ADMIN_ONLY_DASHBOARD_PATHS = [
|
||||
"/dashboard/console-log",
|
||||
];
|
||||
|
||||
// Require auth, but allow through if requireLogin is disabled
|
||||
// Require authenticated access.
|
||||
const PROTECTED_API_PATHS = [
|
||||
"/api/settings",
|
||||
"/api/keys",
|
||||
@@ -164,7 +164,7 @@ async function canAccessPublicLlmApi(request) {
|
||||
|
||||
async function canAccessLocalOnlyRoute(request) {
|
||||
if (await hasValidCliToken(request)) return true;
|
||||
// Browser on host: loopback Host + Origin (blocks tunnel/CSRF) + auth (JWT or requireLogin=false)
|
||||
// Browser on host: loopback Host + Origin (blocks tunnel/CSRF) + JWT auth.
|
||||
if (isLocalRequest(request) && await isAuthenticated(request)) return true;
|
||||
return false;
|
||||
}
|
||||
@@ -202,10 +202,7 @@ async function loadSettings() {
|
||||
}
|
||||
|
||||
async function isAuthenticated(request) {
|
||||
if (await hasValidToken(request)) return true;
|
||||
const settings = await loadSettings();
|
||||
if (settings && settings.requireLogin === false) return true;
|
||||
return false;
|
||||
return hasValidToken(request);
|
||||
}
|
||||
|
||||
function isPublicApi(pathname) {
|
||||
@@ -277,13 +274,11 @@ export async function proxy(request) {
|
||||
return NextResponse.redirect(new URL("/dashboard", request.url));
|
||||
}
|
||||
|
||||
let requireLogin = true;
|
||||
let tunnelDashboardAccess = true;
|
||||
|
||||
try {
|
||||
const settings = await loadSettings();
|
||||
if (settings) {
|
||||
requireLogin = settings.requireLogin !== false;
|
||||
tunnelDashboardAccess = settings.tunnelDashboardAccess === true;
|
||||
|
||||
// Block tunnel/tailscale access if disabled (redirect to login)
|
||||
@@ -297,12 +292,9 @@ export async function proxy(request) {
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// On error, keep defaults (require login, block tunnel)
|
||||
// On error, keep the secure default and block tunnel dashboard access.
|
||||
}
|
||||
|
||||
// If login not required, allow through
|
||||
if (!requireLogin) return NextResponse.next();
|
||||
|
||||
// Verify JWT token
|
||||
const token = request.cookies.get("auth_token")?.value;
|
||||
if (token) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { getDashboardAuthSession } from "./dashboardSession.js";
|
||||
import { getSettings, getUserById, verifyUserPassword } from "@/lib/db";
|
||||
import { getUserById, verifyUserPassword } from "@/lib/db";
|
||||
|
||||
export async function getCurrentDashboardUser() {
|
||||
const cookieStore = await cookies();
|
||||
@@ -23,16 +23,12 @@ export async function requireCurrentDashboardUser() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the user for dashboard data that is also available in the explicit
|
||||
* single-user (`requireLogin=false`) deployment mode. That mode has no account
|
||||
* boundary, so it intentionally uses the system-wide administrator scope.
|
||||
* Resolve the currently authenticated dashboard user.
|
||||
*/
|
||||
export async function requireUsageDashboardUser() {
|
||||
const user = await getCurrentDashboardUser();
|
||||
if (user) return user;
|
||||
|
||||
const settings = await getSettings();
|
||||
if (settings?.requireLogin === false) return { id: null, username: "local", role: "admin" };
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
|
||||
@@ -6,21 +6,16 @@ const DEFAULT_MAX_RECORDS = 200;
|
||||
const DEFAULT_BATCH_SIZE = 20;
|
||||
const DEFAULT_FLUSH_INTERVAL_MS = 5000;
|
||||
const DEFAULT_MAX_JSON_SIZE = 5 * 1024;
|
||||
const CONFIG_CACHE_TTL_MS = 5000;
|
||||
|
||||
let cachedConfig = null;
|
||||
let cachedConfigTs = 0;
|
||||
|
||||
async function getObservabilityConfig() {
|
||||
if (cachedConfig && (Date.now() - cachedConfigTs) < CONFIG_CACHE_TTL_MS) return cachedConfig;
|
||||
try {
|
||||
const { getSettings } = await import("./settingsRepo.js");
|
||||
const settings = await getSettings();
|
||||
const envEnabled = process.env.OBSERVABILITY_ENABLED !== "false";
|
||||
const enabled = typeof settings.enableObservability2 === "boolean"
|
||||
? settings.enableObservability2
|
||||
const enabled = typeof settings.enableObservability === "boolean"
|
||||
? settings.enableObservability
|
||||
: envEnabled;
|
||||
cachedConfig = {
|
||||
return {
|
||||
enabled,
|
||||
maxRecords: settings.observabilityMaxRecords || parseInt(process.env.OBSERVABILITY_MAX_RECORDS || String(DEFAULT_MAX_RECORDS), 10),
|
||||
batchSize: settings.observabilityBatchSize || parseInt(process.env.OBSERVABILITY_BATCH_SIZE || String(DEFAULT_BATCH_SIZE), 10),
|
||||
@@ -28,7 +23,7 @@ async function getObservabilityConfig() {
|
||||
maxJsonSize: (settings.observabilityMaxJsonSize || parseInt(process.env.OBSERVABILITY_MAX_JSON_SIZE || "5", 10)) * 1024,
|
||||
};
|
||||
} catch {
|
||||
cachedConfig = {
|
||||
return {
|
||||
enabled: false,
|
||||
maxRecords: DEFAULT_MAX_RECORDS,
|
||||
batchSize: DEFAULT_BATCH_SIZE,
|
||||
@@ -36,8 +31,6 @@ async function getObservabilityConfig() {
|
||||
maxJsonSize: DEFAULT_MAX_JSON_SIZE,
|
||||
};
|
||||
}
|
||||
cachedConfigTs = Date.now();
|
||||
return cachedConfig;
|
||||
}
|
||||
|
||||
let writeBuffer = [];
|
||||
|
||||
@@ -16,7 +16,6 @@ const DEFAULT_SETTINGS = {
|
||||
comboStrategy: "fallback",
|
||||
comboStickyRoundRobinLimit: 1,
|
||||
comboStrategies: {},
|
||||
requireLogin: true,
|
||||
tunnelDashboardAccess: true,
|
||||
authMode: "password",
|
||||
oidcIssuerUrl: "",
|
||||
|
||||
@@ -13,6 +13,13 @@ export async function getUsageAccessScope(user) {
|
||||
return { isAdmin: true, userId: null, connectionIds: [], apiKeys: [] };
|
||||
}
|
||||
|
||||
// Repository calls without a user are trusted server-side operations (for
|
||||
// example logging, cleanup, and DB-level callers). HTTP routes must resolve
|
||||
// and pass the dashboard user explicitly before querying these repositories.
|
||||
if (!user) {
|
||||
return { isAdmin: true, userId: null, connectionIds: [], apiKeys: [] };
|
||||
}
|
||||
|
||||
if (!user?.id) {
|
||||
return { isAdmin: false, userId: null, connectionIds: [], apiKeys: [] };
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ function request(pathname, headers = {}, authToken) {
|
||||
describe("dashboard guard public LLM API access", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.getSettings.mockResolvedValue({ requireLogin: true });
|
||||
mocks.getSettings.mockResolvedValue({});
|
||||
mocks.getUserById.mockResolvedValue(null);
|
||||
mocks.validateApiKey.mockResolvedValue(false);
|
||||
mocks.getConsistentMachineId.mockResolvedValue("cli-token");
|
||||
@@ -197,7 +197,7 @@ describe("dashboard guard public LLM API access", () => {
|
||||
describe("dashboard guard local-only access", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.getSettings.mockResolvedValue({ requireLogin: true });
|
||||
mocks.getSettings.mockResolvedValue({});
|
||||
mocks.getUserById.mockResolvedValue(null);
|
||||
mocks.validateApiKey.mockResolvedValue(false);
|
||||
mocks.getConsistentMachineId.mockResolvedValue("cli-token");
|
||||
@@ -214,7 +214,7 @@ describe("dashboard guard local-only access", () => {
|
||||
expect(response.body.error).toBe("Local only: CLI token required");
|
||||
});
|
||||
|
||||
it("rejects local-only route on loopback when requireLogin=true and no JWT", async () => {
|
||||
it("rejects local-only route on loopback without a JWT", async () => {
|
||||
const response = await proxy(request("/api/mcp/filesystem/sse", {
|
||||
host: "localhost:20128",
|
||||
origin: "http://localhost:20128",
|
||||
@@ -224,9 +224,7 @@ describe("dashboard guard local-only access", () => {
|
||||
expect(response.body.error).toBe("Local only: CLI token required");
|
||||
});
|
||||
|
||||
it("requires an administrator for CLI Tools even when dashboard login is disabled", async () => {
|
||||
mocks.getSettings.mockResolvedValue({ requireLogin: false });
|
||||
|
||||
it("requires an administrator for CLI Tools", async () => {
|
||||
const response = await proxy(request("/api/cli-tools/antigravity-mitm", {
|
||||
host: "localhost:20128",
|
||||
origin: "http://localhost:20128",
|
||||
@@ -236,9 +234,7 @@ describe("dashboard guard local-only access", () => {
|
||||
expect(response.body.error).toBe("Administrator access required");
|
||||
});
|
||||
|
||||
it("rejects local-only route from tunnel host even when requireLogin=false", async () => {
|
||||
mocks.getSettings.mockResolvedValue({ requireLogin: false });
|
||||
|
||||
it("rejects local-only route from a tunnel host", async () => {
|
||||
const response = await proxy(request("/api/cli-tools/antigravity-mitm", {
|
||||
host: "router.example.com",
|
||||
}));
|
||||
@@ -247,8 +243,6 @@ describe("dashboard guard local-only access", () => {
|
||||
});
|
||||
|
||||
it("rejects local-only route when Origin is non-loopback (CSRF block)", async () => {
|
||||
mocks.getSettings.mockResolvedValue({ requireLogin: false });
|
||||
|
||||
const response = await proxy(request("/api/cli-tools/antigravity-mitm", {
|
||||
host: "localhost:20128",
|
||||
origin: "http://evil.example.com",
|
||||
@@ -270,7 +264,7 @@ describe("dashboard guard local-only access", () => {
|
||||
describe("dashboard guard CLI Tools administration access", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.getSettings.mockResolvedValue({ requireLogin: true });
|
||||
mocks.getSettings.mockResolvedValue({});
|
||||
mocks.getUserById.mockResolvedValue({ id: "user-1", isActive: true, role: "user" });
|
||||
mocks.validateApiKey.mockResolvedValue(false);
|
||||
mocks.getConsistentMachineId.mockResolvedValue("cli-token");
|
||||
@@ -315,7 +309,7 @@ describe("dashboard guard CLI Tools administration access", () => {
|
||||
describe("dashboard guard token saver administration access", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.getSettings.mockResolvedValue({ requireLogin: true });
|
||||
mocks.getSettings.mockResolvedValue({});
|
||||
mocks.getUserById.mockResolvedValue({ id: "user-1", isActive: true, role: "user" });
|
||||
mocks.getConsistentMachineId.mockResolvedValue("cli-token");
|
||||
mocks.getDashboardAuthSession.mockResolvedValue({ userId: "user-1" });
|
||||
|
||||
@@ -28,12 +28,10 @@ describe("DB SQLite layer — public API parity", () => {
|
||||
const s = await sqliteDb.getSettings();
|
||||
expect(s).toBeDefined();
|
||||
expect(s.cloudEnabled).toBe(false);
|
||||
expect(s.requireLogin).toBe(true);
|
||||
|
||||
const updated = await sqliteDb.updateSettings({ cloudEnabled: true, customField: "x" });
|
||||
expect(updated.cloudEnabled).toBe(true);
|
||||
expect(updated.customField).toBe("x");
|
||||
expect(updated.requireLogin).toBe(true); // default preserved
|
||||
|
||||
const re = await sqliteDb.getSettings();
|
||||
expect(re.cloudEnabled).toBe(true);
|
||||
|
||||
@@ -6,6 +6,10 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/auth/currentUser", () => ({
|
||||
requireUsageDashboardUser: vi.fn(async () => ({ id: "test-admin", role: "admin" })),
|
||||
}));
|
||||
|
||||
const originalDataDir = process.env.DATA_DIR;
|
||||
let tempDir;
|
||||
let db;
|
||||
@@ -22,7 +26,7 @@ beforeAll(async () => {
|
||||
vi.resetModules();
|
||||
db = await import("@/lib/db/index.js");
|
||||
await db.initDb();
|
||||
await db.updateSettings({ enableObservability2: true, observabilityBatchSize: 1 });
|
||||
await db.updateSettings({ enableObservability: true, observabilityBatchSize: 1 });
|
||||
|
||||
const { getAdapter } = await import("@/lib/db/driver.js");
|
||||
adapter = await getAdapter();
|
||||
|
||||
Reference in New Issue
Block a user