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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user