diff --git a/src/app/(dashboard)/dashboard/profile/page.js b/src/app/(dashboard)/dashboard/profile/page.js index 4df8cbef..834db0f5 100644 --- a/src/app/(dashboard)/dashboard/profile/page.js +++ b/src/app/(dashboard)/dashboard/profile/page.js @@ -9,6 +9,7 @@ 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"; +import useUserStore from "@/store/userStore"; function getLocaleFromCookie() { if (typeof document === "undefined") return "en"; @@ -21,6 +22,8 @@ function getLocaleFromCookie() { export default function ProfilePage() { const { theme, setTheme, isDark } = useTheme(); + const user = useUserStore((state) => state.user); + const fetchCurrentUser = useUserStore((state) => state.fetchCurrentUser); const [locale, setLocale] = useState("en"); const [langOpen, setLangOpen] = useState(false); const [shutdownOpen, setShutdownOpen] = useState(false); @@ -58,6 +61,10 @@ export default function ProfilePage() { const [proxyLoading, setProxyLoading] = useState(false); const [proxyTestLoading, setProxyTestLoading] = useState(false); + useEffect(() => { + if (!user) fetchCurrentUser(); + }, [fetchCurrentUser, user]); + useEffect(() => { setLocale(getLocaleFromCookie()); }, [langOpen]); @@ -532,6 +539,13 @@ export default function ProfilePage() { throw new Error(data.error || "Failed to import database"); } + // A backup may replace the current account and its permissions. The API + // clears the session cookie; redirect immediately to prevent stale data. + if (data.requiresLogin) { + window.location.assign("/login"); + return; + } + await reloadSettings(); setDbStatus({ type: "success", message: "Database imported successfully" }); } catch (err) { @@ -610,46 +624,51 @@ export default function ProfilePage() { ))} -
-
-
-

Database Location

-

~/.9router/db/data.sqlite

