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