cba5d9c3ac
Sticky sidebar with back-to-app link. Overview: user counts, recipe photos, AI calls/month. User management with role/tier editing. Audit log (all admin actions). Storage page with photo breakdown by tier. AI config showing DB vs .env key source. Site settings page to override .env values at runtime (encrypted in DB).
134 lines
4.2 KiB
TypeScript
134 lines
4.2 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { toast } from "sonner";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Eye, EyeOff } from "lucide-react";
|
|
|
|
type SettingMeta = {
|
|
value: string | null;
|
|
isSecret: boolean;
|
|
fromDb: boolean;
|
|
};
|
|
|
|
type Group = {
|
|
title: string;
|
|
description: string;
|
|
keys: readonly string[];
|
|
};
|
|
|
|
export function AdminSettingsForm({
|
|
group,
|
|
settings,
|
|
}: {
|
|
group: Group;
|
|
settings: Record<string, SettingMeta>;
|
|
}) {
|
|
const [values, setValues] = useState<Record<string, string>>(() =>
|
|
Object.fromEntries(
|
|
group.keys.map((k) => [k, settings[k]?.isSecret && settings[k]?.value ? "" : (settings[k]?.value ?? "")])
|
|
)
|
|
);
|
|
const [showSecret, setShowSecret] = useState<Record<string, boolean>>({});
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
async function handleSave() {
|
|
setSaving(true);
|
|
try {
|
|
const res = await fetch("/api/v1/admin/settings", {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(
|
|
Object.fromEntries(group.keys.map((k) => [k, values[k] || null]))
|
|
),
|
|
});
|
|
if (!res.ok) throw new Error("Save failed");
|
|
toast.success("Settings saved");
|
|
} catch {
|
|
toast.error("Failed to save settings");
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<section className="rounded-xl border p-6 space-y-4">
|
|
<div>
|
|
<h2 className="font-semibold text-lg">{group.title}</h2>
|
|
<p className="text-sm text-muted-foreground mt-1">{group.description}</p>
|
|
</div>
|
|
|
|
<div className="space-y-4">
|
|
{group.keys.map((key) => {
|
|
const meta = settings[key];
|
|
const isSecret = meta?.isSecret ?? false;
|
|
const fromDb = meta?.fromDb ?? false;
|
|
const hasValue = !!meta?.value;
|
|
const revealed = showSecret[key];
|
|
|
|
return (
|
|
<div key={key} className="space-y-1.5">
|
|
<div className="flex items-center gap-2">
|
|
<Label className="font-mono text-xs">{key}</Label>
|
|
{hasValue && (
|
|
<Badge variant={fromDb ? "default" : "secondary"} className="text-xs">
|
|
{fromDb ? "DB override" : "from .env"}
|
|
</Badge>
|
|
)}
|
|
{!hasValue && (
|
|
<Badge variant="outline" className="text-xs text-muted-foreground">
|
|
not set
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Input
|
|
type={isSecret && !revealed ? "password" : "text"}
|
|
placeholder={
|
|
isSecret && hasValue
|
|
? "Enter new value to replace (leave blank to keep current)"
|
|
: `Enter ${key}`
|
|
}
|
|
value={values[key] ?? ""}
|
|
onChange={(e) => setValues((prev) => ({ ...prev, [key]: e.target.value }))}
|
|
className="font-mono text-sm"
|
|
/>
|
|
{isSecret && (
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="icon"
|
|
onClick={() => setShowSecret((prev) => ({ ...prev, [key]: !prev[key] }))}
|
|
>
|
|
{revealed ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
|
</Button>
|
|
)}
|
|
{fromDb && (
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => {
|
|
setValues((prev) => ({ ...prev, [key]: "" }));
|
|
}}
|
|
className="text-destructive hover:text-destructive shrink-0"
|
|
>
|
|
Clear
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<Button onClick={() => { void handleSave(); }} disabled={saving} size="sm">
|
|
{saving ? "Saving…" : "Save"}
|
|
</Button>
|
|
</section>
|
|
);
|
|
}
|