+ {user?.role === "admin" ? ( +
+
+
+

Database Location

+

~/.9router/db/data.sqlite

+
-
-
- - - -
- {dbStatus.message && ( -

- {dbStatus.message} +

+ + + +
+

+ Backups include connected-model availability settings from the Models page.

- )} -
+ {dbStatus.message && ( +

+ {dbStatus.message} +

+ )} +
+ ) : null} {/* Language */} diff --git a/src/app/api/settings/database/route.js b/src/app/api/settings/database/route.js index 893bf68f..01c9e157 100644 --- a/src/app/api/settings/database/route.js +++ b/src/app/api/settings/database/route.js @@ -1,7 +1,8 @@ import { NextResponse } from "next/server"; import { exportDb, getSettings, importDb } from "@/lib/localDb"; import { applyOutboundProxyEnv } from "@/lib/network/outboundProxy"; -import { verifyCurrentDashboardUserPassword } from "@/lib/auth/currentUser"; +import { requireAdminUser, verifyCurrentDashboardUserPassword } from "@/lib/auth/currentUser"; +import { clearDashboardAuthCookie } from "@/lib/auth/dashboardSession"; const CLI_TOKEN_HEADER = "x-9r-cli-token"; const PASSWORD_HEADER = "x-9r-password"; @@ -11,14 +12,29 @@ function isCliRequest(request) { return Boolean(request.headers.get(CLI_TOKEN_HEADER)); } +function getAuthorizationErrorResponse(error) { + if (error.message === "Unauthorized") { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + if (error.message === "Forbidden") { + return NextResponse.json({ error: "Administrator access required" }, { status: 403 }); + } + return null; +} + export async function GET(request) { try { - if (!isCliRequest(request) && !(await verifyCurrentDashboardUserPassword(request.headers.get(PASSWORD_HEADER)))) { - return NextResponse.json({ error: "Invalid password" }, { status: 401 }); + if (!isCliRequest(request)) { + await requireAdminUser(); + if (!(await verifyCurrentDashboardUserPassword(request.headers.get(PASSWORD_HEADER)))) { + return NextResponse.json({ error: "Invalid password" }, { status: 401 }); + } } const payload = await exportDb(); return NextResponse.json(payload); } catch (error) { + const authorizationError = getAuthorizationErrorResponse(error); + if (authorizationError) return authorizationError; console.log("Error exporting database:", error); return NextResponse.json({ error: "Failed to export database" }, { status: 500 }); } @@ -27,8 +43,11 @@ export async function GET(request) { export async function POST(request) { try { const { password, ...payload } = await request.json(); - if (!isCliRequest(request) && !(await verifyCurrentDashboardUserPassword(password))) { - return NextResponse.json({ error: "Invalid password" }, { status: 401 }); + if (!isCliRequest(request)) { + await requireAdminUser(); + if (!(await verifyCurrentDashboardUserPassword(password))) { + return NextResponse.json({ error: "Invalid password" }, { status: 401 }); + } } await importDb(payload); @@ -40,8 +59,21 @@ export async function POST(request) { console.warn("[Settings][DatabaseImport] Failed to re-apply outbound proxy env:", err); } - return NextResponse.json({ success: true }); + const response = NextResponse.json({ success: true, requiresLogin: !isCliRequest(request) }); + + // The imported database can replace the current account and permissions. + // Remove the browser session so all dashboard data is loaded under a new login. + if (!isCliRequest(request)) { + clearDashboardAuthCookie(response.cookies); + response.cookies.delete("oidc_state"); + response.cookies.delete("oidc_nonce"); + response.cookies.delete("oidc_code_verifier"); + } + + return response; } catch (error) { + const authorizationError = getAuthorizationErrorResponse(error); + if (authorizationError) return authorizationError; console.log("Error importing database:", error); return NextResponse.json( { error: error?.message || "Failed to import database" }, diff --git a/src/app/api/usage/topology-providers/route.js b/src/app/api/usage/topology-providers/route.js new file mode 100644 index 00000000..bebad70e --- /dev/null +++ b/src/app/api/usage/topology-providers/route.js @@ -0,0 +1,23 @@ +import { NextResponse } from "next/server"; +import { requireUsageDashboardUser } from "@/lib/auth/currentUser"; +import { getUsageTopologyProviders } from "@/lib/providers/usageTopologyProviders"; + +export const dynamic = "force-dynamic"; + +/** + * GET /api/usage/topology-providers + * Returns provider types that the request router can currently select. + */ +export async function GET() { + try { + await requireUsageDashboardUser(); + const providers = await getUsageTopologyProviders(); + return NextResponse.json({ providers }); + } catch (error) { + if (error?.message === "Unauthorized") { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + console.error("[API] Failed to get usage topology providers:", error); + return NextResponse.json({ error: "Failed to fetch topology providers" }, { status: 500 }); + } +} \ No newline at end of file diff --git a/src/lib/db/index.js b/src/lib/db/index.js index a843de32..fc820715 100644 --- a/src/lib/db/index.js +++ b/src/lib/db/index.js @@ -91,12 +91,14 @@ export async function exportDb() { customModels: [], mitmAlias: {}, pricing: {}, + disabledModels: {}, }; for (const r of db.all(`SELECT key, value FROM kv WHERE scope = 'modelAliases'`)) out.modelAliases[r.key] = parseJson(r.value); for (const r of db.all(`SELECT key, value FROM kv WHERE scope = 'customModels'`)) out.customModels.push(parseJson(r.value)); for (const r of db.all(`SELECT key, value FROM kv WHERE scope = 'mitmAlias'`)) out.mitmAlias[r.key] = parseJson(r.value); for (const r of db.all(`SELECT key, value FROM kv WHERE scope = 'pricing'`)) out.pricing[r.key] = parseJson(r.value); + for (const r of db.all(`SELECT key, value FROM kv WHERE scope = 'disabledModels'`)) out.disabledModels[r.key] = parseJson(r.value, []); return out; } @@ -127,7 +129,7 @@ export async function importDb(payload) { db.run(`DELETE FROM proxyPools`); db.run(`DELETE FROM apiKeys`); db.run(`DELETE FROM combos`); - db.run(`DELETE FROM kv WHERE scope IN ('modelAliases', 'customModels', 'mitmAlias', 'pricing')`); + db.run(`DELETE FROM kv WHERE scope IN ('modelAliases', 'customModels', 'mitmAlias', 'pricing', 'disabledModels')`); // Settings if (payload.settings) { @@ -192,6 +194,14 @@ export async function importDb(payload) { for (const [provider, models] of Object.entries(payload.pricing || {})) { db.run(`INSERT OR REPLACE INTO kv(scope, key, value) VALUES('pricing', ?, ?)`, [provider, stringifyJson(models || {})]); } + for (const [providerAlias, modelIds] of Object.entries(payload.disabledModels || {})) { + const validModelIds = Array.isArray(modelIds) + ? modelIds.filter((modelId) => typeof modelId === "string" && modelId) + : []; + if (providerAlias && validModelIds.length > 0) { + db.run(`INSERT OR REPLACE INTO kv(scope, key, value) VALUES('disabledModels', ?, ?)`, [providerAlias, stringifyJson([...new Set(validModelIds)])]); + } + } }); return await exportDb(); diff --git a/src/lib/providers/usageTopologyProviders.js b/src/lib/providers/usageTopologyProviders.js new file mode 100644 index 00000000..b5b083bf --- /dev/null +++ b/src/lib/providers/usageTopologyProviders.js @@ -0,0 +1,46 @@ +import { getProviderConnections, getProviderNodes } from "@/lib/db"; +import { AI_PROVIDERS, FREE_PROVIDERS } from "@/shared/constants/providers"; + +function isLLMProvider(providerId) { + const provider = AI_PROVIDERS[providerId]; + return !provider?.serviceKinds || provider.serviceKinds.includes("llm"); +} + +/** + * Return the provider types available to the request router. + * + * Provider credentials are selected globally after a valid dashboard API key + * is authenticated, so a regular user can route through any active + * connection. This intentionally differs from provider-management APIs, + * which only return connections owned by the signed-in user. + */ +export async function getUsageTopologyProviders() { + const [connections, providerNodes] = await Promise.all([ + getProviderConnections({ isActive: true }), + getProviderNodes(), + ]); + const nodeNameMap = Object.fromEntries( + providerNodes + .filter((node) => node.id && node.name) + .map((node) => [node.id, node.name]), + ); + const seen = new Set(); + const providers = []; + + for (const connection of connections) { + if (!connection.provider || !isLLMProvider(connection.provider) || seen.has(connection.provider)) continue; + seen.add(connection.provider); + providers.push({ + provider: connection.provider, + nodeName: nodeNameMap[connection.provider] || null, + }); + } + + for (const provider of Object.values(FREE_PROVIDERS)) { + if (!provider.noAuth || !provider.id || !isLLMProvider(provider.id) || seen.has(provider.id)) continue; + seen.add(provider.id); + providers.push({ provider: provider.id, name: provider.name }); + } + + return providers; +} \ No newline at end of file diff --git a/src/shared/components/UsageStats.js b/src/shared/components/UsageStats.js index cfc0e07d..3a849460 100644 --- a/src/shared/components/UsageStats.js +++ b/src/shared/components/UsageStats.js @@ -2,14 +2,6 @@ import { useState, useEffect, useMemo, useCallback, useRef } from "react"; import { useSearchParams, useRouter } from "next/navigation"; -import { FREE_PROVIDERS, AI_PROVIDERS } from "@/shared/constants/providers"; - -// Keep providers without serviceKinds (default LLM) or with "llm" in serviceKinds -function isLLMProvider(id) { - const p = AI_PROVIDERS[id]; - if (!p?.serviceKinds) return true; - return p.serviceKinds.includes("llm"); -} import Badge from "./Badge"; import Card from "./Card"; import OverviewCards from "@/app/(dashboard)/dashboard/usage/components/OverviewCards"; @@ -225,35 +217,12 @@ export default function UsageStats({ period: periodProp, setPeriod: setPeriodPro }, [stats?.availableTableViews]); const activeTableView = tableOptions.some((option) => option.value === tableView) ? tableView : "model"; - // Fetch connected providers once, deduplicate by provider type - // Always include noAuth free providers (e.g. opencode) regardless of connections + // The topology must use router availability rather than the provider-management + // list. The latter is intentionally limited to a regular user's owned connections. useEffect(() => { - Promise.all([ - fetch("/api/providers").then((r) => r.ok ? r.json() : null), - fetch("/api/provider-nodes").then((r) => r.ok ? r.json() : null), - ]) - .then(([d, nodesData]) => { - // Build node name lookup for custom providers - const nodeNameMap = {}; - for (const node of (nodesData?.nodes || [])) { - nodeNameMap[node.id] = node.name; - } - const seen = new Set(); - const unique = (d?.connections || []).filter((c) => { - if (c.isActive === false) return false; - if (!isLLMProvider(c.provider)) return false; - if (seen.has(c.provider)) return false; - seen.add(c.provider); - return true; - }).map((c) => ({ - ...c, - nodeName: nodeNameMap[c.provider] || null, - })); - const noAuthProviders = Object.values(FREE_PROVIDERS) - .filter((p) => p.noAuth && !seen.has(p.id) && isLLMProvider(p.id)) - .map((p) => ({ provider: p.id, name: p.name })); - setProviders([...unique, ...noAuthProviders]); - }) + fetch("/api/usage/topology-providers") + .then((response) => response.ok ? response.json() : null) + .then((data) => setProviders(data?.providers || [])) .catch(() => {}); }, []); diff --git a/tests/unit/db-sqlite-vs-lowdb.test.js b/tests/unit/db-sqlite-vs-lowdb.test.js index ba8ddba6..0ca45e16 100644 --- a/tests/unit/db-sqlite-vs-lowdb.test.js +++ b/tests/unit/db-sqlite-vs-lowdb.test.js @@ -352,6 +352,20 @@ describe("DB SQLite layer — public API parity", () => { expect((await sqliteDb.getModelAliases()).marker).toBe("before"); }); + it("exportDb / importDb preserves disabled model settings", async () => { + await sqliteDb.disableModels("backup-provider", ["backup-model"]); + const snapshot = await sqliteDb.exportDb(); + + expect(snapshot.disabledModels).toMatchObject({ + "backup-provider": ["backup-model"], + }); + + await sqliteDb.enableModels("backup-provider", []); + await sqliteDb.importDb(snapshot); + + expect(await sqliteDb.getDisabledByProvider("backup-provider")).toEqual(["backup-model"]); + }); + it("pricing: user pricing merged with constants", async () => { await sqliteDb.updatePricing({ openai: { "gpt-test": { input: 1, output: 2 } } }); const p = await sqliteDb.getPricing(); diff --git a/tests/unit/usage-topology-providers.test.js b/tests/unit/usage-topology-providers.test.js new file mode 100644 index 00000000..c0b05e7d --- /dev/null +++ b/tests/unit/usage-topology-providers.test.js @@ -0,0 +1,58 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; + +const originalDataDir = process.env.DATA_DIR; +let tempDir; +let db; +let getUsageTopologyProviders; + +beforeAll(async () => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "9router-usage-topology-")); + process.env.DATA_DIR = tempDir; + vi.resetModules(); + db = await import("@/lib/db/index.js"); + await db.initDb(); + ({ getUsageTopologyProviders } = await import("@/lib/providers/usageTopologyProviders.js")); +}); + +afterAll(() => { + if (tempDir) fs.rmSync(tempDir, { recursive: true, force: true }); + if (originalDataDir === undefined) delete process.env.DATA_DIR; + else process.env.DATA_DIR = originalDataDir; +}); + +describe("usage topology providers", () => { + it("includes every active router connection, regardless of dashboard owner", async () => { + const firstUser = await db.createUser({ username: "topology-first", password: "password", role: "user" }); + const secondUser = await db.createUser({ username: "topology-second", password: "password", role: "user" }); + + await db.createProviderConnection({ + provider: "topology-provider-first", + authType: "apikey", + apiKey: "first-key", + ownerId: firstUser.id, + }); + await db.createProviderConnection({ + provider: "topology-provider-second", + authType: "apikey", + apiKey: "second-key", + ownerId: secondUser.id, + }); + await db.createProviderConnection({ + provider: "topology-provider-inactive", + authType: "apikey", + apiKey: "inactive-key", + ownerId: secondUser.id, + isActive: false, + }); + + const providers = await getUsageTopologyProviders(); + const providerIds = providers.map((provider) => provider.provider); + + expect(providerIds).toContain("topology-provider-first"); + expect(providerIds).toContain("topology-provider-second"); + expect(providerIds).not.toContain("topology-provider-inactive"); + }); +}); \ No newline at end of file