From e2e499e5c7da97aba5252ef2cb690b86a96741e3 Mon Sep 17 00:00:00 2001 From: "dayuan.jiang" Date: Wed, 10 Jun 2026 20:41:17 +0900 Subject: [PATCH] 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. --- Dockerfile | 3 + README.md | 19 + app/[lang]/admin/page.tsx | 746 ++++++++++++++++++++++++++++++ app/api/admin/settings/route.ts | 171 +++++++ docker-compose.yml | 3 + docs/cn/README_CN.md | 17 + docs/ja/README_JA.md | 17 + env.example | 8 + instrumentation.ts | 12 +- lib/admin/settings-registry.ts | 684 +++++++++++++++++++++++++++ lib/admin/settings.ts | 125 +++++ tests/unit/admin-settings.test.ts | 124 +++++ 12 files changed, 1928 insertions(+), 1 deletion(-) create mode 100644 app/[lang]/admin/page.tsx create mode 100644 app/api/admin/settings/route.ts create mode 100644 lib/admin/settings-registry.ts create mode 100644 lib/admin/settings.ts create mode 100644 tests/unit/admin-settings.test.ts diff --git a/Dockerfile b/Dockerfile index 7d5f640..037b48c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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/static ./.next/static +# Writable dir for admin panel settings (data/settings.json) +RUN mkdir -p /app/data && chown nextjs:nodejs /app/data + USER nextjs EXPOSE 3000 diff --git a/README.md b/README.md index 699b2bc..7dd607f 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,8 @@ https://github.com/user-attachments/assets/9d60a3e8-4a1c-4b5e-acbb-26af2d3eabd1 - [Deploy on Vercel](#deploy-on-vercel) - [Deploy on Cloudflare Workers](#deploy-on-cloudflare-workers) - [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) - [Support \& Contact](#support--contact) - [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. +### 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. 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. diff --git a/app/[lang]/admin/page.tsx b/app/[lang]/admin/page.tsx new file mode 100644 index 0000000..f6db0d9 --- /dev/null +++ b/app/[lang]/admin/page.tsx @@ -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 + +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 ( + + {source === "file" ? "Saved" : "Env"} + + ) +} + +function RestartBadge() { + return ( + + Restart Required + + ) +} + +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 = ( + + onChange(checked ? "true" : "false") + } + /> + ) + break + case "enum": + control = ( + + ) + break + case "json": + control = ( +