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:
dayuan.jiang
2026-06-10 20:41:17 +09:00
parent 54ff8d982c
commit e2e499e5c7
12 changed files with 1928 additions and 1 deletions

View File

@@ -61,6 +61,9 @@ COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
# Writable dir for admin panel settings (data/settings.json)
RUN mkdir -p /app/data && chown nextjs:nodejs /app/data
USER nextjs USER nextjs
EXPOSE 3000 EXPOSE 3000

View File

@@ -43,6 +43,8 @@ https://github.com/user-attachments/assets/9d60a3e8-4a1c-4b5e-acbb-26af2d3eabd1
- [Deploy on Vercel](#deploy-on-vercel) - [Deploy on Vercel](#deploy-on-vercel)
- [Deploy on Cloudflare Workers](#deploy-on-cloudflare-workers) - [Deploy on Cloudflare Workers](#deploy-on-cloudflare-workers)
- [Multi-Provider Support](#multi-provider-support) - [Multi-Provider Support](#multi-provider-support)
- [Server-Side Multi-Model Configuration](#server-side-multi-model-configuration)
- [Admin Panel](#admin-panel)
- [How It Works](#how-it-works) - [How It Works](#how-it-works)
- [Support \& Contact](#support--contact) - [Support \& Contact](#support--contact)
- [FAQ](#faq) - [FAQ](#faq)
@@ -224,6 +226,23 @@ All providers except AWS Bedrock and OpenRouter support custom endpoints.
Administrators can configure multiple server-side models that are available to all users without requiring personal API keys. Configure via `AI_MODELS_CONFIG` environment variable (JSON string) or `ai-models.json` file. Administrators can configure multiple server-side models that are available to all users without requiring personal API keys. Configure via `AI_MODELS_CONFIG` environment variable (JSON string) or `ai-models.json` file.
### Admin Panel
Instead of hand-editing `.env`, you can manage most server settings (provider keys, model behavior, access codes, quota, features) in a web admin panel:
1. Set the `ADMIN_PASSWORD` environment variable (leave unset to disable the panel).
2. Visit `/admin` and sign in.
3. Saved settings are written to `data/settings.json` and apply immediately — no restart needed (a few settings such as Langfuse and DynamoDB are marked "Restart Required").
Precedence: settings saved in the panel override environment variables, which override built-in defaults. Removing a saved value falls back to the environment variable.
Notes:
- Secrets are stored in plaintext in `data/settings.json` (file mode 600). Keep the file private.
- On serverless platforms (Vercel, Cloudflare Workers) there is no persistent disk, so the panel is read-only — configure via environment variables there.
- With Docker, the `data/` directory is persisted via the volume in `docker-compose.yml`.
- `NEXT_PUBLIC_*` variables are baked in at build time and cannot be changed in the panel.
**Model Requirements**: This task requires strong model capabilities for generating long-form text with strict formatting constraints (draw.io XML). Recommended models include Claude Sonnet 4.5, GPT-5.1, Gemini 3 Pro, and DeepSeek V3.2/R1. **Model Requirements**: This task requires strong model capabilities for generating long-form text with strict formatting constraints (draw.io XML). Recommended models include Claude Sonnet 4.5, GPT-5.1, Gemini 3 Pro, and DeepSeek V3.2/R1.
Note that the `claude` series has been trained on draw.io diagrams with cloud architecture logos like AWS, Azure, GCP. So if you want to create cloud architecture diagrams, this is the best choice. Note that the `claude` series has been trained on draw.io diagrams with cloud architecture logos like AWS, Azure, GCP. So if you want to create cloud architecture diagrams, this is the best choice.

746
app/[lang]/admin/page.tsx Normal file
View 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>
)
}

View 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(),
})
}

View File

@@ -11,6 +11,9 @@ services:
# - NEXT_PUBLIC_BASE_PATH=/nextaidrawio # - NEXT_PUBLIC_BASE_PATH=/nextaidrawio
ports: ["3000:3000"] ports: ["3000:3000"]
env_file: .env env_file: .env
volumes:
# Persists admin panel settings (data/settings.json)
- ./data:/app/data
# environment: # environment:
# # For subdirectory deployment, uncomment and set your path: # # For subdirectory deployment, uncomment and set your path:
# NEXT_PUBLIC_BASE_PATH: /nextaidrawio # NEXT_PUBLIC_BASE_PATH: /nextaidrawio

View File

@@ -222,6 +222,23 @@ npm run dev
注意:`claude` 系列已在带有 AWS、Azure、GCP 等云架构 Logo 的 draw.io 图表上进行训练,因此如果您想创建云架构图,这是最佳选择。 注意:`claude` 系列已在带有 AWS、Azure、GCP 等云架构 Logo 的 draw.io 图表上进行训练,因此如果您想创建云架构图,这是最佳选择。
### 管理面板
无需手动编辑 `.env`,您可以在 Web 管理面板中管理大部分服务端设置(提供商密钥、模型行为、访问码、配额、功能开关):
1. 设置 `ADMIN_PASSWORD` 环境变量(不设置则面板禁用)。
2. 访问 `/admin` 并登录。
3. 保存的设置会写入 `data/settings.json` 并立即生效,无需重启(少数设置如 Langfuse 和 DynamoDB 标记为"需要重启")。
优先级:面板中保存的设置覆盖环境变量,环境变量覆盖内置默认值。删除已保存的值会回退到环境变量。
注意事项:
- 密钥以明文形式存储在 `data/settings.json` 中(文件权限 600请妥善保管该文件。
- 在无服务器平台Vercel、Cloudflare Workers上没有持久化磁盘面板为只读 — 请改用环境变量配置。
- 使用 Docker 时,`data/` 目录通过 `docker-compose.yml` 中的卷持久化。
- `NEXT_PUBLIC_*` 变量在构建时固化,无法在面板中修改。
## 工作原理 ## 工作原理

View File

@@ -221,6 +221,23 @@ AWS BedrockとOpenRouter以外のすべてのプロバイダーはカスタム
注:`claude`シリーズはAWS、Azure、GCPなどのクラウドアーキテクチャロゴ付きのdraw.ioダイアグラムで学習されているため、クラウドアーキテクチャダイアグラムを作成したい場合は最適な選択です。 注:`claude`シリーズはAWS、Azure、GCPなどのクラウドアーキテクチャロゴ付きのdraw.ioダイアグラムで学習されているため、クラウドアーキテクチャダイアグラムを作成したい場合は最適な選択です。
### 管理パネル
`.env` を手動で編集する代わりに、Web 管理パネルでほとんどのサーバー設定(プロバイダーキー、モデル動作、アクセスコード、クォータ、機能)を管理できます:
1. `ADMIN_PASSWORD` 環境変数を設定します(未設定の場合、パネルは無効になります)。
2. `/admin` にアクセスしてサインインします。
3. 保存された設定は `data/settings.json` に書き込まれ、即座に反映されます — 再起動は不要ですLangfuse や DynamoDB など一部の設定は「再起動が必要」と表示されます)。
優先順位:パネルで保存された設定は環境変数を上書きし、環境変数は組み込みのデフォルト値を上書きします。保存した値を削除すると環境変数にフォールバックします。
注意事項:
- シークレットは `data/settings.json` に平文で保存されます(ファイルモード 600。ファイルの管理にはご注意ください。
- サーバーレスプラットフォームVercel、Cloudflare Workersには永続ディスクがないため、パネルは読み取り専用です — 環境変数で設定してください。
- Docker では、`data/` ディレクトリは `docker-compose.yml` のボリュームで永続化されます。
- `NEXT_PUBLIC_*` 変数はビルド時に固定されるため、パネルでは変更できません。
## 仕組み ## 仕組み

View File

@@ -116,6 +116,14 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
# Access Control (Optional) # Access Control (Optional)
# ACCESS_CODE_LIST=your-secret-code,another-code # ACCESS_CODE_LIST=your-secret-code,another-code
# Admin Panel (Optional)
# Set a password to enable the web admin panel at /admin, where most of the
# settings in this file can be edited at runtime (stored in data/settings.json,
# which takes precedence over environment variables).
# Leave unset to disable the admin panel entirely.
# ADMIN_PASSWORD=your-admin-password
# SETTINGS_FILE=./data/settings.json # Optional: custom settings file location
# Draw.io Configuration (Optional) # Draw.io Configuration (Optional)
# NEXT_PUBLIC_DRAWIO_BASE_URL=https://embed.diagrams.net # Default: https://embed.diagrams.net # NEXT_PUBLIC_DRAWIO_BASE_URL=https://embed.diagrams.net # Default: https://embed.diagrams.net
# Use this to point to a self-hosted draw.io instance # Use this to point to a self-hosted draw.io instance

View File

@@ -1,7 +1,17 @@
import { LangfuseSpanProcessor } from "@langfuse/otel" import { LangfuseSpanProcessor } from "@langfuse/otel"
import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node" import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node"
export function register() { export async function register() {
// Overlay admin settings file onto process.env before anything reads config
if (process.env.NEXT_RUNTIME === "nodejs") {
try {
const { applyToEnv } = await import("@/lib/admin/settings")
applyToEnv()
} catch (err) {
console.error("[admin-settings] Failed to apply settings:", err)
}
}
// Skip telemetry if Langfuse env vars are not configured // Skip telemetry if Langfuse env vars are not configured
if (!process.env.LANGFUSE_PUBLIC_KEY || !process.env.LANGFUSE_SECRET_KEY) { if (!process.env.LANGFUSE_PUBLIC_KEY || !process.env.LANGFUSE_SECRET_KEY) {
console.warn( console.warn(

View File

@@ -0,0 +1,684 @@
// Declarative registry of all env vars editable in the admin panel.
// Drives both server-side validation (app/api/admin/settings) and UI
// rendering (app/[lang]/admin). Keys are exactly the env var names.
//
// Not listed here (and therefore rejected by the API):
// - NEXT_PUBLIC_* vars: baked into the client bundle at build time
// - ADMIN_PASSWORD / SETTINGS_FILE: bootstrap values, env-only to avoid lockout
export type SettingType =
| "string"
| "secret"
| "number"
| "boolean"
| "enum"
| "json"
export interface SettingDef {
key: string
group: string
type: SettingType
label: string
description?: string
options?: string[]
min?: number
max?: number
placeholder?: string
// Value is only picked up at process start (module-load readers)
restartRequired?: boolean
// Collapsible subsection within a group (used for the per-provider lists)
subgroup?: string
}
export interface SettingGroup {
id: string
title: string
description: string
}
export const SETTING_GROUPS: SettingGroup[] = [
{
id: "general",
title: "General",
description: "Default AI provider and model used by the server.",
},
{
id: "providers",
title: "Provider Credentials",
description:
"API keys, endpoints, and tuning for each supported provider.",
},
{
id: "models",
title: "Model Behavior",
description: "Generation parameters and the multi-model registry.",
},
{
id: "access",
title: "Access Control",
description: "Restrict who can use this deployment.",
},
{
id: "quota",
title: "Quota & Rate Limits",
description:
"Per-IP usage limits. Enforcement requires a DynamoDB table.",
},
{
id: "features",
title: "Features",
description: "Optional features and security toggles.",
},
{
id: "observability",
title: "Observability",
description: "Langfuse tracing for LLM calls.",
},
]
const PROVIDER_OPTIONS = [
"bedrock",
"openai",
"anthropic",
"google",
"vertexai",
"azure",
"ollama",
"openrouter",
"deepseek",
"siliconflow",
"sglang",
"gateway",
"edgeone",
"doubao",
"modelscope",
"glm",
"qwen",
"kimi",
"qiniu",
"minimax",
"novita",
]
// Simple API-key + base-URL providers, rendered as one subgroup each
const SIMPLE_PROVIDERS: Array<{
subgroup: string
keyVar: string
urlVar: string
urlPlaceholder?: string
}> = [
{
subgroup: "OpenRouter",
keyVar: "OPENROUTER_API_KEY",
urlVar: "OPENROUTER_BASE_URL",
urlPlaceholder: "https://openrouter.ai/api/v1",
},
{
subgroup: "DeepSeek",
keyVar: "DEEPSEEK_API_KEY",
urlVar: "DEEPSEEK_BASE_URL",
urlPlaceholder: "https://api.deepseek.com/v1",
},
{
subgroup: "SiliconFlow",
keyVar: "SILICONFLOW_API_KEY",
urlVar: "SILICONFLOW_BASE_URL",
urlPlaceholder: "https://api.siliconflow.com/v1",
},
{
subgroup: "SGLang",
keyVar: "SGLANG_API_KEY",
urlVar: "SGLANG_BASE_URL",
urlPlaceholder: "http://127.0.0.1:8000/v1",
},
{
subgroup: "Vercel AI Gateway",
keyVar: "AI_GATEWAY_API_KEY",
urlVar: "AI_GATEWAY_BASE_URL",
urlPlaceholder: "https://ai-gateway.vercel.sh/v1/ai",
},
{
subgroup: "Doubao",
keyVar: "DOUBAO_API_KEY",
urlVar: "DOUBAO_BASE_URL",
urlPlaceholder: "https://ark.cn-beijing.volces.com/api/v3",
},
{
subgroup: "ModelScope",
keyVar: "MODELSCOPE_API_KEY",
urlVar: "MODELSCOPE_BASE_URL",
urlPlaceholder: "https://api-inference.modelscope.cn/v1",
},
{
subgroup: "GLM",
keyVar: "GLM_API_KEY",
urlVar: "GLM_BASE_URL",
urlPlaceholder: "https://open.bigmodel.cn/api/paas/v4",
},
{
subgroup: "Qwen",
keyVar: "QWEN_API_KEY",
urlVar: "QWEN_BASE_URL",
urlPlaceholder: "https://dashscope.aliyuncs.com/compatible-mode/v1",
},
{
subgroup: "Kimi",
keyVar: "KIMI_API_KEY",
urlVar: "KIMI_BASE_URL",
urlPlaceholder: "https://api.moonshot.cn/v1",
},
{
subgroup: "Qiniu",
keyVar: "QINIU_API_KEY",
urlVar: "QINIU_BASE_URL",
urlPlaceholder: "https://api.qnaigc.com/v1",
},
{
subgroup: "MiniMax",
keyVar: "MINIMAX_API_KEY",
urlVar: "MINIMAX_BASE_URL",
urlPlaceholder: "https://api.minimaxi.com/anthropic",
},
{
subgroup: "Novita",
keyVar: "NOVITA_API_KEY",
urlVar: "NOVITA_BASE_URL",
urlPlaceholder: "https://api.novita.ai/openai",
},
]
function simpleProviderSettings(): SettingDef[] {
return SIMPLE_PROVIDERS.flatMap((p) => [
{
key: p.keyVar,
group: "providers",
subgroup: p.subgroup,
type: "secret" as const,
label: "API Key",
},
{
key: p.urlVar,
group: "providers",
subgroup: p.subgroup,
type: "string" as const,
label: "Base URL",
placeholder: p.urlPlaceholder,
},
])
}
export const SETTINGS_REGISTRY: SettingDef[] = [
// ── General ──────────────────────────────────────────────────────
{
key: "AI_PROVIDER",
group: "general",
type: "enum",
label: "AI Provider",
description: "Default provider for chat requests.",
options: PROVIDER_OPTIONS,
},
{
key: "AI_MODEL",
group: "general",
type: "string",
label: "AI Model",
description: "Model ID for the chosen provider.",
placeholder: "e.g. gpt-5.2 or global.anthropic.claude-sonnet-4-5…",
},
// ── Providers: AWS Bedrock ───────────────────────────────────────
{
key: "AWS_REGION",
group: "providers",
subgroup: "AWS Bedrock",
type: "string",
label: "AWS Region",
placeholder: "us-west-2",
},
{
key: "AWS_ACCESS_KEY_ID",
group: "providers",
subgroup: "AWS Bedrock",
type: "secret",
label: "Access Key ID",
},
{
key: "AWS_SECRET_ACCESS_KEY",
group: "providers",
subgroup: "AWS Bedrock",
type: "secret",
label: "Secret Access Key",
},
{
key: "BEDROCK_REASONING_BUDGET_TOKENS",
group: "providers",
subgroup: "AWS Bedrock",
type: "number",
label: "Reasoning Budget Tokens",
description: "Claude extended-thinking budget (102464000).",
min: 1024,
max: 64000,
},
{
key: "BEDROCK_REASONING_EFFORT",
group: "providers",
subgroup: "AWS Bedrock",
type: "enum",
label: "Reasoning Effort",
description: "For Nova models.",
options: ["low", "medium", "high"],
},
// ── Providers: OpenAI ────────────────────────────────────────────
{
key: "OPENAI_API_KEY",
group: "providers",
subgroup: "OpenAI",
type: "secret",
label: "API Key",
},
{
key: "OPENAI_BASE_URL",
group: "providers",
subgroup: "OpenAI",
type: "string",
label: "Base URL",
description: "Custom OpenAI-compatible endpoint.",
placeholder: "https://api.openai.com/v1",
},
{
key: "OPENAI_REASONING_EFFORT",
group: "providers",
subgroup: "OpenAI",
type: "enum",
label: "Reasoning Effort",
options: ["minimal", "low", "medium", "high"],
},
{
key: "OPENAI_REASONING_SUMMARY",
group: "providers",
subgroup: "OpenAI",
type: "enum",
label: "Reasoning Summary",
options: ["none", "brief", "detailed"],
},
// ── Providers: Anthropic ─────────────────────────────────────────
{
key: "ANTHROPIC_API_KEY",
group: "providers",
subgroup: "Anthropic",
type: "secret",
label: "API Key",
description: "Sent as x-api-key header.",
},
{
key: "ANTHROPIC_AUTH_TOKEN",
group: "providers",
subgroup: "Anthropic",
type: "secret",
label: "Auth Token",
description:
"Alternative to the API key; sent as Authorization: Bearer.",
},
{
key: "ANTHROPIC_BASE_URL",
group: "providers",
subgroup: "Anthropic",
type: "string",
label: "Base URL",
placeholder: "https://api.anthropic.com/v1",
},
{
key: "ANTHROPIC_THINKING_TYPE",
group: "providers",
subgroup: "Anthropic",
type: "enum",
label: "Extended Thinking",
options: ["enabled"],
},
{
key: "ANTHROPIC_THINKING_BUDGET_TOKENS",
group: "providers",
subgroup: "Anthropic",
type: "number",
label: "Thinking Budget Tokens",
min: 1024,
max: 64000,
},
// ── Providers: Google ────────────────────────────────────────────
{
key: "GOOGLE_GENERATIVE_AI_API_KEY",
group: "providers",
subgroup: "Google",
type: "secret",
label: "API Key",
},
{
key: "GOOGLE_BASE_URL",
group: "providers",
subgroup: "Google",
type: "string",
label: "Base URL",
placeholder: "https://generativelanguage.googleapis.com/v1beta",
},
{
key: "GOOGLE_THINKING_BUDGET",
group: "providers",
subgroup: "Google",
type: "number",
label: "Thinking Budget (Gemini 2.5)",
min: 1024,
max: 100000,
},
{
key: "GOOGLE_THINKING_LEVEL",
group: "providers",
subgroup: "Google",
type: "enum",
label: "Thinking Level (Gemini 3)",
options: ["low", "high"],
},
{
key: "GOOGLE_REASONING_EFFORT",
group: "providers",
subgroup: "Google",
type: "enum",
label: "Reasoning Effort",
options: ["low", "medium", "high"],
},
{
key: "GOOGLE_CANDIDATE_COUNT",
group: "providers",
subgroup: "Google",
type: "number",
label: "Candidate Count",
min: 1,
max: 8,
},
{
key: "GOOGLE_TOP_K",
group: "providers",
subgroup: "Google",
type: "number",
label: "Top K",
min: 1,
max: 100,
},
{
key: "GOOGLE_TOP_P",
group: "providers",
subgroup: "Google",
type: "number",
label: "Top P",
min: 0,
max: 1,
},
// ── Providers: Vertex AI ─────────────────────────────────────────
{
key: "GOOGLE_VERTEX_API_KEY",
group: "providers",
subgroup: "Vertex AI",
type: "secret",
label: "API Key",
description: "Express Mode API key.",
},
{
key: "GOOGLE_VERTEX_BASE_URL",
group: "providers",
subgroup: "Vertex AI",
type: "string",
label: "Base URL",
},
{
key: "GOOGLE_VERTEX_THINKING_BUDGET",
group: "providers",
subgroup: "Vertex AI",
type: "number",
label: "Thinking Budget (Gemini 2.5)",
min: 1024,
max: 100000,
},
{
key: "GOOGLE_VERTEX_THINKING_LEVEL",
group: "providers",
subgroup: "Vertex AI",
type: "enum",
label: "Thinking Level (Gemini 3)",
options: ["minimal", "low", "medium", "high"],
},
// ── Providers: Azure OpenAI ──────────────────────────────────────
{
key: "AZURE_API_KEY",
group: "providers",
subgroup: "Azure OpenAI",
type: "secret",
label: "API Key",
},
{
key: "AZURE_RESOURCE_NAME",
group: "providers",
subgroup: "Azure OpenAI",
type: "string",
label: "Resource Name",
description: "Endpoint becomes https://{name}.openai.azure.com.",
},
{
key: "AZURE_BASE_URL",
group: "providers",
subgroup: "Azure OpenAI",
type: "string",
label: "Base URL",
description: "Alternative to resource name; takes precedence.",
},
{
key: "AZURE_REASONING_EFFORT",
group: "providers",
subgroup: "Azure OpenAI",
type: "enum",
label: "Reasoning Effort",
options: ["low", "medium", "high"],
},
{
key: "AZURE_REASONING_SUMMARY",
group: "providers",
subgroup: "Azure OpenAI",
type: "enum",
label: "Reasoning Summary",
options: ["none", "brief", "detailed"],
},
// ── Providers: Ollama ────────────────────────────────────────────
{
key: "OLLAMA_BASE_URL",
group: "providers",
subgroup: "Ollama",
type: "string",
label: "Base URL",
placeholder: "https://ollama.com/api",
},
{
key: "OLLAMA_API_KEY",
group: "providers",
subgroup: "Ollama",
type: "secret",
label: "API Key",
description: "For Ollama Cloud or authenticated remote instances.",
},
{
key: "OLLAMA_ENABLE_THINKING",
group: "providers",
subgroup: "Ollama",
type: "boolean",
label: "Enable Thinking",
},
...simpleProviderSettings(),
// ── Model Behavior ───────────────────────────────────────────────
{
key: "TEMPERATURE",
group: "models",
type: "number",
label: "Temperature",
description:
"Leave unset for reasoning models that reject temperature.",
min: 0,
max: 2,
},
{
key: "MAX_OUTPUT_TOKENS",
group: "models",
type: "number",
label: "Max Output Tokens",
min: 1,
},
{
key: "AI_MODELS_CONFIG",
group: "models",
type: "json",
label: "Multi-Model Registry (JSON)",
description:
'Server model list shown to all users. Schema: {"providers":[{"name":"…","provider":"openai","models":["…"]}]}.',
},
{
key: "AI_MODELS_CONFIG_PATH",
group: "models",
type: "string",
label: "Model Registry File Path",
description: "Used only when the JSON registry above is empty.",
placeholder: "./ai-models.json",
},
// ── Access Control ───────────────────────────────────────────────
{
key: "ACCESS_CODE_LIST",
group: "access",
type: "string",
label: "Access Codes",
description:
"Comma-separated list. Users must enter one to chat. Empty = open access.",
placeholder: "code1,code2",
},
// ── Quota ────────────────────────────────────────────────────────
{
key: "DAILY_REQUEST_LIMIT",
group: "quota",
type: "number",
label: "Daily Request Limit",
description: "Per IP per day.",
min: 1,
},
{
key: "DAILY_TOKEN_LIMIT",
group: "quota",
type: "number",
label: "Daily Token Limit",
description: "Per IP per day.",
min: 1,
},
{
key: "TPM_LIMIT",
group: "quota",
type: "number",
label: "Tokens Per Minute",
min: 1,
},
{
key: "DYNAMODB_QUOTA_TABLE",
group: "quota",
type: "string",
label: "DynamoDB Table",
description: "Quota enforcement is disabled when empty.",
restartRequired: true,
},
{
key: "DYNAMODB_REGION",
group: "quota",
type: "string",
label: "DynamoDB Region",
placeholder: "ap-northeast-1",
restartRequired: true,
},
{
key: "QUOTA_TIMEZONE",
group: "quota",
type: "string",
label: "Quota Timezone",
description: "Timezone for the daily reset boundary.",
placeholder: "UTC",
restartRequired: true,
},
// ── Features ─────────────────────────────────────────────────────
{
key: "ENABLE_VLM_VALIDATION",
group: "features",
type: "boolean",
label: "VLM Diagram Validation",
description:
"Visually validate generated diagrams with a vision model.",
},
{
key: "VALIDATION_MODEL",
group: "features",
type: "string",
label: "Validation Model",
description: "Falls back to the default AI model when empty.",
},
{
key: "VALIDATION_TIMEOUT",
group: "features",
type: "number",
label: "Validation Timeout (ms)",
min: 1000,
},
{
key: "ENABLE_HISTORY_XML_REPLACE",
group: "features",
type: "boolean",
label: "History XML Compression",
description: "Replace old diagram XML in history with placeholders.",
},
{
key: "ALLOW_PRIVATE_URLS",
group: "features",
type: "boolean",
label: "Allow Private URLs",
description:
"Turn off to block requests to private IPs and internal hostnames (SSRF protection).",
},
// ── Observability ────────────────────────────────────────────────
{
key: "LANGFUSE_PUBLIC_KEY",
group: "observability",
type: "string",
label: "Langfuse Public Key",
placeholder: "pk-lf-…",
restartRequired: true,
},
{
key: "LANGFUSE_SECRET_KEY",
group: "observability",
type: "secret",
label: "Langfuse Secret Key",
restartRequired: true,
},
{
key: "LANGFUSE_BASEURL",
group: "observability",
type: "string",
label: "Langfuse Base URL",
placeholder: "https://cloud.langfuse.com",
restartRequired: true,
},
]
export const SETTINGS_BY_KEY: Map<string, SettingDef> = new Map(
SETTINGS_REGISTRY.map((def) => [def.key, def]),
)

125
lib/admin/settings.ts Normal file
View File

@@ -0,0 +1,125 @@
import fs from "fs"
import path from "path"
// File-based admin settings, overlaid onto process.env (dotenv-style).
// Precedence: settings file > env var > built-in default.
// Keys are exactly the env var names.
interface SettingsFile {
version: 1
values: Record<string, string>
}
// Original env values snapshotted before the first overlay, so removing a
// key from the settings file restores the env default. null = was unset.
const originalEnv: Record<string, string | null> = {}
// Keys currently overlaid, so we can restore ones removed from the file.
let overlaidKeys = new Set<string>()
let cachedSettings: Record<string, string> | null = null
export function getSettingsPath(): string {
const custom = process.env.SETTINGS_FILE
if (custom && custom.trim().length > 0) return custom
return path.join(process.cwd(), "data", "settings.json")
}
export function loadSettings(): Record<string, string> {
if (cachedSettings) return cachedSettings
try {
const raw = fs.readFileSync(getSettingsPath(), "utf8")
const parsed = JSON.parse(raw) as SettingsFile
cachedSettings =
parsed && typeof parsed.values === "object" ? parsed.values : {}
} catch (err: any) {
if (err?.code !== "ENOENT") {
console.error("[admin-settings] Failed to read settings file:", err)
}
cachedSettings = {}
}
return cachedSettings
}
export function applyToEnv(): void {
const values = loadSettings()
// Restore env for keys that were overlaid before but are now gone
for (const key of overlaidKeys) {
if (!(key in values)) {
const original = originalEnv[key]
if (original === null) delete process.env[key]
else process.env[key] = original
}
}
for (const [key, value] of Object.entries(values)) {
if (!(key in originalEnv)) {
originalEnv[key] = process.env[key] ?? null
}
process.env[key] = value
}
overlaidKeys = new Set(Object.keys(values))
}
// Whether a key's current value comes from the file, the environment, or is unset
export function getValueSource(key: string): "file" | "env" | "default" {
const values = loadSettings()
if (key in values) return "file"
const envValue = overlaidKeys.has(key)
? originalEnv[key]
: (process.env[key] ?? null)
return envValue !== null && envValue !== undefined ? "env" : "default"
}
// The effective env value if the file entry were removed (for fallback display)
export function getEnvFallback(key: string): string | null {
if (overlaidKeys.has(key)) return originalEnv[key] ?? null
return process.env[key] ?? null
}
export function saveSettings(updates: Record<string, string | null>): void {
const current = { ...loadSettings() }
for (const [key, value] of Object.entries(updates)) {
if (value === null) delete current[key]
else current[key] = value
}
const filePath = getSettingsPath()
fs.mkdirSync(path.dirname(filePath), { recursive: true })
const tmpPath = `${filePath}.tmp`
const data: SettingsFile = { version: 1, values: current }
fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2), { mode: 0o600 })
fs.renameSync(tmpPath, filePath)
cachedSettings = current
applyToEnv()
}
let writableCache: boolean | null = null
export function isSettingsWritable(): boolean {
if (writableCache !== null) return writableCache
try {
const dir = path.dirname(getSettingsPath())
fs.mkdirSync(dir, { recursive: true })
fs.accessSync(dir, fs.constants.W_OK)
writableCache = true
} catch {
writableCache = false
}
return writableCache
}
// Test-only: reset module state
export function _resetForTests(): void {
cachedSettings = null
writableCache = null
for (const key of overlaidKeys) {
const original = originalEnv[key]
if (original === null) delete process.env[key]
else if (original !== undefined) process.env[key] = original
}
overlaidKeys = new Set()
for (const key of Object.keys(originalEnv)) delete originalEnv[key]
}

View File

@@ -0,0 +1,124 @@
import fs from "fs"
import os from "os"
import path from "path"
import { afterEach, beforeEach, describe, expect, it } from "vitest"
import {
_resetForTests,
applyToEnv,
getEnvFallback,
getValueSource,
isSettingsWritable,
loadSettings,
saveSettings,
} from "@/lib/admin/settings"
let tmpDir: string
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "admin-settings-"))
process.env.SETTINGS_FILE = path.join(tmpDir, "settings.json")
_resetForTests()
})
afterEach(() => {
_resetForTests()
delete process.env.SETTINGS_FILE
fs.rmSync(tmpDir, { recursive: true, force: true })
delete process.env.TEST_ADMIN_VAR
})
describe("loadSettings", () => {
it("returns empty object when file does not exist", () => {
expect(loadSettings()).toEqual({})
})
it("reads values from the settings file", () => {
fs.writeFileSync(
process.env.SETTINGS_FILE!,
JSON.stringify({ version: 1, values: { TEST_ADMIN_VAR: "abc" } }),
)
expect(loadSettings()).toEqual({ TEST_ADMIN_VAR: "abc" })
})
})
describe("applyToEnv / saveSettings", () => {
it("overlays file values onto process.env", () => {
saveSettings({ TEST_ADMIN_VAR: "from-file" })
expect(process.env.TEST_ADMIN_VAR).toBe("from-file")
})
it("file value wins over pre-existing env value", () => {
process.env.TEST_ADMIN_VAR = "from-env"
saveSettings({ TEST_ADMIN_VAR: "from-file" })
expect(process.env.TEST_ADMIN_VAR).toBe("from-file")
})
it("deleting a key restores the original env value", () => {
process.env.TEST_ADMIN_VAR = "from-env"
saveSettings({ TEST_ADMIN_VAR: "from-file" })
saveSettings({ TEST_ADMIN_VAR: null })
expect(process.env.TEST_ADMIN_VAR).toBe("from-env")
})
it("deleting a key unsets env when there was no original value", () => {
saveSettings({ TEST_ADMIN_VAR: "from-file" })
saveSettings({ TEST_ADMIN_VAR: null })
expect(process.env.TEST_ADMIN_VAR).toBeUndefined()
})
it("persists across cache reset (file round-trip)", () => {
saveSettings({ TEST_ADMIN_VAR: "persisted" })
_resetForTests()
applyToEnv()
expect(process.env.TEST_ADMIN_VAR).toBe("persisted")
})
})
describe("getValueSource / getEnvFallback", () => {
it("reports file source when key is in settings", () => {
saveSettings({ TEST_ADMIN_VAR: "x" })
expect(getValueSource("TEST_ADMIN_VAR")).toBe("file")
})
it("reports env source when only env is set", () => {
process.env.TEST_ADMIN_VAR = "from-env"
applyToEnv()
expect(getValueSource("TEST_ADMIN_VAR")).toBe("env")
})
it("reports default when neither is set", () => {
expect(getValueSource("TEST_ADMIN_VAR")).toBe("default")
})
it("returns the shadowed env value as fallback", () => {
process.env.TEST_ADMIN_VAR = "from-env"
saveSettings({ TEST_ADMIN_VAR: "from-file" })
expect(getEnvFallback("TEST_ADMIN_VAR")).toBe("from-env")
})
})
describe("isSettingsWritable", () => {
it("returns true for a writable temp dir", () => {
expect(isSettingsWritable()).toBe(true)
})
it("returns false for an unwritable path", () => {
_resetForTests()
process.env.SETTINGS_FILE = "/nonexistent-root-dir/settings.json"
expect(isSettingsWritable()).toBe(false)
})
})
describe("settings file on disk", () => {
it("writes valid JSON with restrictive permissions", () => {
saveSettings({ TEST_ADMIN_VAR: "secret" })
const filePath = process.env.SETTINGS_FILE!
const parsed = JSON.parse(fs.readFileSync(filePath, "utf8"))
expect(parsed).toEqual({
version: 1,
values: { TEST_ADMIN_VAR: "secret" },
})
const mode = fs.statSync(filePath).mode & 0o777
expect(mode).toBe(0o600)
})
})