Files
Epicure/apps/web/components/admin/admin-settings-form.tsx
T
Arnaud cba5d9c3ac feat(admin): admin panel with overview, users, audit logs, storage, AI config, settings
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).
2026-07-01 08:10:59 +02:00

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>
);
}