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:
Arnaud
2026-07-01 08:10:59 +02:00
parent b2d592afe8
commit cba5d9c3ac
14 changed files with 1197 additions and 0 deletions
@@ -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>
);
}
+82
View File
@@ -0,0 +1,82 @@
"use client";
import { useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Label } from "@/components/ui/label";
interface UserEditorProps {
userId: string;
currentRole: "user" | "moderator" | "admin";
currentTier: "free" | "pro";
}
export function UserEditor({ userId, currentRole, currentTier }: UserEditorProps) {
const [role, setRole] = useState<"user" | "moderator" | "admin">(currentRole);
const [tier, setTier] = useState<"free" | "pro">(currentTier);
const [saving, setSaving] = useState(false);
async function handleSave() {
setSaving(true);
try {
const res = await fetch(`/api/v1/admin/users/${userId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ role, tier }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error((data as { error?: string }).error ?? "Failed to save");
}
toast.success("User updated successfully");
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to save");
} finally {
setSaving(false);
}
}
return (
<div className="flex flex-col gap-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="role-select">Role</Label>
<Select value={role} onValueChange={(v) => setRole(v as typeof role)}>
<SelectTrigger id="role-select">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">User</SelectItem>
<SelectItem value="moderator">Moderator</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="tier-select">Tier</Label>
<Select value={tier} onValueChange={(v) => setTier(v as typeof tier)}>
<SelectTrigger id="tier-select">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="free">Free</SelectItem>
<SelectItem value="pro">Pro</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div>
<Button onClick={handleSave} disabled={saving}>
{saving ? "Saving…" : "Save Changes"}
</Button>
</div>
</div>
);
}