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).
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user