mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-09-02 17:40:22 +08:00
feat: add file-based admin settings panel at /admin
Settings saved in the panel are written to data/settings.json and overlaid onto process.env, taking precedence over environment variables and applying immediately without restart. Enable by setting ADMIN_PASSWORD; on serverless platforms without persistent disk the panel degrades to read-only.
This commit is contained in:
746
app/[lang]/admin/page.tsx
Normal file
746
app/[lang]/admin/page.tsx
Normal file
@@ -0,0 +1,746 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
ChevronRight,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Loader2,
|
||||
LockKeyhole,
|
||||
RotateCcw,
|
||||
ShieldCheck,
|
||||
} from "lucide-react"
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import {
|
||||
SETTING_GROUPS,
|
||||
SETTINGS_REGISTRY,
|
||||
type SettingDef,
|
||||
} from "@/lib/admin/settings-registry"
|
||||
import { getApiEndpoint } from "@/lib/base-path"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const SESSION_PASSWORD_KEY = "next-ai-draw-io-admin-password"
|
||||
|
||||
type SecretValue = { isSet: true; hint: string }
|
||||
|
||||
interface SettingState {
|
||||
key: string
|
||||
source: "file" | "env" | "default"
|
||||
value: string | SecretValue | null
|
||||
}
|
||||
|
||||
type SettingsMap = Record<string, SettingState>
|
||||
|
||||
function isSecretValue(v: unknown): v is SecretValue {
|
||||
return typeof v === "object" && v !== null && "isSet" in v
|
||||
}
|
||||
|
||||
function SourceChip({ source }: { source: "file" | "env" | "default" }) {
|
||||
if (source === "default") return null
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"rounded px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide",
|
||||
source === "file"
|
||||
? "bg-primary/10 text-primary"
|
||||
: "bg-muted text-muted-foreground",
|
||||
)}
|
||||
title={
|
||||
source === "file"
|
||||
? "Set in the admin settings file"
|
||||
: "Set by an environment variable"
|
||||
}
|
||||
>
|
||||
{source === "file" ? "Saved" : "Env"}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function RestartBadge() {
|
||||
return (
|
||||
<span className="rounded bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-amber-600 dark:text-amber-400">
|
||||
Restart Required
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function SettingField({
|
||||
def,
|
||||
state,
|
||||
pendingValue,
|
||||
error,
|
||||
disabled,
|
||||
onChange,
|
||||
}: {
|
||||
def: SettingDef
|
||||
state: SettingState | undefined
|
||||
pendingValue: string | null | undefined
|
||||
error?: string
|
||||
disabled: boolean
|
||||
onChange: (value: string | null) => void
|
||||
}) {
|
||||
const [showSecret, setShowSecret] = useState(false)
|
||||
const isDirty = pendingValue !== undefined
|
||||
const source = state?.source ?? "default"
|
||||
|
||||
const savedText =
|
||||
state && !isSecretValue(state.value) ? (state.value ?? "") : ""
|
||||
const currentValue = isDirty ? (pendingValue ?? "") : savedText
|
||||
|
||||
const secretIsSet = state ? isSecretValue(state.value) : false
|
||||
const secretHint =
|
||||
state && isSecretValue(state.value) ? state.value.hint : ""
|
||||
|
||||
const inputId = `setting-${def.key}`
|
||||
const errorId = `${inputId}-error`
|
||||
|
||||
let control: React.ReactNode
|
||||
switch (def.type) {
|
||||
case "boolean":
|
||||
control = (
|
||||
<Switch
|
||||
id={inputId}
|
||||
checked={currentValue === "true"}
|
||||
disabled={disabled}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange(checked ? "true" : "false")
|
||||
}
|
||||
/>
|
||||
)
|
||||
break
|
||||
case "enum":
|
||||
control = (
|
||||
<Select
|
||||
value={currentValue || undefined}
|
||||
disabled={disabled}
|
||||
onValueChange={onChange}
|
||||
>
|
||||
<SelectTrigger id={inputId} className="w-full max-w-xs">
|
||||
<SelectValue placeholder="Not set" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{def.options?.map((opt) => (
|
||||
<SelectItem key={opt} value={opt}>
|
||||
{opt}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
break
|
||||
case "json":
|
||||
control = (
|
||||
<Textarea
|
||||
id={inputId}
|
||||
value={currentValue}
|
||||
disabled={disabled}
|
||||
spellCheck={false}
|
||||
rows={6}
|
||||
className="font-mono text-xs"
|
||||
placeholder={def.placeholder ?? "{ … }"}
|
||||
aria-invalid={!!error}
|
||||
aria-describedby={error ? errorId : undefined}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
)
|
||||
break
|
||||
case "secret":
|
||||
control = (
|
||||
<div className="flex w-full max-w-md items-center gap-1">
|
||||
<Input
|
||||
id={inputId}
|
||||
type={showSecret ? "text" : "password"}
|
||||
value={currentValue}
|
||||
disabled={disabled}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
placeholder={
|
||||
secretIsSet && !isDirty
|
||||
? `Saved (${secretHint}) — type to replace`
|
||||
: "Not set"
|
||||
}
|
||||
aria-invalid={!!error}
|
||||
aria-describedby={error ? errorId : undefined}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="shrink-0"
|
||||
aria-label={showSecret ? "Hide value" : "Show value"}
|
||||
onClick={() => setShowSecret((s) => !s)}
|
||||
>
|
||||
{showSecret ? (
|
||||
<EyeOff className="h-4 w-4" aria-hidden="true" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" aria-hidden="true" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
break
|
||||
case "number":
|
||||
control = (
|
||||
<Input
|
||||
id={inputId}
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={def.min}
|
||||
max={def.max}
|
||||
value={currentValue}
|
||||
disabled={disabled}
|
||||
placeholder={def.placeholder ?? "Not set"}
|
||||
className="w-full max-w-xs tabular-nums"
|
||||
aria-invalid={!!error}
|
||||
aria-describedby={error ? errorId : undefined}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
)
|
||||
break
|
||||
default:
|
||||
control = (
|
||||
<Input
|
||||
id={inputId}
|
||||
type="text"
|
||||
value={currentValue}
|
||||
disabled={disabled}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
placeholder={def.placeholder ?? "Not set"}
|
||||
className="w-full max-w-md"
|
||||
aria-invalid={!!error}
|
||||
aria-describedby={error ? errorId : undefined}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const canReset =
|
||||
!disabled && !isDirty && source === "file" && def.type !== "boolean"
|
||||
|
||||
return (
|
||||
<div className="border-b border-border/60 py-4 last:border-b-0">
|
||||
<div className="mb-1.5 flex flex-wrap items-center gap-2">
|
||||
<Label htmlFor={inputId} className="text-sm font-medium">
|
||||
{def.label}
|
||||
</Label>
|
||||
<SourceChip source={source} />
|
||||
{def.restartRequired && <RestartBadge />}
|
||||
{isDirty && (
|
||||
<span className="rounded bg-blue-500/10 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-blue-600 dark:text-blue-400">
|
||||
Modified
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{def.description && (
|
||||
<p className="mb-2 max-w-prose text-xs text-muted-foreground">
|
||||
{def.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex min-w-0 items-start gap-2">
|
||||
{control}
|
||||
{canReset && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="shrink-0"
|
||||
aria-label={`Reset ${def.label} to environment default`}
|
||||
title="Remove saved value (falls back to env var)"
|
||||
onClick={() => onChange(null)}
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" aria-hidden="true" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{isDirty && pendingValue === null && (
|
||||
<p className="mt-1.5 text-xs text-amber-600 dark:text-amber-400">
|
||||
Saved value will be removed; the environment default applies
|
||||
after saving.
|
||||
</p>
|
||||
)}
|
||||
<p
|
||||
id={errorId}
|
||||
className={cn(
|
||||
"text-xs text-destructive",
|
||||
error ? "mt-1.5" : "sr-only",
|
||||
)}
|
||||
aria-live="polite"
|
||||
>
|
||||
{error ?? ""}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProviderSubgroup({
|
||||
name,
|
||||
defs,
|
||||
settings,
|
||||
pending,
|
||||
errors,
|
||||
disabled,
|
||||
onChange,
|
||||
}: {
|
||||
name: string
|
||||
defs: SettingDef[]
|
||||
settings: SettingsMap
|
||||
pending: Record<string, string | null>
|
||||
errors: Record<string, string>
|
||||
disabled: boolean
|
||||
onChange: (key: string, value: string | null) => void
|
||||
}) {
|
||||
const configured = defs.some((d) => {
|
||||
const s = settings[d.key]
|
||||
return s && s.source !== "default"
|
||||
})
|
||||
const hasDirty = defs.some((d) => d.key in pending)
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<Collapsible open={open || hasDirty} onOpenChange={setOpen}>
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between rounded-md px-2 py-2.5 text-left text-sm font-medium hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||
<span className="flex items-center gap-2">
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"h-4 w-4 text-muted-foreground transition-transform motion-reduce:transition-none",
|
||||
(open || hasDirty) && "rotate-90",
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{name}
|
||||
</span>
|
||||
{configured && (
|
||||
<span className="flex items-center gap-1 text-xs text-green-600 dark:text-green-400">
|
||||
<Check className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
Configured
|
||||
</span>
|
||||
)}
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="px-2 pb-2">
|
||||
{defs.map((def) => (
|
||||
<SettingField
|
||||
key={def.key}
|
||||
def={def}
|
||||
state={settings[def.key]}
|
||||
pendingValue={pending[def.key]}
|
||||
error={errors[def.key]}
|
||||
disabled={disabled}
|
||||
onChange={(v) => onChange(def.key, v)}
|
||||
/>
|
||||
))}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AdminPage() {
|
||||
const [password, setPassword] = useState("")
|
||||
const [authedPassword, setAuthedPassword] = useState<string | null>(null)
|
||||
const [authError, setAuthError] = useState("")
|
||||
const [authLoading, setAuthLoading] = useState(false)
|
||||
|
||||
const [settings, setSettings] = useState<SettingsMap>({})
|
||||
const [writable, setWritable] = useState(true)
|
||||
const [pending, setPending] = useState<Record<string, string | null>>({})
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saveMessage, setSaveMessage] = useState("")
|
||||
const mainRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const dirtyCount = Object.keys(pending).length
|
||||
|
||||
const fetchSettings = useCallback(async (pw: string) => {
|
||||
const res = await fetch(getApiEndpoint("/api/admin/settings"), {
|
||||
headers: { "x-admin-password": pw },
|
||||
})
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
throw new Error(data.error || `Request failed (${res.status})`)
|
||||
}
|
||||
return res.json() as Promise<{
|
||||
writable: boolean
|
||||
settings: SettingState[]
|
||||
}>
|
||||
}, [])
|
||||
|
||||
const applyResponse = useCallback(
|
||||
(data: { writable: boolean; settings: SettingState[] }) => {
|
||||
setWritable(data.writable)
|
||||
const map: SettingsMap = {}
|
||||
for (const s of data.settings) map[s.key] = s
|
||||
setSettings(map)
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const login = useCallback(
|
||||
async (pw: string) => {
|
||||
setAuthLoading(true)
|
||||
setAuthError("")
|
||||
try {
|
||||
const data = await fetchSettings(pw)
|
||||
applyResponse(data)
|
||||
setAuthedPassword(pw)
|
||||
sessionStorage.setItem(SESSION_PASSWORD_KEY, pw)
|
||||
} catch (err) {
|
||||
setAuthError(
|
||||
err instanceof Error ? err.message : "Login failed",
|
||||
)
|
||||
} finally {
|
||||
setAuthLoading(false)
|
||||
}
|
||||
},
|
||||
[fetchSettings, applyResponse],
|
||||
)
|
||||
|
||||
// Restore session on mount
|
||||
useEffect(() => {
|
||||
const stored = sessionStorage.getItem(SESSION_PASSWORD_KEY)
|
||||
if (stored) void login(stored)
|
||||
}, [login])
|
||||
|
||||
// Warn before leaving with unsaved changes
|
||||
useEffect(() => {
|
||||
if (dirtyCount === 0) return
|
||||
const handler = (e: BeforeUnloadEvent) => {
|
||||
e.preventDefault()
|
||||
}
|
||||
window.addEventListener("beforeunload", handler)
|
||||
return () => window.removeEventListener("beforeunload", handler)
|
||||
}, [dirtyCount])
|
||||
|
||||
const handleChange = useCallback(
|
||||
(key: string, value: string | null) => {
|
||||
setSaveMessage("")
|
||||
setErrors((prev) => {
|
||||
if (!(key in prev)) return prev
|
||||
const next = { ...prev }
|
||||
delete next[key]
|
||||
return next
|
||||
})
|
||||
setPending((prev) => {
|
||||
const state = settings[key]
|
||||
const savedText =
|
||||
state && !isSecretValue(state.value)
|
||||
? (state.value ?? "")
|
||||
: ""
|
||||
// Typing back the saved value (or clearing an untouched field)
|
||||
// removes it from the dirty set
|
||||
const isRevert =
|
||||
value !== null &&
|
||||
state?.source === "file" &&
|
||||
!isSecretValue(state?.value) &&
|
||||
value === savedText
|
||||
const isNoop =
|
||||
value === "" &&
|
||||
(!state || state.source !== "file") &&
|
||||
!isSecretValue(state?.value)
|
||||
if (isRevert || isNoop) {
|
||||
const next = { ...prev }
|
||||
delete next[key]
|
||||
return next
|
||||
}
|
||||
return { ...prev, [key]: value === "" ? null : value }
|
||||
})
|
||||
},
|
||||
[settings],
|
||||
)
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!authedPassword || dirtyCount === 0) return
|
||||
setSaving(true)
|
||||
setSaveMessage("")
|
||||
setErrors({})
|
||||
try {
|
||||
const res = await fetch(getApiEndpoint("/api/admin/settings"), {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-admin-password": authedPassword,
|
||||
},
|
||||
body: JSON.stringify({ values: pending }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) {
|
||||
if (data.errors) {
|
||||
setErrors(data.errors)
|
||||
const firstKey = Object.keys(data.errors)[0]
|
||||
document.getElementById(`setting-${firstKey}`)?.focus()
|
||||
} else {
|
||||
setSaveMessage(data.error || "Save failed")
|
||||
}
|
||||
return
|
||||
}
|
||||
applyResponse(data)
|
||||
setPending({})
|
||||
setSaveMessage("Settings saved. Changes apply immediately.")
|
||||
} catch {
|
||||
setSaveMessage(
|
||||
"Save failed: network error. Check your connection and try again.",
|
||||
)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [authedPassword, pending, dirtyCount, applyResponse])
|
||||
|
||||
const providerSubgroups = useMemo(() => {
|
||||
const map = new Map<string, SettingDef[]>()
|
||||
for (const def of SETTINGS_REGISTRY) {
|
||||
if (def.group !== "providers" || !def.subgroup) continue
|
||||
const list = map.get(def.subgroup) ?? []
|
||||
list.push(def)
|
||||
map.set(def.subgroup, list)
|
||||
}
|
||||
return map
|
||||
}, [])
|
||||
|
||||
// ── Login screen ─────────────────────────────────────────────────
|
||||
if (!authedPassword) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
||||
<form
|
||||
className="w-full max-w-sm space-y-4 rounded-lg border bg-card p-6 shadow-sm"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
void login(password)
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<LockKeyhole
|
||||
className="h-5 w-5 text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<h1 className="text-lg font-semibold">
|
||||
Admin Settings
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Enter the admin password (the ADMIN_PASSWORD environment
|
||||
variable) to manage server settings.
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="admin-password">Password</Label>
|
||||
<Input
|
||||
id="admin-password"
|
||||
name="admin-password"
|
||||
type="password"
|
||||
value={password}
|
||||
autoComplete="current-password"
|
||||
spellCheck={false}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
className={cn(
|
||||
"text-sm text-destructive",
|
||||
!authError && "sr-only",
|
||||
)}
|
||||
aria-live="polite"
|
||||
>
|
||||
{authError}
|
||||
</p>
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={authLoading}
|
||||
>
|
||||
{authLoading ? (
|
||||
<>
|
||||
<Loader2
|
||||
className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Signing In…
|
||||
</>
|
||||
) : (
|
||||
"Sign In"
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Settings screen ──────────────────────────────────────────────
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<header className="sticky top-0 z-20 border-b bg-background/95 backdrop-blur">
|
||||
<div className="mx-auto flex max-w-5xl items-center justify-between px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldCheck
|
||||
className="h-5 w-5 text-primary"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<h1 className="text-lg font-semibold">
|
||||
Admin Settings
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
File overrides env · env overrides defaults
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{!writable && (
|
||||
<div className="border-b bg-amber-500/10">
|
||||
<div className="mx-auto flex max-w-5xl items-center gap-2 px-4 py-3 text-sm text-amber-700 dark:text-amber-400">
|
||||
<AlertTriangle
|
||||
className="h-4 w-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
The settings file is not writable on this deployment
|
||||
(serverless platforms have no persistent disk). Settings
|
||||
are shown read-only — configure via environment
|
||||
variables instead.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mx-auto flex max-w-5xl gap-8 px-4 py-6">
|
||||
<nav
|
||||
aria-label="Setting groups"
|
||||
className="sticky top-20 hidden h-fit w-44 shrink-0 md:block"
|
||||
>
|
||||
<ul className="space-y-1">
|
||||
{SETTING_GROUPS.map((group) => (
|
||||
<li key={group.id}>
|
||||
<a
|
||||
href={`#${group.id}`}
|
||||
className="block rounded-md px-3 py-1.5 text-sm text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{group.title}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<main ref={mainRef} className="min-w-0 flex-1 pb-24">
|
||||
{SETTING_GROUPS.map((group) => {
|
||||
const defs = SETTINGS_REGISTRY.filter(
|
||||
(d) => d.group === group.id,
|
||||
)
|
||||
return (
|
||||
<section
|
||||
key={group.id}
|
||||
aria-labelledby={group.id}
|
||||
className="mb-10"
|
||||
>
|
||||
<h2
|
||||
id={group.id}
|
||||
className="scroll-mt-20 text-base font-semibold"
|
||||
>
|
||||
{group.title}
|
||||
</h2>
|
||||
<p className="mb-3 mt-1 text-sm text-muted-foreground text-pretty">
|
||||
{group.description}
|
||||
</p>
|
||||
<div className="rounded-lg border bg-card px-4">
|
||||
{group.id === "providers"
|
||||
? [...providerSubgroups.entries()].map(
|
||||
([name, subDefs]) => (
|
||||
<ProviderSubgroup
|
||||
key={name}
|
||||
name={name}
|
||||
defs={subDefs}
|
||||
settings={settings}
|
||||
pending={pending}
|
||||
errors={errors}
|
||||
disabled={!writable}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
),
|
||||
)
|
||||
: defs.map((def) => (
|
||||
<SettingField
|
||||
key={def.key}
|
||||
def={def}
|
||||
state={settings[def.key]}
|
||||
pendingValue={
|
||||
pending[def.key]
|
||||
}
|
||||
error={errors[def.key]}
|
||||
disabled={!writable}
|
||||
onChange={(v) =>
|
||||
handleChange(def.key, v)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Always-mounted live region so save results are announced */}
|
||||
<p aria-live="polite" className="sr-only">
|
||||
{saveMessage}
|
||||
</p>
|
||||
|
||||
{(dirtyCount > 0 || saveMessage) && (
|
||||
<div className="fixed inset-x-0 bottom-0 z-30 border-t bg-background/95 backdrop-blur">
|
||||
<div className="mx-auto flex max-w-5xl items-center justify-between gap-4 px-4 py-3">
|
||||
<p className="min-w-0 truncate text-sm text-muted-foreground">
|
||||
{dirtyCount > 0
|
||||
? `${dirtyCount} unsaved ${dirtyCount === 1 ? "change" : "changes"}`
|
||||
: saveMessage}
|
||||
</p>
|
||||
{dirtyCount > 0 && (
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={saving}
|
||||
onClick={() => {
|
||||
setPending({})
|
||||
setErrors({})
|
||||
}}
|
||||
>
|
||||
Discard
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={saving || !writable}
|
||||
onClick={() => void handleSave()}
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<Loader2
|
||||
className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Saving…
|
||||
</>
|
||||
) : (
|
||||
"Save Changes"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
171
app/api/admin/settings/route.ts
Normal file
171
app/api/admin/settings/route.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { timingSafeEqual } from "crypto"
|
||||
import {
|
||||
getEnvFallback,
|
||||
getValueSource,
|
||||
isSettingsWritable,
|
||||
loadSettings,
|
||||
saveSettings,
|
||||
} from "@/lib/admin/settings"
|
||||
import { SETTINGS_BY_KEY, type SettingDef } from "@/lib/admin/settings-registry"
|
||||
import { ServerModelsConfigSchema } from "@/lib/server-model-config"
|
||||
|
||||
export const runtime = "nodejs"
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
function checkAuth(req: Request): Response | null {
|
||||
const password = process.env.ADMIN_PASSWORD
|
||||
if (!password) {
|
||||
return Response.json(
|
||||
{
|
||||
error: "Admin panel is disabled. Set the ADMIN_PASSWORD environment variable to enable it.",
|
||||
},
|
||||
{ status: 403 },
|
||||
)
|
||||
}
|
||||
const provided = req.headers.get("x-admin-password") || ""
|
||||
const a = Buffer.from(provided)
|
||||
const b = Buffer.from(password)
|
||||
if (a.length !== b.length || !timingSafeEqual(a, b)) {
|
||||
return Response.json(
|
||||
{ error: "Invalid admin password" },
|
||||
{ status: 401 },
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function maskSecret(value: string): { isSet: true; hint: string } {
|
||||
return {
|
||||
isSet: true,
|
||||
hint: value.length > 8 ? `…${value.slice(-4)}` : "••••",
|
||||
}
|
||||
}
|
||||
|
||||
function serializeSettings() {
|
||||
const fileValues = loadSettings()
|
||||
return [...SETTINGS_BY_KEY.values()].map((def) => {
|
||||
const source = getValueSource(def.key)
|
||||
const raw =
|
||||
source === "file"
|
||||
? fileValues[def.key]
|
||||
: (getEnvFallback(def.key) ?? null)
|
||||
|
||||
let value: unknown = raw
|
||||
if (def.type === "secret" && raw) {
|
||||
value = maskSecret(raw)
|
||||
}
|
||||
return { key: def.key, source, value }
|
||||
})
|
||||
}
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const authError = checkAuth(req)
|
||||
if (authError) return authError
|
||||
|
||||
return Response.json({
|
||||
writable: isSettingsWritable(),
|
||||
settings: serializeSettings(),
|
||||
})
|
||||
}
|
||||
|
||||
function validateValue(def: SettingDef, value: string): string | null {
|
||||
switch (def.type) {
|
||||
case "number": {
|
||||
const num = Number(value)
|
||||
if (Number.isNaN(num)) return "Must be a number"
|
||||
if (def.min !== undefined && num < def.min)
|
||||
return `Must be at least ${def.min}`
|
||||
if (def.max !== undefined && num > def.max)
|
||||
return `Must be at most ${def.max}`
|
||||
return null
|
||||
}
|
||||
case "boolean":
|
||||
return value === "true" || value === "false"
|
||||
? null
|
||||
: 'Must be "true" or "false"'
|
||||
case "enum":
|
||||
return def.options?.includes(value)
|
||||
? null
|
||||
: `Must be one of: ${def.options?.join(", ")}`
|
||||
case "json": {
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(value)
|
||||
} catch {
|
||||
return "Invalid JSON"
|
||||
}
|
||||
if (def.key === "AI_MODELS_CONFIG") {
|
||||
const result = ServerModelsConfigSchema.safeParse(parsed)
|
||||
if (!result.success) {
|
||||
return `Invalid model registry: ${result.error.issues[0]?.message ?? "schema mismatch"}`
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(req: Request) {
|
||||
const authError = checkAuth(req)
|
||||
if (authError) return authError
|
||||
|
||||
if (!isSettingsWritable()) {
|
||||
return Response.json(
|
||||
{
|
||||
error: "Settings file is not writable on this deployment. Configure via environment variables instead.",
|
||||
},
|
||||
{ status: 503 },
|
||||
)
|
||||
}
|
||||
|
||||
let body: { values?: Record<string, unknown> }
|
||||
try {
|
||||
body = await req.json()
|
||||
} catch {
|
||||
return Response.json({ error: "Invalid JSON body" }, { status: 400 })
|
||||
}
|
||||
if (!body.values || typeof body.values !== "object") {
|
||||
return Response.json(
|
||||
{ error: "Body must contain a values object" },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
const updates: Record<string, string | null> = {}
|
||||
const errors: Record<string, string> = {}
|
||||
|
||||
for (const [key, value] of Object.entries(body.values)) {
|
||||
const def = SETTINGS_BY_KEY.get(key)
|
||||
if (!def) {
|
||||
errors[key] = "Unknown setting"
|
||||
continue
|
||||
}
|
||||
if (value === null || value === "") {
|
||||
updates[key] = null
|
||||
continue
|
||||
}
|
||||
if (typeof value !== "string") {
|
||||
errors[key] = "Value must be a string"
|
||||
continue
|
||||
}
|
||||
const error = validateValue(def, value)
|
||||
if (error) {
|
||||
errors[key] = error
|
||||
continue
|
||||
}
|
||||
updates[key] = value
|
||||
}
|
||||
|
||||
if (Object.keys(errors).length > 0) {
|
||||
return Response.json({ errors }, { status: 400 })
|
||||
}
|
||||
|
||||
saveSettings(updates)
|
||||
|
||||
return Response.json({
|
||||
writable: true,
|
||||
settings: serializeSettings(),
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user