mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-09-02 01:20:23 +08:00
feat: add file-based admin settings panel at /admin (#866)
* 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.
* polish: admin panel UI improvements
- Provider logos in credential rows (shared ProviderLogo component,
extracted from model-config-dialog)
- Scroll-spy active state in the sidebar nav
- Green success state in the save bar that clears after a few seconds
- Wider content column (max-w-6xl) for less wasted space on desktop
* polish: admin panel section toggles and reorder
- Move Quota & Rate Limits to the end of the settings page
- Add enable switches to Observability and Quota sections; default off
with fields grayed out, auto-on when any field is already configured
* polish: make section enable switch more visible
Wrap the switch in a labeled pill ('Enabled'/'Disabled') with border
and background so the off state is clearly visible.
* refactor: derive admin registry from PROVIDER_INFO, simplify page state
- Provider options, labels, and base-URL placeholders now come from
PROVIDER_INFO instead of hand-copied lists (fixes SiliconFlow .com/.cn
placeholder drift; panel names now match the model-config dialog)
- Replace free-text subgroup strings + SUBGROUP_PROVIDERS reverse map
with a typed provider field on SettingDef
- Precompute SETTINGS_BY_GROUP and PROVIDER_SUBGROUPS at module level
- Merge justSaved into saveMessage, drop unused mainRef, hoist
fetchSettings out of the component, dedupe savedText logic
- Serialize from SETTINGS_REGISTRY directly; json validators in a map
instead of a hardcoded key check
- Make allowPrivateUrls a function so ALLOW_PRIVATE_URLS edits in the
admin panel apply without restart
* feat: graphical model management in admin panel
Replace the provider credential fields and raw AI_MODELS_CONFIG JSON
textarea with a Models section mirroring the in-app model settings UI:
provider instance list with logos, credential fields per provider type,
model add/remove with suggestions, per-model connectivity test, and a
default-provider star.
On save the server derives everything the runtime needs into
settings.json: credential env vars (with _2 suffixes for multiple
instances of one provider), AI_MODELS_CONFIG, and AI_PROVIDER/AI_MODEL
for the default. Secrets round-trip as masked markers and are never
sent back to the browser. The general settings registry now only
covers non-provider settings (generation, access, features,
observability, quota).
* fix: allow testing unsaved providers in admin panel
The test button previously looked up credentials by providerId in the
saved settings, so testing a newly added (unsaved) provider failed with
'Unknown provider or model'. The test endpoint now accepts the client's
current provider state; newly typed secrets are used as-is and masked
markers are resolved against the stored values, so testing works both
before and after saving.
* fix: merge env AI_MODELS_CONFIG with admin panel providers
Previously, saving in the admin panel wrote a complete AI_MODELS_CONFIG
into settings.json, which (by overlay precedence) replaced any config
from .env or ai-models.json — admins lost their env-configured models.
The panel no longer writes AI_MODELS_CONFIG. Instead its providers are
merged with the env baseline at read time in loadRawServerModelsConfig,
and panel credentials go to ADMIN_-prefixed env vars wired up via
apiKeyEnv/baseUrlEnv so they never shadow standard vars. Env-based
providers now appear read-only in the panel, name clashes are rejected,
and a panel default overrides the env default. data/ is now gitignored.
* fix: block global-credential providers already managed via env
Bedrock, Vertex AI, and Ollama credentials live in fixed env vars with
no apiKeyEnv redirection, so a panel instance of one of these would
silently override the credentials that env-configured models rely on.
The API now rejects saving such a provider when the env config already
uses that type, and the Add Provider dropdown disables it with a
'managed via env' note.
* fix: address admin panel review findings
- Security: test-model no longer resolves a stored secret when the
request's baseUrl/provider differs from the stored entry, closing a
path where a tampered baseUrl could exfiltrate a saved key
- Save failures are now visible: the save bar shows the error in red
(was masked by the persistent 'Unsaved changes' text), and per-field
validation errors from the settings API are surfaced under each field
- The Observability/Quota enable switch is now real: toggling off stages
deletion of the group's saved values, and the toggle no longer snaps
back to Enabled after saving
- Env provider's default star is hidden when a panel provider is the
active default (no more double star)
- Clearing a credential field reverts to the stored value instead of
silently deleting it; an explicit X button removes a stored secret
- Form inputs are disabled during an in-flight save
* refactor(admin): split 1549-line admin page into focused modules
Extract admin-shared.ts (types + fetch helper), setting-field.tsx
(registry-driven fields), and models-section.tsx (provider/model
manager) from page.tsx. Pure mechanical move, no behavior change.
* feat(admin): share credential fields with user dialog and localize panel
Extract ProviderCredentialsFields (display name + per-provider
credential inputs) used by both the user ModelConfigDialog and the
admin Models panel; secret input passed via renderSecret (plaintext
vs masked), test button via footer slot. Add full i18n for the admin
panel across en/zh/ja/zh-Hant, reusing modelConfig.* for shared parts.
* fix(admin): address Copilot review findings
- Reflect built-in defaults for boolean settings (ALLOW_PRIVATE_URLS
defaults on) and allow clearing a saved boolean back to default,
so the SSRF toggle matches actual runtime behavior.
- Harden JSON loading: filter settings values to strings only, and
schema-validate stored ADMIN_PROVIDERS entries, dropping malformed
ones instead of letting them reach runtime code.
- Set beforeunload returnValue so the unsaved-changes prompt shows in
all browsers; reject non-finite numbers in settings validation.
- Fix README/CN/JA docs that claimed the panel auto-generates
AI_MODELS_CONFIG (providers are merged at read time, not written).
- Add unit tests for corrupted-file value filtering and provider
schema validation.
* docs: move admin panel details to dedicated docs/{en,cn,ja}/admin-panel.md
The READMEs now carry a short blurb + link, matching the existing
per-topic docs (docker.md, ai-providers.md, ...). Removes the ~22-line
inline section and the duplicated data/settings.json mentions.
* fix(admin): address follow-up Copilot findings on the prior fixes
- loadAdminProviders now validates against a stored-shape schema where
secrets are plain strings, so a hand-edited ADMIN_PROVIDERS holding an
{isSet} marker is dropped instead of later crashing maskSecret().
- loadSettings guards against array values (typeof [] === 'object'),
which would otherwise overlay numeric keys onto process.env.
- Admin SecretInput uses the bare id so the shared component's
<Label htmlFor> stays associated (only one ProviderDetail mounts).
- Add tests: marker-secret rejection, array-values guard, bedrock
multi-secret round-trip.
This commit is contained in:
65
app/[lang]/admin/admin-shared.ts
Normal file
65
app/[lang]/admin/admin-shared.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { getApiEndpoint } from "@/lib/base-path"
|
||||
import type { ProviderName } from "@/lib/types/model-config"
|
||||
|
||||
export const SESSION_PASSWORD_KEY = "next-ai-draw-io-admin-password"
|
||||
|
||||
// ── Shared types ─────────────────────────────────────────────────────
|
||||
|
||||
export type SecretValue = { isSet: true; hint: string }
|
||||
|
||||
export function isSecretValue(v: unknown): v is SecretValue {
|
||||
return typeof v === "object" && v !== null && "isSet" in v
|
||||
}
|
||||
|
||||
export interface SettingState {
|
||||
key: string
|
||||
source: "file" | "env" | "default"
|
||||
value: string | SecretValue | null
|
||||
}
|
||||
|
||||
export type SettingsMap = Record<string, SettingState>
|
||||
|
||||
// Editable text of a saved setting; secrets have none (write-only)
|
||||
export function savedTextOf(state: SettingState | undefined): string {
|
||||
return state && !isSecretValue(state.value) ? (state.value ?? "") : ""
|
||||
}
|
||||
|
||||
// Admin provider in client state. Secret fields hold either a masked
|
||||
// marker (unchanged) or a plaintext string (new value).
|
||||
export interface AdminProvider {
|
||||
id: string
|
||||
provider: ProviderName
|
||||
name?: string
|
||||
apiKey?: string | SecretValue
|
||||
baseUrl?: string
|
||||
awsAccessKeyId?: string | SecretValue
|
||||
awsSecretAccessKey?: string | SecretValue
|
||||
awsRegion?: string
|
||||
vertexApiKey?: string | SecretValue
|
||||
models: string[]
|
||||
isDefault?: boolean
|
||||
}
|
||||
|
||||
// Provider defined in AI_MODELS_CONFIG / ai-models.json — shown read-only
|
||||
export interface EnvProvider {
|
||||
name: string
|
||||
provider: ProviderName
|
||||
models: string[]
|
||||
isDefault: boolean
|
||||
}
|
||||
|
||||
export async function adminFetch(path: string, pw: string, init?: RequestInit) {
|
||||
const res = await fetch(getApiEndpoint(path), {
|
||||
...init,
|
||||
headers: {
|
||||
...init?.headers,
|
||||
"x-admin-password": pw,
|
||||
...(init?.body ? { "Content-Type": "application/json" } : {}),
|
||||
},
|
||||
})
|
||||
const data = await res.json().catch(() => ({}))
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || `Request failed (${res.status})`)
|
||||
}
|
||||
return data
|
||||
}
|
||||
609
app/[lang]/admin/models-section.tsx
Normal file
609
app/[lang]/admin/models-section.tsx
Normal file
@@ -0,0 +1,609 @@
|
||||
import {
|
||||
AlertCircle,
|
||||
Check,
|
||||
Loader2,
|
||||
Plus,
|
||||
Star,
|
||||
Trash2,
|
||||
X,
|
||||
Zap,
|
||||
} from "lucide-react"
|
||||
import { useState } from "react"
|
||||
import { ProviderCredentialsFields } from "@/components/provider-credentials-fields"
|
||||
import { ProviderLogo } from "@/components/provider-logo"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from "@/components/ui/select"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { formatMessage } from "@/lib/i18n/utils"
|
||||
import {
|
||||
FIXED_CRED_PROVIDERS,
|
||||
PROVIDER_INFO,
|
||||
type ProviderName,
|
||||
SUGGESTED_MODELS,
|
||||
} from "@/lib/types/model-config"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
type AdminProvider,
|
||||
adminFetch,
|
||||
type EnvProvider,
|
||||
} from "./admin-shared"
|
||||
import { SecretInput } from "./setting-field"
|
||||
|
||||
// ── Models section (mirrors the user ModelConfigDialog) ──────────────
|
||||
|
||||
function ProviderDetail({
|
||||
provider,
|
||||
disabled,
|
||||
password,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
}: {
|
||||
provider: AdminProvider
|
||||
disabled: boolean
|
||||
password: string
|
||||
onUpdate: (patch: Partial<AdminProvider>) => void
|
||||
onDelete: () => void
|
||||
}) {
|
||||
const dict = useDictionary()
|
||||
const [modelInput, setModelInput] = useState("")
|
||||
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||
const [testing, setTesting] = useState<string | null>(null)
|
||||
const [testResults, setTestResults] = useState<
|
||||
Record<string, { ok: boolean; message: string }>
|
||||
>({})
|
||||
|
||||
const info = PROVIDER_INFO[provider.provider]
|
||||
const suggestions = (SUGGESTED_MODELS[provider.provider] || []).filter(
|
||||
(m) => !provider.models.includes(m),
|
||||
)
|
||||
|
||||
const addModel = (modelId: string) => {
|
||||
const trimmed = modelId.trim()
|
||||
if (!trimmed || provider.models.includes(trimmed)) return
|
||||
onUpdate({ models: [...provider.models, trimmed] })
|
||||
setModelInput("")
|
||||
}
|
||||
|
||||
const testModel = async (modelId: string) => {
|
||||
setTesting(modelId)
|
||||
try {
|
||||
const data = await adminFetch("/api/admin/test-model", password, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ provider, modelId }),
|
||||
})
|
||||
setTestResults((prev) => ({
|
||||
...prev,
|
||||
[modelId]: data.valid
|
||||
? {
|
||||
ok: true,
|
||||
message: formatMessage(dict.admin.testOk, {
|
||||
ms: data.responseTime,
|
||||
}),
|
||||
}
|
||||
: {
|
||||
ok: false,
|
||||
message: data.error || dict.admin.testFailed,
|
||||
},
|
||||
}))
|
||||
} catch (err) {
|
||||
setTestResults((prev) => ({
|
||||
...prev,
|
||||
[modelId]: {
|
||||
ok: false,
|
||||
message:
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: dict.admin.testFailed,
|
||||
},
|
||||
}))
|
||||
} finally {
|
||||
setTesting(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
|
||||
<ProviderLogo
|
||||
provider={provider.provider}
|
||||
className="size-5"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="font-semibold">{info.label}</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{provider.models.length === 0
|
||||
? dict.admin.noModelsConfigured
|
||||
: formatMessage(
|
||||
provider.models.length === 1
|
||||
? dict.admin.modelCount
|
||||
: dict.admin.modelCountPlural,
|
||||
{ count: provider.models.length },
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<label className="flex cursor-pointer items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Star
|
||||
className={cn(
|
||||
"h-3.5 w-3.5",
|
||||
provider.isDefault &&
|
||||
"fill-amber-400 text-amber-400",
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{dict.admin.default}
|
||||
<Switch
|
||||
checked={!!provider.isDefault}
|
||||
disabled={disabled}
|
||||
aria-label={dict.admin.setAsDefault}
|
||||
onCheckedChange={(checked) =>
|
||||
onUpdate({ isDefault: checked })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
>
|
||||
<Trash2 className="mr-1.5 h-4 w-4" aria-hidden="true" />
|
||||
{dict.admin.delete}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Credentials (shared with the user ModelConfigDialog) */}
|
||||
<ProviderCredentialsFields
|
||||
provider={provider.provider}
|
||||
name={provider.name}
|
||||
baseUrl={provider.baseUrl}
|
||||
awsRegion={provider.awsRegion}
|
||||
disabled={disabled}
|
||||
onChange={(field, value) => onUpdate({ [field]: value })}
|
||||
renderSecret={({ field, id }) => (
|
||||
// Bare id keeps the shared component's <Label htmlFor={id}>
|
||||
// associated; only one ProviderDetail is mounted at a time.
|
||||
<SecretInput
|
||||
id={id}
|
||||
keepOnEmpty
|
||||
value={provider[field]}
|
||||
disabled={disabled}
|
||||
onChange={(v) => onUpdate({ [field]: v })}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Models */}
|
||||
<div>
|
||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
|
||||
<Label className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{dict.admin.models}
|
||||
</Label>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Input
|
||||
value={modelInput}
|
||||
disabled={disabled}
|
||||
placeholder={dict.admin.modelIdPlaceholder}
|
||||
spellCheck={false}
|
||||
className="h-8 w-48 font-mono text-xs"
|
||||
onChange={(e) => setModelInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") addModel(modelInput)
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
disabled={disabled || !modelInput.trim()}
|
||||
aria-label={dict.admin.addModel}
|
||||
onClick={() => addModel(modelInput)}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</Button>
|
||||
{suggestions.length > 0 && (
|
||||
<Select
|
||||
disabled={disabled}
|
||||
onValueChange={(v) => addModel(v)}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-28 text-xs">
|
||||
{dict.admin.suggested}
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-72">
|
||||
{suggestions.map((m) => (
|
||||
<SelectItem
|
||||
key={m}
|
||||
value={m}
|
||||
className="font-mono text-xs"
|
||||
>
|
||||
{m}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-lg border">
|
||||
{provider.models.length === 0 ? (
|
||||
<p className="p-5 text-center text-sm text-muted-foreground">
|
||||
{dict.admin.addProviderToOfferModels}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y">
|
||||
{provider.models.map((modelId, index) => {
|
||||
const result = testResults[modelId]
|
||||
return (
|
||||
<li
|
||||
key={modelId}
|
||||
className="flex items-center gap-2 px-3 py-2"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-xs">
|
||||
{modelId}
|
||||
{provider.isDefault &&
|
||||
index === 0 && (
|
||||
<span className="ml-2 rounded bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium uppercase text-amber-600 dark:text-amber-400">
|
||||
{
|
||||
dict.admin
|
||||
.defaultModel
|
||||
}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{result && (
|
||||
<span
|
||||
className={cn(
|
||||
"flex items-center gap-1 text-xs",
|
||||
result.ok
|
||||
? "text-green-600 dark:text-green-400"
|
||||
: "text-destructive",
|
||||
)}
|
||||
>
|
||||
{result.ok ? (
|
||||
<Check
|
||||
className="h-3.5 w-3.5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : (
|
||||
<AlertCircle
|
||||
className="h-3.5 w-3.5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<span className="max-w-48 truncate">
|
||||
{result.message}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
disabled={
|
||||
disabled || testing !== null
|
||||
}
|
||||
onClick={() =>
|
||||
void testModel(modelId)
|
||||
}
|
||||
>
|
||||
{testing === modelId ? (
|
||||
<Loader2
|
||||
className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : (
|
||||
<Zap
|
||||
className="h-3.5 w-3.5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<span className="ml-1">
|
||||
{dict.admin.test}
|
||||
</span>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
disabled={disabled}
|
||||
aria-label={formatMessage(
|
||||
dict.admin.removeModel,
|
||||
{ model: modelId },
|
||||
)}
|
||||
onClick={() =>
|
||||
onUpdate({
|
||||
models: provider.models.filter(
|
||||
(m) => m !== modelId,
|
||||
),
|
||||
})
|
||||
}
|
||||
>
|
||||
<X
|
||||
className="h-3.5 w-3.5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{formatMessage(dict.admin.deleteProviderTitle, {
|
||||
name: provider.name || info.label,
|
||||
})}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{dict.admin.deleteProviderDesc}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
{dict.admin.cancel}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={() => {
|
||||
setDeleteOpen(false)
|
||||
onDelete()
|
||||
}}
|
||||
>
|
||||
{dict.admin.delete}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ModelsSection({
|
||||
providers,
|
||||
envProviders,
|
||||
disabled,
|
||||
password,
|
||||
onChange,
|
||||
}: {
|
||||
providers: AdminProvider[]
|
||||
envProviders: EnvProvider[]
|
||||
disabled: boolean
|
||||
password: string
|
||||
onChange: (providers: AdminProvider[]) => void
|
||||
}) {
|
||||
const dict = useDictionary()
|
||||
const [selectedId, setSelectedId] = useState<string | null>(
|
||||
providers[0]?.id ?? null,
|
||||
)
|
||||
const selected = providers.find((p) => p.id === selectedId)
|
||||
const selectedEnv = envProviders.find((p) => `env:${p.name}` === selectedId)
|
||||
|
||||
const addProvider = (provider: ProviderName) => {
|
||||
const newProvider: AdminProvider = {
|
||||
id: crypto.randomUUID(),
|
||||
provider,
|
||||
models: [],
|
||||
isDefault: providers.length === 0,
|
||||
}
|
||||
onChange([...providers, newProvider])
|
||||
setSelectedId(newProvider.id)
|
||||
}
|
||||
|
||||
const updateProvider = (id: string, patch: Partial<AdminProvider>) => {
|
||||
onChange(
|
||||
providers.map((p) => {
|
||||
if (p.id !== id) {
|
||||
// Only one default at a time
|
||||
return patch.isDefault ? { ...p, isDefault: false } : p
|
||||
}
|
||||
return { ...p, ...patch }
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const deleteProvider = (id: string) => {
|
||||
const next = providers.filter((p) => p.id !== id)
|
||||
onChange(next)
|
||||
setSelectedId(next[0]?.id ?? null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-72 flex-col sm:flex-row">
|
||||
{/* Provider list */}
|
||||
<div className="flex w-full shrink-0 flex-col border-b sm:w-52 sm:border-b-0 sm:border-r">
|
||||
<div className="flex-1 space-y-1 p-2">
|
||||
{providers.length === 0 && envProviders.length === 0 && (
|
||||
<p className="px-2 py-6 text-center text-xs text-muted-foreground">
|
||||
{dict.admin.addProviderHint}
|
||||
</p>
|
||||
)}
|
||||
{envProviders.map((p) => (
|
||||
<button
|
||||
key={`env:${p.name}`}
|
||||
type="button"
|
||||
onClick={() => setSelectedId(`env:${p.name}`)}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-sm hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
selectedId === `env:${p.name}` &&
|
||||
"bg-muted font-medium",
|
||||
)}
|
||||
>
|
||||
<ProviderLogo provider={p.provider} />
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{p.name}
|
||||
</span>
|
||||
<span className="rounded bg-muted px-1 py-0.5 text-[10px] font-medium uppercase text-muted-foreground">
|
||||
{dict.admin.sourceEnv}
|
||||
</span>
|
||||
{p.isDefault && (
|
||||
<Star
|
||||
className="h-3.5 w-3.5 shrink-0 fill-amber-400 text-amber-400"
|
||||
aria-label={dict.admin.defaultProvider}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
{providers.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedId(p.id)}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-sm hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
selectedId === p.id && "bg-muted font-medium",
|
||||
)}
|
||||
>
|
||||
<ProviderLogo provider={p.provider} />
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{p.name || PROVIDER_INFO[p.provider].label}
|
||||
</span>
|
||||
{p.isDefault && (
|
||||
<Star
|
||||
className="h-3.5 w-3.5 shrink-0 fill-amber-400 text-amber-400"
|
||||
aria-label={dict.admin.defaultProvider}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="border-t p-2">
|
||||
<Select
|
||||
disabled={disabled}
|
||||
onValueChange={(v) => addProvider(v as ProviderName)}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<Plus
|
||||
className="mr-1 h-4 w-4 text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{dict.modelConfig.addProvider}
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-72">
|
||||
{(Object.keys(PROVIDER_INFO) as ProviderName[]).map(
|
||||
(p) => {
|
||||
// Global-credential providers already in
|
||||
// the env config can't be added here —
|
||||
// panel credentials would override theirs
|
||||
const envBlocked =
|
||||
FIXED_CRED_PROVIDERS.includes(p) &&
|
||||
envProviders.some(
|
||||
(e) => e.provider === p,
|
||||
)
|
||||
return (
|
||||
<SelectItem
|
||||
key={p}
|
||||
value={p}
|
||||
disabled={envBlocked}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderLogo provider={p} />
|
||||
{PROVIDER_INFO[p].label}
|
||||
{envBlocked && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{
|
||||
dict.admin
|
||||
.managedViaEnv
|
||||
}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
)
|
||||
},
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Detail */}
|
||||
<div className="min-w-0 flex-1 p-4">
|
||||
{selected ? (
|
||||
<ProviderDetail
|
||||
key={selected.id}
|
||||
provider={selected}
|
||||
disabled={disabled}
|
||||
password={password}
|
||||
onUpdate={(patch) => updateProvider(selected.id, patch)}
|
||||
onDelete={() => deleteProvider(selected.id)}
|
||||
/>
|
||||
) : selectedEnv ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
|
||||
<ProviderLogo
|
||||
provider={selectedEnv.provider}
|
||||
className="size-5"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="font-semibold">
|
||||
{selectedEnv.name}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{dict.admin.envReadOnly}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-lg border">
|
||||
<ul className="divide-y">
|
||||
{selectedEnv.models.map((modelId, index) => (
|
||||
<li
|
||||
key={modelId}
|
||||
className="flex items-center gap-2 px-3 py-2"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-xs">
|
||||
{modelId}
|
||||
{selectedEnv.isDefault &&
|
||||
index === 0 && (
|
||||
<span className="ml-2 rounded bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium uppercase text-amber-600 dark:text-amber-400">
|
||||
{
|
||||
dict.admin
|
||||
.defaultModel
|
||||
}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="py-12 text-center text-sm text-muted-foreground">
|
||||
{dict.admin.selectProviderHint}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
610
app/[lang]/admin/page.tsx
Normal file
610
app/[lang]/admin/page.tsx
Normal file
@@ -0,0 +1,610 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
Loader2,
|
||||
LockKeyhole,
|
||||
ShieldCheck,
|
||||
} from "lucide-react"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import {
|
||||
SETTING_GROUPS,
|
||||
SETTINGS_BY_GROUP,
|
||||
} from "@/lib/admin/settings-registry"
|
||||
import { getApiEndpoint } from "@/lib/base-path"
|
||||
import { formatMessage } from "@/lib/i18n/utils"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
type AdminProvider,
|
||||
adminFetch,
|
||||
type EnvProvider,
|
||||
isSecretValue,
|
||||
SESSION_PASSWORD_KEY,
|
||||
type SettingState,
|
||||
type SettingsMap,
|
||||
savedTextOf,
|
||||
} from "./admin-shared"
|
||||
import { ModelsSection } from "./models-section"
|
||||
import { SettingField } from "./setting-field"
|
||||
|
||||
// ── Page ─────────────────────────────────────────────────────────────
|
||||
|
||||
const NAV_GROUP_IDS = ["models", ...SETTING_GROUPS.map((g) => g.id)]
|
||||
|
||||
export default function AdminPage() {
|
||||
const dict = useDictionary()
|
||||
// Localized group title/description, keyed by group id
|
||||
const groupText = (id: string) =>
|
||||
(
|
||||
dict.admin.groups as Record<
|
||||
string,
|
||||
{ title: string; description: string } | undefined
|
||||
>
|
||||
)[id]
|
||||
const navItems = NAV_GROUP_IDS.map((id) => ({
|
||||
id,
|
||||
title:
|
||||
id === "models" ? dict.admin.models : (groupText(id)?.title ?? id),
|
||||
}))
|
||||
const [password, setPassword] = useState("")
|
||||
const [authedPassword, setAuthedPassword] = useState<string | null>(null)
|
||||
const [authError, setAuthError] = useState("")
|
||||
const [authLoading, setAuthLoading] = useState(false)
|
||||
|
||||
const [writable, setWritable] = useState(true)
|
||||
|
||||
// Models section state
|
||||
const [providers, setProviders] = useState<AdminProvider[]>([])
|
||||
const [envProviders, setEnvProviders] = useState<EnvProvider[]>([])
|
||||
const [savedProviders, setSavedProviders] = useState<string>("[]")
|
||||
const providersDirty = JSON.stringify(providers) !== savedProviders
|
||||
|
||||
// General settings state
|
||||
const [settings, setSettings] = useState<SettingsMap>({})
|
||||
const [pending, setPending] = useState<Record<string, string | null>>({})
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [enabledGroups, setEnabledGroups] = useState<Record<string, boolean>>(
|
||||
{},
|
||||
)
|
||||
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saveMessage, setSaveMessage] = useState<{
|
||||
ok: boolean
|
||||
text: string
|
||||
} | null>(null)
|
||||
const [activeGroup, setActiveGroup] = useState("models")
|
||||
|
||||
const dirtyCount = Object.keys(pending).length + (providersDirty ? 1 : 0)
|
||||
|
||||
const applySettingsResponse = useCallback(
|
||||
(data: { writable: boolean; settings: SettingState[] }) => {
|
||||
setWritable(data.writable)
|
||||
const map: SettingsMap = {}
|
||||
for (const s of data.settings) map[s.key] = s
|
||||
setSettings(map)
|
||||
// Seed each toggle once from whether the group has configured
|
||||
// values; don't stomp a user's explicit toggle on later saves
|
||||
setEnabledGroups((prev) => {
|
||||
const next = { ...prev }
|
||||
for (const group of SETTING_GROUPS) {
|
||||
if (!group.toggleable || group.id in next) continue
|
||||
next[group.id] = !!SETTINGS_BY_GROUP.get(group.id)?.some(
|
||||
(d) => map[d.key]?.source !== "default",
|
||||
)
|
||||
}
|
||||
return next
|
||||
})
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const applyProvidersResponse = useCallback(
|
||||
(data: {
|
||||
providers: AdminProvider[]
|
||||
envProviders?: EnvProvider[]
|
||||
}) => {
|
||||
setProviders(data.providers)
|
||||
setSavedProviders(JSON.stringify(data.providers))
|
||||
setEnvProviders(data.envProviders ?? [])
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const login = useCallback(
|
||||
async (pw: string) => {
|
||||
setAuthLoading(true)
|
||||
setAuthError("")
|
||||
try {
|
||||
const [settingsData, providersData] = await Promise.all([
|
||||
adminFetch("/api/admin/settings", pw),
|
||||
adminFetch("/api/admin/providers", pw),
|
||||
])
|
||||
applySettingsResponse(settingsData)
|
||||
applyProvidersResponse(providersData)
|
||||
setAuthedPassword(pw)
|
||||
sessionStorage.setItem(SESSION_PASSWORD_KEY, pw)
|
||||
} catch (err) {
|
||||
setAuthError(
|
||||
err instanceof Error ? err.message : dict.admin.loginFailed,
|
||||
)
|
||||
} finally {
|
||||
setAuthLoading(false)
|
||||
}
|
||||
},
|
||||
[applySettingsResponse, applyProvidersResponse, dict],
|
||||
)
|
||||
|
||||
// Restore session on mount
|
||||
useEffect(() => {
|
||||
const stored = sessionStorage.getItem(SESSION_PASSWORD_KEY)
|
||||
if (stored) void login(stored)
|
||||
}, [login])
|
||||
|
||||
// Warn before leaving with unsaved changes
|
||||
const hasDirty = dirtyCount > 0
|
||||
useEffect(() => {
|
||||
if (!hasDirty) return
|
||||
const handler = (e: BeforeUnloadEvent) => {
|
||||
e.preventDefault()
|
||||
// Some browsers only show the prompt when returnValue is set
|
||||
e.returnValue = ""
|
||||
}
|
||||
window.addEventListener("beforeunload", handler)
|
||||
return () => window.removeEventListener("beforeunload", handler)
|
||||
}, [hasDirty])
|
||||
|
||||
// Highlight the section currently in view in the sidebar
|
||||
useEffect(() => {
|
||||
if (!authedPassword) return
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const visible = entries
|
||||
.filter((e) => e.isIntersecting)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
a.boundingClientRect.top - b.boundingClientRect.top,
|
||||
)
|
||||
if (visible[0]) setActiveGroup(visible[0].target.id)
|
||||
},
|
||||
{ rootMargin: "-10% 0px -50% 0px" },
|
||||
)
|
||||
for (const id of NAV_GROUP_IDS) {
|
||||
const el = document.getElementById(id)
|
||||
if (el) observer.observe(el)
|
||||
}
|
||||
return () => observer.disconnect()
|
||||
}, [authedPassword])
|
||||
|
||||
const handleChange = useCallback(
|
||||
(key: string, value: string | null) => {
|
||||
setSaveMessage(null)
|
||||
setErrors((prev) => {
|
||||
if (!(key in prev)) return prev
|
||||
const next = { ...prev }
|
||||
delete next[key]
|
||||
return next
|
||||
})
|
||||
setPending((prev) => {
|
||||
const state = settings[key]
|
||||
const isRevert =
|
||||
value !== null &&
|
||||
state?.source === "file" &&
|
||||
!isSecretValue(state?.value) &&
|
||||
value === savedTextOf(state)
|
||||
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],
|
||||
)
|
||||
|
||||
// Toggling a group off stages deletion of its saved values so the
|
||||
// feature actually turns off on save; toggling on drops those deletions.
|
||||
const handleGroupToggle = useCallback(
|
||||
(groupId: string, enabled: boolean) => {
|
||||
setSaveMessage(null)
|
||||
setEnabledGroups((prev) => ({ ...prev, [groupId]: enabled }))
|
||||
const keys = (SETTINGS_BY_GROUP.get(groupId) ?? []).map(
|
||||
(d) => d.key,
|
||||
)
|
||||
setPending((prev) => {
|
||||
const next = { ...prev }
|
||||
for (const key of keys) {
|
||||
if (!enabled) {
|
||||
// Stage deletion only for values currently set
|
||||
if (settings[key]?.source !== "default")
|
||||
next[key] = null
|
||||
} else if (next[key] === null) {
|
||||
delete next[key]
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
},
|
||||
[settings],
|
||||
)
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!authedPassword || dirtyCount === 0) return
|
||||
setSaving(true)
|
||||
setSaveMessage(null)
|
||||
setErrors({})
|
||||
try {
|
||||
if (providersDirty) {
|
||||
const data = await adminFetch(
|
||||
"/api/admin/providers",
|
||||
authedPassword,
|
||||
{ method: "PUT", body: JSON.stringify({ providers }) },
|
||||
)
|
||||
applyProvidersResponse(data)
|
||||
}
|
||||
if (Object.keys(pending).length > 0) {
|
||||
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().catch(() => ({}))
|
||||
if (!res.ok) {
|
||||
// Per-field validation errors come back as {errors: {...}}
|
||||
if (data.errors) {
|
||||
setErrors(data.errors)
|
||||
const firstKey = Object.keys(data.errors)[0]
|
||||
document.getElementById(`setting-${firstKey}`)?.focus()
|
||||
throw new Error(dict.admin.invalidSettings)
|
||||
}
|
||||
throw new Error(
|
||||
data.error || `Request failed (${res.status})`,
|
||||
)
|
||||
}
|
||||
applySettingsResponse(data)
|
||||
setPending({})
|
||||
}
|
||||
setSaveMessage({
|
||||
ok: true,
|
||||
text: dict.admin.saved,
|
||||
})
|
||||
setTimeout(() => setSaveMessage(null), 4000)
|
||||
} catch (err) {
|
||||
setSaveMessage({
|
||||
ok: false,
|
||||
text:
|
||||
err instanceof Error ? err.message : dict.admin.saveFailed,
|
||||
})
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [
|
||||
authedPassword,
|
||||
pending,
|
||||
providers,
|
||||
providersDirty,
|
||||
dirtyCount,
|
||||
applySettingsResponse,
|
||||
applyProvidersResponse,
|
||||
dict,
|
||||
])
|
||||
|
||||
// ── 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">
|
||||
{dict.admin.title}
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{dict.admin.loginPrompt}
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="admin-password">
|
||||
{dict.admin.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"
|
||||
/>
|
||||
{dict.admin.signingIn}
|
||||
</>
|
||||
) : (
|
||||
dict.admin.signIn
|
||||
)}
|
||||
</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-6xl 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">
|
||||
{dict.admin.title}
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{dict.admin.precedence}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{!writable && (
|
||||
<div className="border-b bg-amber-500/10">
|
||||
<div className="mx-auto flex max-w-6xl 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"
|
||||
/>
|
||||
{dict.admin.notWritable}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mx-auto flex max-w-6xl gap-8 px-4 py-6">
|
||||
<nav
|
||||
aria-label={dict.admin.settingGroups}
|
||||
className="sticky top-20 hidden h-fit w-44 shrink-0 md:block"
|
||||
>
|
||||
<ul className="space-y-1">
|
||||
{navItems.map((item) => (
|
||||
<li key={item.id}>
|
||||
<a
|
||||
href={`#${item.id}`}
|
||||
aria-current={
|
||||
activeGroup === item.id
|
||||
? "true"
|
||||
: undefined
|
||||
}
|
||||
className={cn(
|
||||
"block rounded-md px-3 py-1.5 text-sm hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
activeGroup === item.id
|
||||
? "bg-muted font-medium text-foreground"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{item.title}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<main className="min-w-0 flex-1 pb-24">
|
||||
{/* Models section */}
|
||||
<section aria-labelledby="models" className="mb-10">
|
||||
<h2
|
||||
id="models"
|
||||
className="scroll-mt-20 text-base font-semibold"
|
||||
>
|
||||
{dict.admin.models}
|
||||
</h2>
|
||||
<p className="mb-3 mt-1 text-sm text-muted-foreground text-pretty">
|
||||
{dict.admin.modelsDescription}
|
||||
</p>
|
||||
<div className="overflow-hidden rounded-lg border bg-card">
|
||||
<ModelsSection
|
||||
providers={providers}
|
||||
envProviders={envProviders}
|
||||
disabled={!writable || saving}
|
||||
password={authedPassword}
|
||||
onChange={(next) => {
|
||||
setSaveMessage(null)
|
||||
setProviders(next)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Registry-driven groups */}
|
||||
{SETTING_GROUPS.map((group) => {
|
||||
const defs = SETTINGS_BY_GROUP.get(group.id) ?? []
|
||||
const groupOff =
|
||||
group.toggleable && !enabledGroups[group.id]
|
||||
const fieldsDisabled = !writable || saving || !!groupOff
|
||||
const gt = groupText(group.id)
|
||||
const title = gt?.title ?? group.title
|
||||
return (
|
||||
<section
|
||||
key={group.id}
|
||||
aria-labelledby={group.id}
|
||||
className="mb-10"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<h2
|
||||
id={group.id}
|
||||
className="scroll-mt-20 text-base font-semibold"
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
{group.toggleable && (
|
||||
<label
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-medium transition-colors motion-reduce:transition-none",
|
||||
enabledGroups[group.id]
|
||||
? "border-primary/30 bg-primary/5 text-primary"
|
||||
: "border-border bg-muted/50 text-muted-foreground hover:border-foreground/30 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{enabledGroups[group.id]
|
||||
? dict.admin.enabled
|
||||
: dict.admin.disabled}
|
||||
<Switch
|
||||
checked={
|
||||
!!enabledGroups[group.id]
|
||||
}
|
||||
disabled={!writable || saving}
|
||||
aria-label={formatMessage(
|
||||
dict.admin.enableGroup,
|
||||
{ group: title },
|
||||
)}
|
||||
onCheckedChange={(checked) =>
|
||||
handleGroupToggle(
|
||||
group.id,
|
||||
checked,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
<p className="mb-3 mt-1 text-sm text-muted-foreground text-pretty">
|
||||
{gt?.description ?? group.description}
|
||||
</p>
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border bg-card px-4",
|
||||
groupOff &&
|
||||
"pointer-events-none opacity-50",
|
||||
)}
|
||||
>
|
||||
{defs.map((def) => (
|
||||
<SettingField
|
||||
key={def.key}
|
||||
def={def}
|
||||
state={settings[def.key]}
|
||||
pendingValue={pending[def.key]}
|
||||
error={errors[def.key]}
|
||||
disabled={fieldsDisabled}
|
||||
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?.text ?? ""}
|
||||
</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-6xl items-center justify-between gap-4 px-4 py-3">
|
||||
<p
|
||||
className={cn(
|
||||
"flex min-w-0 items-center gap-1.5 truncate text-sm",
|
||||
saveMessage?.ok
|
||||
? "text-green-600 dark:text-green-400"
|
||||
: saveMessage
|
||||
? "text-destructive"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{saveMessage?.ok && (
|
||||
<Check
|
||||
className="h-4 w-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
{saveMessage && !saveMessage.ok
|
||||
? saveMessage.text
|
||||
: dirtyCount > 0
|
||||
? dict.admin.unsavedChanges
|
||||
: saveMessage?.text}
|
||||
</p>
|
||||
{dirtyCount > 0 && (
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={saving}
|
||||
onClick={() => {
|
||||
setPending({})
|
||||
setErrors({})
|
||||
setProviders(JSON.parse(savedProviders))
|
||||
}}
|
||||
>
|
||||
{dict.admin.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"
|
||||
/>
|
||||
{dict.admin.saving}
|
||||
</>
|
||||
) : (
|
||||
dict.admin.saveChanges
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
312
app/[lang]/admin/setting-field.tsx
Normal file
312
app/[lang]/admin/setting-field.tsx
Normal file
@@ -0,0 +1,312 @@
|
||||
import { Eye, EyeOff, X } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
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 { useDictionary } from "@/hooks/use-dictionary"
|
||||
import type { SettingDef } from "@/lib/admin/settings-registry"
|
||||
import { formatMessage } from "@/lib/i18n/utils"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
isSecretValue,
|
||||
type SecretValue,
|
||||
type SettingState,
|
||||
savedTextOf,
|
||||
} from "./admin-shared"
|
||||
|
||||
// ── Small shared UI bits ─────────────────────────────────────────────
|
||||
|
||||
export function SourceChip({ source }: { source: "file" | "env" | "default" }) {
|
||||
const dict = useDictionary()
|
||||
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"
|
||||
? dict.admin.sourceSavedTitle
|
||||
: dict.admin.sourceEnvTitle
|
||||
}
|
||||
>
|
||||
{source === "file" ? dict.admin.sourceSaved : dict.admin.sourceEnv}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function RestartBadge() {
|
||||
const dict = useDictionary()
|
||||
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">
|
||||
{dict.admin.restartRequired}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// Secret input: shows masked hint as placeholder, typing replaces.
|
||||
// With keepOnEmpty, clearing the field reverts to the stored value
|
||||
// ("keep") instead of deleting it — explicit deletion is via the X button.
|
||||
export function SecretInput({
|
||||
id,
|
||||
value,
|
||||
disabled,
|
||||
keepOnEmpty,
|
||||
onChange,
|
||||
}: {
|
||||
id: string
|
||||
value: string | SecretValue | undefined
|
||||
disabled?: boolean
|
||||
keepOnEmpty?: boolean
|
||||
onChange: (value: string | SecretValue) => void
|
||||
}) {
|
||||
const dict = useDictionary()
|
||||
const [show, setShow] = useState(false)
|
||||
// The stored marker as it was at mount, to revert to on empty
|
||||
const [original] = useState(value)
|
||||
const hadStored = isSecretValue(original)
|
||||
const text = typeof value === "string" ? value : ""
|
||||
const placeholder = isSecretValue(value)
|
||||
? formatMessage(dict.admin.savedReplace, { hint: value.hint })
|
||||
: dict.admin.notSet
|
||||
const handleText = (t: string) => {
|
||||
if (t === "" && keepOnEmpty && hadStored && original) {
|
||||
onChange(original)
|
||||
} else {
|
||||
onChange(t)
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<Input
|
||||
id={id}
|
||||
type={show ? "text" : "password"}
|
||||
value={text}
|
||||
disabled={disabled}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
placeholder={placeholder}
|
||||
className="h-9 font-mono text-xs"
|
||||
onChange={(e) => handleText(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="shrink-0"
|
||||
aria-label={show ? dict.admin.hideValue : dict.admin.showValue}
|
||||
onClick={() => setShow((s) => !s)}
|
||||
>
|
||||
{show ? (
|
||||
<EyeOff className="h-4 w-4" aria-hidden="true" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" aria-hidden="true" />
|
||||
)}
|
||||
</Button>
|
||||
{keepOnEmpty && (hadStored || text) && !disabled && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="shrink-0"
|
||||
aria-label={dict.admin.removeValue}
|
||||
title={dict.admin.removeValueTitle}
|
||||
onClick={() => onChange("")}
|
||||
>
|
||||
<X className="h-4 w-4" aria-hidden="true" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── General settings field (registry-driven) ─────────────────────────
|
||||
|
||||
export 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 dict = useDictionary()
|
||||
const isDirty = pendingValue !== undefined
|
||||
const source = state?.source ?? "default"
|
||||
const currentValue = isDirty ? (pendingValue ?? "") : savedTextOf(state)
|
||||
const secretState = state && isSecretValue(state.value) ? state.value : null
|
||||
|
||||
// Localized label/description keyed by env var name, falling back to the
|
||||
// registry's English (the registry stays canonical for the server).
|
||||
const t = (
|
||||
dict.admin.settings as Record<
|
||||
string,
|
||||
{ label?: string; description?: string } | undefined
|
||||
>
|
||||
)[def.key]
|
||||
const label = t?.label ?? def.label
|
||||
const description = t?.description ?? def.description
|
||||
|
||||
const inputId = `setting-${def.key}`
|
||||
const errorId = `${inputId}-error`
|
||||
|
||||
let control: React.ReactNode
|
||||
switch (def.type) {
|
||||
case "boolean": {
|
||||
// When unset, reflect the built-in runtime default so the toggle
|
||||
// matches actual behavior (e.g. ALLOW_PRIVATE_URLS defaults on).
|
||||
const effective =
|
||||
currentValue !== "" ? currentValue : (def.default ?? "false")
|
||||
// A saved boolean can be cleared back to its env/default value.
|
||||
const canClear =
|
||||
(isDirty && pendingValue !== null) || source === "file"
|
||||
control = (
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch
|
||||
id={inputId}
|
||||
checked={effective === "true"}
|
||||
disabled={disabled}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange(checked ? "true" : "false")
|
||||
}
|
||||
/>
|
||||
{canClear && !disabled && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs text-muted-foreground"
|
||||
onClick={() => onChange(null)}
|
||||
>
|
||||
{dict.admin.resetToDefault}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
break
|
||||
}
|
||||
case "enum":
|
||||
control = (
|
||||
<Select
|
||||
value={currentValue || undefined}
|
||||
disabled={disabled}
|
||||
onValueChange={onChange}
|
||||
>
|
||||
<SelectTrigger id={inputId} className="w-full max-w-xs">
|
||||
<SelectValue placeholder={dict.admin.notSet} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{def.options?.map((opt) => (
|
||||
<SelectItem key={opt} value={opt}>
|
||||
{opt}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
break
|
||||
case "secret":
|
||||
control = (
|
||||
<div className="w-full max-w-md">
|
||||
<SecretInput
|
||||
id={inputId}
|
||||
value={
|
||||
isDirty
|
||||
? (pendingValue ?? "")
|
||||
: (secretState ?? currentValue)
|
||||
}
|
||||
disabled={disabled}
|
||||
onChange={(v) =>
|
||||
onChange(typeof v === "string" ? v : "")
|
||||
}
|
||||
/>
|
||||
</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 ?? dict.admin.notSet}
|
||||
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 ?? dict.admin.notSet}
|
||||
className="w-full max-w-md"
|
||||
aria-invalid={!!error}
|
||||
aria-describedby={error ? errorId : undefined}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
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">
|
||||
{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">
|
||||
{dict.admin.modified}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{description && (
|
||||
<p className="mb-2 max-w-prose text-xs text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
{control}
|
||||
<p
|
||||
id={errorId}
|
||||
className={cn(
|
||||
"text-xs text-destructive",
|
||||
error ? "mt-1.5" : "sr-only",
|
||||
)}
|
||||
aria-live="polite"
|
||||
>
|
||||
{error ?? ""}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user