diff --git a/changelog.d/fixes/12709-guest-import-settings.md b/changelog.d/fixes/12709-guest-import-settings.md new file mode 100644 index 0000000000..0542d75ac3 --- /dev/null +++ b/changelog.d/fixes/12709-guest-import-settings.md @@ -0,0 +1 @@ +- fix(dashboard): surface an authentication-required banner instead of silently blanking database settings for a guest session (#12709) diff --git a/src/app/(dashboard)/dashboard/settings/components/SystemStorageTab.tsx b/src/app/(dashboard)/dashboard/settings/components/SystemStorageTab.tsx index e42bb19f60..c82170e256 100644 --- a/src/app/(dashboard)/dashboard/settings/components/SystemStorageTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/SystemStorageTab.tsx @@ -4,6 +4,11 @@ import { useState, useEffect, useCallback, useRef } from "react"; import { Card, Button, Badge, ConfirmModal } from "@/shared/components"; import { useLocale, useTranslations } from "next-intl"; import DatabaseBackupRetentionCard from "./DatabaseBackupRetentionCard"; +import { + fetchDatabaseSettingsData, + isAuthRequiredResponse, + AuthRequiredBanner, +} from "./systemStorageAuth"; // Whitelist mirrored from src/lib/db/cleanup.ts::RESET_USAGE_HISTORY_PERIODS. const RESET_USAGE_PERIOD_VALUES = [ @@ -29,16 +34,6 @@ async function fetchStorageHealthData() { } } -async function fetchDatabaseSettingsData() { - try { - const res = await fetch("/api/settings/database"); - if (res.ok) return await res.json(); - } catch (err) { - console.error("Failed to load database settings:", err); - } - return null; -} - export default function SystemStorageTab() { const [backups, setBackups] = useState([]); const [backupsLoading, setBackupsLoading] = useState(false); @@ -108,6 +103,7 @@ export default function SystemStorageTab() { // Database settings state (tasks 23-26) const [dbSettings, setDbSettings] = useState(null); const [dbSettingsLoading, setDbSettingsLoading] = useState(true); + const [dbSettingsAuthRequired, setDbSettingsAuthRequired] = useState(false); const [dbSettingsSaving, setDbSettingsSaving] = useState(false); const [dbStatsRefreshing, setDbStatsRefreshing] = useState(false); @@ -137,8 +133,9 @@ export default function SystemStorageTab() { applyStorageHealth(await fetchStorageHealthData()); }; - const applyDatabaseSettings = useCallback((data) => { - if (data) setDbSettings(data); + const applyDatabaseSettings = useCallback((result: { data: any; authRequired: boolean }) => { + if (result.data) setDbSettings(result.data); + setDbSettingsAuthRequired(result.authRequired); setDbSettingsLoading(false); }, []); @@ -589,6 +586,8 @@ export default function SystemStorageTab() { }); await loadStorageHealth(); if (backupsExpanded) await loadBackups(); + } else if (isAuthRequiredResponse(res.status, data)) { + setImportStatus({ type: "error", message: t("jsonImportAuthRequired") }); } else { setImportStatus({ type: "error", message: data.error || t("jsonImportFailed") }); } @@ -1290,6 +1289,7 @@ export default function SystemStorageTab() { + {dbSettingsAuthRequired && !dbSettingsLoading && } {renderDatabaseStatistics()}
diff --git a/src/app/(dashboard)/dashboard/settings/components/systemStorageAuth.tsx b/src/app/(dashboard)/dashboard/settings/components/systemStorageAuth.tsx new file mode 100644 index 0000000000..de58828443 --- /dev/null +++ b/src/app/(dashboard)/dashboard/settings/components/systemStorageAuth.tsx @@ -0,0 +1,57 @@ +"use client"; + +// #12709: database-settings requests are ALWAYS_PROTECTED (routeGuard.ts) — a guest/anonymous +// session correctly gets a 401 AUTH_001 from /api/settings/database and /api/settings/import-json +// (GHSA-mghq-58h3-qcqj, GHSA-v7g9-7f55-5g46). Do NOT loosen that gate; this module only makes the +// client surface the failure instead of silently rendering a blank section. +import Link from "next/link"; + +export interface DatabaseSettingsFetchResult { + data: unknown; + authRequired: boolean; +} + +/** + * True when a response represents the intentional auth-required rejection + * (401, optionally carrying the AUTH_001 error code) rather than some other + * transient failure. + */ +export function isAuthRequiredResponse(status: number, data: unknown): boolean { + if (status !== 401) return false; + const code = (data as { error?: { code?: string } } | null)?.error?.code; + return code === undefined || code === "AUTH_001"; +} + +export async function fetchDatabaseSettingsData(): Promise { + try { + const res = await fetch("/api/settings/database"); + const body = await res.json().catch(() => null); + if (res.ok) return { data: body, authRequired: false }; + return { data: null, authRequired: isAuthRequiredResponse(res.status, body) }; + } catch (err) { + console.error("Failed to load database settings:", err); + return { data: null, authRequired: false }; + } +} + +export function AuthRequiredBanner({ t }: { t: (key: string) => string }) { + return ( +
+

+ {t("databaseSettingsAuthRequiredTitle")} +

+

+ {t("databaseSettingsAuthRequiredBody")} +

+ + {t("databaseSettingsAuthRequiredCta")} + +
+ ); +} diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index ab6a8347bb..a0f19222fa 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -7741,6 +7741,10 @@ "legacyJsonImportSuccess": "Legacy JSON imported successfully!", "jsonImportFailed": "Failed to import JSON", "jsonImportError": "Error during JSON import", + "jsonImportAuthRequired": "Authentication required to import a legacy JSON configuration. Please sign in or complete setup first.", + "databaseSettingsAuthRequiredTitle": "Authentication required", + "databaseSettingsAuthRequiredBody": "Database settings and JSON import are only available to an authenticated admin. Sign in or complete setup to view and edit them.", + "databaseSettingsAuthRequiredCta": "Sign in", "storagePurgeData": "Purge Data", "storagePurgeDataDesc": "Immediately delete all records without applying retention checks. Use with caution.", "storageRetentionCleanup": "Retention Settings", diff --git a/tests/unit/ui/system-storage-tab-guest-401-12709.test.tsx b/tests/unit/ui/system-storage-tab-guest-401-12709.test.tsx new file mode 100644 index 0000000000..edd72421b9 --- /dev/null +++ b/tests/unit/ui/system-storage-tab-guest-401-12709.test.tsx @@ -0,0 +1,84 @@ +// @vitest-environment jsdom +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import SystemStorageTab from "@/app/(dashboard)/dashboard/settings/components/SystemStorageTab"; + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, + useLocale: () => "en", +})); + +const roots: Array<{ root: Root; el: HTMLDivElement }> = []; + +async function render(): Promise { + const el = document.createElement("div"); + document.body.appendChild(el); + const root = createRoot(el); + await act(async () => { + root.render(); + }); + roots.push({ root, el }); + return el; +} + +async function flush() { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + }); +} + +describe("#12709 - SystemStorageTab guest-session 401 on /api/settings/database", () => { + let fetchMock: ReturnType; + + beforeEach(() => { + (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/api/settings/database")) { + return new Response( + JSON.stringify({ error: { code: "AUTH_001", message: "Authentication required" } }), + { status: 401, headers: { "Content-Type": "application/json" } } + ); + } + if (url.includes("/api/storage/health")) { + return new Response( + JSON.stringify({ + driver: "sqlite", + dbPath: "~/.omniroute/storage.sqlite", + sizeBytes: 0, + retentionDays: { app: 7, call: 7 }, + tableMaxRows: { callLogs: 100000, proxyLogs: 100000 }, + backupCount: 0, + backupRetention: { maxFiles: 20, days: 0 }, + lastBackupAt: null, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + return new Response("{}", { status: 200 }); + }); + (globalThis as any).fetch = fetchMock; + }); + + afterEach(() => { + for (const { root, el } of roots.splice(0)) { + act(() => root.unmount()); + el.remove(); + } + vi.restoreAllMocks(); + }); + + it("surfaces an authentication-required message instead of silently hiding Settings", async () => { + const container = await render(); + await flush(); + await flush(); + + const dbCall = fetchMock.mock.calls.find((c) => String(c[0]).includes("/api/settings/database")); + expect(dbCall).toBeTruthy(); + + const text = container.textContent || ""; + const mentionsAuth = /auth|sign in|log in|login|401|unauthorized/i.test(text); + expect(mentionsAuth).toBe(true); + }); +});