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,167 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { db, users, recipes, userUsage, eq, desc, count, and, sql } from "@epicure/db";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { UserEditor } from "@/components/admin/user-editor";
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
|
||||
export const metadata: Metadata = { title: "User Detail" };
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
const ROLE_COLORS = {
|
||||
user: "secondary",
|
||||
moderator: "outline",
|
||||
admin: "destructive",
|
||||
} as const;
|
||||
|
||||
const TIER_COLORS = {
|
||||
free: "secondary",
|
||||
pro: "default",
|
||||
} as const;
|
||||
|
||||
export default async function AdminUserDetailPage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
|
||||
const [user] = await db.select().from(users).where(eq(users.id, id)).limit(1);
|
||||
if (!user) notFound();
|
||||
|
||||
const currentMonth = new Date().toISOString().slice(0, 7);
|
||||
|
||||
const [usage] = await db
|
||||
.select()
|
||||
.from(userUsage)
|
||||
.where(and(eq(userUsage.userId, id), eq(userUsage.month, currentMonth)))
|
||||
.limit(1);
|
||||
|
||||
const [recipeCountRow] = await db
|
||||
.select({ count: count() })
|
||||
.from(recipes)
|
||||
.where(eq(recipes.authorId, id));
|
||||
|
||||
const recentRecipes = await db
|
||||
.select({
|
||||
id: recipes.id,
|
||||
title: recipes.title,
|
||||
visibility: recipes.visibility,
|
||||
createdAt: recipes.createdAt,
|
||||
})
|
||||
.from(recipes)
|
||||
.where(eq(recipes.authorId, id))
|
||||
.orderBy(desc(recipes.createdAt))
|
||||
.limit(10);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
href="/admin/users"
|
||||
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to Users
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar className="h-16 w-16">
|
||||
<AvatarImage src={user.avatarUrl ?? ""} />
|
||||
<AvatarFallback className="text-lg">
|
||||
{user.name.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">{user.name}</h1>
|
||||
<p className="text-muted-foreground">{user.email}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Joined {user.createdAt.toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="ml-auto flex gap-2">
|
||||
<Badge variant={ROLE_COLORS[user.role]}>{user.role}</Badge>
|
||||
<Badge variant={TIER_COLORS[user.tier]}>{user.tier}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Edit Role & Tier</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<UserEditor userId={user.id} currentRole={user.role} currentTier={user.tier} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Usage This Month ({currentMonth})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">AI Calls Used</span>
|
||||
<span className="font-medium">{usage?.aiCallsUsed ?? 0}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Total Recipes</span>
|
||||
<span className="font-medium">{recipeCountRow?.count ?? 0}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Storage Used</span>
|
||||
<span className="font-medium">{usage?.storageUsedMb ?? 0} MB</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold mb-3">Recent Recipes</h2>
|
||||
{recentRecipes.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">No recipes yet.</p>
|
||||
) : (
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b bg-muted/50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-medium">Title</th>
|
||||
<th className="px-4 py-3 text-left font-medium">Visibility</th>
|
||||
<th className="px-4 py-3 text-left font-medium">Created</th>
|
||||
<th className="px-4 py-3 text-left font-medium">Link</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{recentRecipes.map((recipe) => (
|
||||
<tr key={recipe.id} className="border-b last:border-0 hover:bg-muted/30">
|
||||
<td className="px-4 py-3 font-medium">{recipe.title}</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant="outline">{recipe.visibility}</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">
|
||||
{recipe.createdAt.toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Link
|
||||
href={`/r/${recipe.id}`}
|
||||
className="text-primary underline-offset-4 hover:underline text-xs"
|
||||
>
|
||||
View
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user