From cba5d9c3ac1da8497b3ee526f037aee571655eaf Mon Sep 17 00:00:00 2001 From: Arnaud Date: Wed, 1 Jul 2026 08:10:59 +0200 Subject: [PATCH] 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). --- apps/web/app/admin/ai-config/page.tsx | 108 +++++++++++ apps/web/app/admin/audit-logs/page.tsx | 114 ++++++++++++ apps/web/app/admin/layout.tsx | 61 +++++++ apps/web/app/admin/page.tsx | 51 ++++++ apps/web/app/admin/recipes/page.tsx | 99 +++++++++++ apps/web/app/admin/settings/page.tsx | 43 +++++ apps/web/app/admin/storage/page.tsx | 131 ++++++++++++++ apps/web/app/admin/users/[id]/page.tsx | 167 ++++++++++++++++++ apps/web/app/admin/users/page.tsx | 75 ++++++++ apps/web/app/api/v1/admin/settings/route.ts | 51 ++++++ apps/web/app/api/v1/admin/test-email/route.ts | 26 +++ apps/web/app/api/v1/admin/users/[id]/route.ts | 56 ++++++ .../components/admin/admin-settings-form.tsx | 133 ++++++++++++++ apps/web/components/admin/user-editor.tsx | 82 +++++++++ 14 files changed, 1197 insertions(+) create mode 100644 apps/web/app/admin/ai-config/page.tsx create mode 100644 apps/web/app/admin/audit-logs/page.tsx create mode 100644 apps/web/app/admin/layout.tsx create mode 100644 apps/web/app/admin/page.tsx create mode 100644 apps/web/app/admin/recipes/page.tsx create mode 100644 apps/web/app/admin/settings/page.tsx create mode 100644 apps/web/app/admin/storage/page.tsx create mode 100644 apps/web/app/admin/users/[id]/page.tsx create mode 100644 apps/web/app/admin/users/page.tsx create mode 100644 apps/web/app/api/v1/admin/settings/route.ts create mode 100644 apps/web/app/api/v1/admin/test-email/route.ts create mode 100644 apps/web/app/api/v1/admin/users/[id]/route.ts create mode 100644 apps/web/components/admin/admin-settings-form.tsx create mode 100644 apps/web/components/admin/user-editor.tsx diff --git a/apps/web/app/admin/ai-config/page.tsx b/apps/web/app/admin/ai-config/page.tsx new file mode 100644 index 0000000..6446c4c --- /dev/null +++ b/apps/web/app/admin/ai-config/page.tsx @@ -0,0 +1,108 @@ +import type { Metadata } from "next"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { CheckCircle, XCircle, Database, Cpu } from "lucide-react"; +import Link from "next/link"; +import { getAllSiteSettings } from "@/lib/site-settings"; + +export const metadata: Metadata = { title: "AI Config" }; + +function resolveProvider(settings: Record) { + if (settings["OPENROUTER_API_KEY"]?.value) return { provider: "OpenRouter", description: "Routes to many models via openrouter.ai" }; + if (settings["OPENAI_API_KEY"]?.value) return { provider: "OpenAI", description: "Direct OpenAI API (GPT-4 etc.)" }; + if (settings["ANTHROPIC_API_KEY"]?.value) return { provider: "Anthropic", description: "Direct Anthropic API (Claude models)" }; + const url = settings["OLLAMA_BASE_URL"]?.value || "http://localhost:11434"; + return { provider: "Ollama", description: `Local inference at ${url}` }; +} + +export default async function AdminAiConfigPage() { + const settings = await getAllSiteSettings(); + const active = resolveProvider(settings); + + const keyRows = [ + { key: "OPENROUTER_API_KEY", label: "OPENROUTER_API_KEY" }, + { key: "OPENAI_API_KEY", label: "OPENAI_API_KEY" }, + { key: "ANTHROPIC_API_KEY", label: "ANTHROPIC_API_KEY" }, + ]; + + return ( +
+
+

AI Configuration

+

+ Current AI provider settings. Override values in{" "} + + Site Settings + + . +

+
+ + + + Active Provider + + +
+ {active.provider} + {active.description} +
+

+ Priority: OpenRouter → OpenAI → Anthropic → Ollama (first configured key wins). +

+
+
+ + + + API Keys + + + {keyRows.map(({ key, label }) => { + const meta = settings[key]; + const present = !!meta?.value; + return ( +
+
+ {present ? ( + + ) : ( + + )} + {label} +
+
+ {present && meta?.fromDb && ( + + DB override + + )} + {present && !meta?.fromDb && ( + + from .env + + )} + + {present ? "Configured" : "Not set"} + +
+
+ ); + })} +
+ OLLAMA_BASE_URL + + {settings["OLLAMA_BASE_URL"]?.value || "http://localhost:11434 (default)"} + +
+
+ OPENROUTER_DEFAULT_MODEL + + {settings["OPENROUTER_DEFAULT_MODEL"]?.value || "google/gemini-flash-1.5 (default)"} + +
+
+
+
+ ); +} diff --git a/apps/web/app/admin/audit-logs/page.tsx b/apps/web/app/admin/audit-logs/page.tsx new file mode 100644 index 0000000..f361433 --- /dev/null +++ b/apps/web/app/admin/audit-logs/page.tsx @@ -0,0 +1,114 @@ +import type { Metadata } from "next"; +import { db, auditLogs, users, eq, desc } from "@epicure/db"; + +export const metadata: Metadata = { title: "Audit Logs" }; + +const PAGE_SIZE = 50; + +interface PageProps { + searchParams: Promise<{ page?: string }>; +} + +export default async function AdminAuditLogsPage({ searchParams }: PageProps) { + const { page: pageParam } = await searchParams; + const page = Math.max(1, parseInt(pageParam ?? "1", 10)); + const offset = (page - 1) * PAGE_SIZE; + + const rows = await db + .select({ + id: auditLogs.id, + action: auditLogs.action, + targetType: auditLogs.targetType, + targetId: auditLogs.targetId, + metadata: auditLogs.metadata, + createdAt: auditLogs.createdAt, + actorName: users.name, + actorEmail: users.email, + }) + .from(auditLogs) + .leftJoin(users, eq(auditLogs.userId, users.id)) + .orderBy(desc(auditLogs.createdAt)) + .limit(PAGE_SIZE) + .offset(offset); + + return ( +
+
+

Audit Logs

+

+ Admin actions recorded for accountability. Page {page}. +

+
+ +
+ + + + + + + + + + + + {rows.length === 0 ? ( + + + + ) : ( + rows.map((row) => ( + + + + + + + + )) + )} + +
ActorActionTargetMetadataTimestamp
+ No audit log entries yet. +
+
{row.actorName ?? "System"}
+ {row.actorEmail && ( +
{row.actorEmail}
+ )} +
{row.action} + {row.targetType ? ( + + {row.targetType} + {row.targetId ? `: ${row.targetId.slice(0, 8)}…` : ""} + + ) : ( + "—" + )} + + {row.metadata ?? "—"} + + {row.createdAt.toLocaleString()} +
+
+ +
+ {page > 1 && ( + + Previous + + )} + {rows.length === PAGE_SIZE && ( + + Next + + )} +
+
+ ); +} diff --git a/apps/web/app/admin/layout.tsx b/apps/web/app/admin/layout.tsx new file mode 100644 index 0000000..4bdefbc --- /dev/null +++ b/apps/web/app/admin/layout.tsx @@ -0,0 +1,61 @@ +import { redirect } from "next/navigation"; +import { headers } from "next/headers"; +import { auth } from "@/lib/auth/server"; +import { db, users, eq } from "@epicure/db"; +import Link from "next/link"; +import { Shield, Users, BookOpen, Settings, BarChart3, ClipboardList, HardDrive, Bot, ArrowLeft } from "lucide-react"; +import { cn } from "@/lib/utils"; + +const adminNav = [ + { href: "/admin", label: "Overview", icon: BarChart3 }, + { href: "/admin/users", label: "Users", icon: Users }, + { href: "/admin/recipes", label: "Recipes", icon: BookOpen }, + { href: "/admin/audit-logs", label: "Audit Logs", icon: ClipboardList }, + { href: "/admin/storage", label: "Storage", icon: HardDrive }, + { href: "/admin/ai-config", label: "AI Config", icon: Bot }, + { href: "/admin/settings", label: "Settings", icon: Settings }, +]; + +export default async function AdminLayout({ children }: { children: React.ReactNode }) { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session) redirect("/login"); + + const [dbUser] = await db.select({ role: users.role }).from(users).where(eq(users.id, session.user.id)); + if (dbUser?.role !== "admin") redirect("/recipes"); + + return ( +
+ +
{children}
+
+ ); +} diff --git a/apps/web/app/admin/page.tsx b/apps/web/app/admin/page.tsx new file mode 100644 index 0000000..e90c65f --- /dev/null +++ b/apps/web/app/admin/page.tsx @@ -0,0 +1,51 @@ +import type { Metadata } from "next"; +import { db } from "@epicure/db"; +import { users, recipes, userUsage, recipePhotos } from "@epicure/db"; +import { count, eq, sql } from "@epicure/db"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Users, BookOpen, Sparkles, Image } from "lucide-react"; + +export const metadata: Metadata = { title: "Admin Overview" }; + +export default async function AdminPage() { + const currentMonth = new Date().toISOString().slice(0, 7); + + const [userCount, recipeCount, proUserCount, aiUsage, imageCount] = await Promise.all([ + db.select({ count: count() }).from(users).then((r) => r[0]), + db.select({ count: count() }).from(recipes).then((r) => r[0]), + db.select({ count: count() }).from(users).where(eq(users.tier, "pro")).then((r) => r[0]), + db + .select({ total: sql`coalesce(sum(${userUsage.aiCallsUsed}), 0)` }) + .from(userUsage) + .where(eq(userUsage.month, currentMonth)) + .then((r) => r[0]), + db.select({ count: count() }).from(recipePhotos).then((r) => r[0]), + ]); + + const stats = [ + { label: "Total Users", value: userCount?.count ?? 0, sub: `${proUserCount?.count ?? 0} pro`, icon: Users }, + { label: "Total Recipes", value: recipeCount?.count ?? 0, icon: BookOpen }, + { label: "AI Calls This Month", value: Number(aiUsage?.total ?? 0), icon: Sparkles }, + { label: "Recipe Photos", value: imageCount?.count ?? 0, icon: Image }, + ]; + + return ( +
+

Overview

+
+ {stats.map(({ label, value, sub, icon: Icon }) => ( + + + {label} + + + +
{value.toLocaleString()}
+ {sub &&

{sub}

} +
+
+ ))} +
+
+ ); +} diff --git a/apps/web/app/admin/recipes/page.tsx b/apps/web/app/admin/recipes/page.tsx new file mode 100644 index 0000000..0d1d767 --- /dev/null +++ b/apps/web/app/admin/recipes/page.tsx @@ -0,0 +1,99 @@ +import type { Metadata } from "next"; +import { db, recipes, users, eq, desc } from "@epicure/db"; +import { Badge } from "@/components/ui/badge"; +import Link from "next/link"; + +export const metadata: Metadata = { title: "Recipe Moderation" }; + +const VISIBILITY_COLORS = { + public: "default", + unlisted: "outline", + private: "secondary", +} as const; + +export default async function AdminRecipesPage() { + const publicRecipes = await db + .select({ + id: recipes.id, + title: recipes.title, + visibility: recipes.visibility, + createdAt: recipes.createdAt, + authorName: users.name, + authorId: users.id, + }) + .from(recipes) + .leftJoin(users, eq(recipes.authorId, users.id)) + .where(eq(recipes.visibility, "public")) + .orderBy(desc(recipes.createdAt)) + .limit(200); + + return ( +
+
+

Recipe Moderation

+

+ Public recipes sorted by newest. Review and decide manually. +

+
+ +
+ + + + + + + + + + + + {publicRecipes.length === 0 ? ( + + + + ) : ( + publicRecipes.map((recipe) => ( + + + + + + + + )) + )} + +
TitleAuthorVisibilityCreatedLink
+ No public recipes yet. +
{recipe.title} + {recipe.authorId ? ( + + {recipe.authorName ?? "Unknown"} + + ) : ( + {recipe.authorName ?? "Unknown"} + )} + + + {recipe.visibility} + + + {recipe.createdAt.toLocaleDateString()} + + + View + +
+
+
+ ); +} diff --git a/apps/web/app/admin/settings/page.tsx b/apps/web/app/admin/settings/page.tsx new file mode 100644 index 0000000..d88fcb6 --- /dev/null +++ b/apps/web/app/admin/settings/page.tsx @@ -0,0 +1,43 @@ +import type { Metadata } from "next"; +import { getAllSiteSettings } from "@/lib/site-settings"; +import { AdminSettingsForm } from "@/components/admin/admin-settings-form"; + +export const metadata: Metadata = { title: "Site Settings – Admin" }; + +const SETTING_GROUPS = [ + { + title: "AI Provider Keys", + description: "Override the environment variable API keys at runtime. Values are encrypted at rest.", + keys: ["OPENAI_API_KEY", "ANTHROPIC_API_KEY", "OPENROUTER_API_KEY"] as const, + }, + { + title: "AI Configuration", + description: "Provider routing and model defaults.", + keys: ["OPENROUTER_DEFAULT_MODEL", "OLLAMA_BASE_URL"] as const, + }, + { + title: "Push Notifications (VAPID)", + description: "Keys for web push notifications. Generate with: npx web-push generate-vapid-keys", + keys: ["NEXT_PUBLIC_VAPID_PUBLIC_KEY", "VAPID_PRIVATE_KEY"] as const, + }, +]; + +export default async function AdminSettingsPage() { + const settings = await getAllSiteSettings(); + + return ( +
+
+

Site Settings

+

+ Override .env values at runtime. DB values take precedence over environment variables. + Clear a value to fall back to the environment variable. +

+
+ + {SETTING_GROUPS.map((group) => ( + + ))} +
+ ); +} diff --git a/apps/web/app/admin/storage/page.tsx b/apps/web/app/admin/storage/page.tsx new file mode 100644 index 0000000..2711878 --- /dev/null +++ b/apps/web/app/admin/storage/page.tsx @@ -0,0 +1,131 @@ +import type { Metadata } from "next"; +import { db, users, recipePhotos, recipes, eq, desc, sql, count } from "@epicure/db"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { HardDrive } from "lucide-react"; + +export const metadata: Metadata = { title: "Storage Usage" }; + +export default async function AdminStoragePage() { + const [totalPhotos, recipesWithPhotos, photosByTier, topUsers] = await Promise.all([ + db.select({ count: count() }).from(recipePhotos).then((r) => r[0]), + db + .select({ count: count() }) + .from(recipePhotos) + .where(eq(recipePhotos.isCover, true)) + .then((r) => r[0]), + db + .select({ + tier: users.tier, + photoCount: sql`count(${recipePhotos.id})`, + }) + .from(recipePhotos) + .innerJoin(recipes, eq(recipePhotos.recipeId, recipes.id)) + .innerJoin(users, eq(recipes.authorId, users.id)) + .groupBy(users.tier), + db + .select({ + userId: users.id, + name: users.name, + email: users.email, + tier: users.tier, + photoCount: sql`count(${recipePhotos.id})`, + }) + .from(recipePhotos) + .innerJoin(recipes, eq(recipePhotos.recipeId, recipes.id)) + .innerJoin(users, eq(recipes.authorId, users.id)) + .groupBy(users.id, users.name, users.email, users.tier) + .orderBy(desc(sql`count(${recipePhotos.id})`)) + .limit(10), + ]); + + const freePhotos = Number(photosByTier.find((r) => r.tier === "free")?.photoCount ?? 0); + const proPhotos = Number(photosByTier.find((r) => r.tier === "pro")?.photoCount ?? 0); + + return ( +
+
+

Storage Usage

+

+ Photo storage breakdown by tier and user. +

+
+ +
+ + + Total Photos + + + +
{(totalPhotos?.count ?? 0).toLocaleString()}
+

{recipesWithPhotos?.count ?? 0} cover photos

+
+
+ + + + Free Tier Photos + + + +
{freePhotos.toLocaleString()}
+
+
+ + + + Pro Tier Photos + + + +
{proPhotos.toLocaleString()}
+
+
+
+ +
+

Top 10 Users by Photo Count

+
+ + + + + + + + + + {topUsers.length === 0 ? ( + + + + ) : ( + topUsers.map((row) => ( + + + + + + )) + )} + +
UserTierPhotos
+ No photos uploaded yet. +
+
{row.name ?? "Unknown"}
+ {row.email && ( +
{row.email}
+ )} +
+ + {row.tier ?? "—"} + + + {Number(row.photoCount).toLocaleString()} +
+
+
+
+ ); +} diff --git a/apps/web/app/admin/users/[id]/page.tsx b/apps/web/app/admin/users/[id]/page.tsx new file mode 100644 index 0000000..b11f764 --- /dev/null +++ b/apps/web/app/admin/users/[id]/page.tsx @@ -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 ( +
+
+ + + Back to Users + +
+ +
+ + + + {user.name.slice(0, 2).toUpperCase()} + + +
+

{user.name}

+

{user.email}

+

+ Joined {user.createdAt.toLocaleDateString()} +

+
+
+ {user.role} + {user.tier} +
+
+ + + +
+ + + Edit Role & Tier + + + + + + + + + Usage This Month ({currentMonth}) + + +
+ AI Calls Used + {usage?.aiCallsUsed ?? 0} +
+
+ Total Recipes + {recipeCountRow?.count ?? 0} +
+
+ Storage Used + {usage?.storageUsedMb ?? 0} MB +
+
+
+
+ +
+

Recent Recipes

+ {recentRecipes.length === 0 ? ( +

No recipes yet.

+ ) : ( +
+ + + + + + + + + + + {recentRecipes.map((recipe) => ( + + + + + + + ))} + +
TitleVisibilityCreatedLink
{recipe.title} + {recipe.visibility} + + {recipe.createdAt.toLocaleDateString()} + + + View + +
+
+ )} +
+
+ ); +} diff --git a/apps/web/app/admin/users/page.tsx b/apps/web/app/admin/users/page.tsx new file mode 100644 index 0000000..3fcb2e7 --- /dev/null +++ b/apps/web/app/admin/users/page.tsx @@ -0,0 +1,75 @@ +import type { Metadata } from "next"; +import { db } from "@epicure/db"; +import { users } from "@epicure/db"; +import { desc } from "@epicure/db"; +import { Badge } from "@/components/ui/badge"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import Link from "next/link"; + +export const metadata: Metadata = { title: "User Management" }; + +const ROLE_COLORS = { + user: "secondary", + moderator: "outline", + admin: "destructive", +} as const; + +const TIER_COLORS = { + free: "secondary", + pro: "default", +} as const; + +export default async function AdminUsersPage() { + const allUsers = await db + .select() + .from(users) + .orderBy(desc(users.createdAt)) + .limit(100); + + return ( +
+

Users

+
+ + + + + + + + + + + {allUsers.map((user) => ( + + + + + + + ))} + +
UserRoleTierJoined
+ + + + + {user.name.slice(0, 2).toUpperCase()} + + +
+
{user.name}
+
{user.email}
+
+ +
+ {user.role} + + {user.tier} + + {user.createdAt.toLocaleDateString()} +
+
+
+ ); +} diff --git a/apps/web/app/api/v1/admin/settings/route.ts b/apps/web/app/api/v1/admin/settings/route.ts new file mode 100644 index 0000000..772d4de --- /dev/null +++ b/apps/web/app/api/v1/admin/settings/route.ts @@ -0,0 +1,51 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { headers } from "next/headers"; +import { auth } from "@/lib/auth/server"; +import { db, users, auditLogs, eq } from "@epicure/db"; +import { setSiteSetting, type SiteSettingKey } from "@/lib/site-settings"; +import { randomUUID } from "crypto"; + +const ALLOWED_KEYS: SiteSettingKey[] = [ + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "OPENROUTER_API_KEY", + "OPENROUTER_DEFAULT_MODEL", + "OLLAMA_BASE_URL", + "NEXT_PUBLIC_VAPID_PUBLIC_KEY", + "VAPID_PRIVATE_KEY", +]; + +async function requireAdmin() { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session) return null; + const [dbUser] = await db.select({ role: users.role }).from(users).where(eq(users.id, session.user.id)); + if (dbUser?.role !== "admin") return null; + return session; +} + +export async function PUT(req: NextRequest) { + const session = await requireAdmin(); + if (!session) return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + + const body = (await req.json()) as Record; + + const changed: string[] = []; + for (const [key, value] of Object.entries(body)) { + if (!ALLOWED_KEYS.includes(key as SiteSettingKey)) continue; + await setSiteSetting(key as SiteSettingKey, value, session.user.id); + changed.push(key); + } + + if (changed.length > 0) { + await db.insert(auditLogs).values({ + id: randomUUID(), + userId: session.user.id, + action: "admin.settings.update", + targetType: "site_settings", + metadata: JSON.stringify({ keys: changed }), + createdAt: new Date(), + }); + } + + return NextResponse.json({ ok: true }); +} diff --git a/apps/web/app/api/v1/admin/test-email/route.ts b/apps/web/app/api/v1/admin/test-email/route.ts new file mode 100644 index 0000000..858e14f --- /dev/null +++ b/apps/web/app/api/v1/admin/test-email/route.ts @@ -0,0 +1,26 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireAdmin } from "@/lib/api-auth"; +import { sendEmail, verifyEmailHtml } from "@/lib/email"; + +export async function POST(req: NextRequest) { + const { response } = await requireAdmin(); + if (response) return response; + + const { to } = await req.json() as { to: string }; + if (!to) return NextResponse.json({ error: "Missing 'to'" }, { status: 400 }); + + try { + await sendEmail({ + to, + subject: "Epicure — test email", + html: verifyEmailHtml(`${process.env["BETTER_AUTH_URL"] ?? "http://localhost:3001"}/verify-email?token=test`), + }); + return NextResponse.json({ + ok: true, + smtp_host: process.env["SMTP_HOST"] ?? null, + smtp_user: process.env["SMTP_USER"] ?? null, + }); + } catch (err) { + return NextResponse.json({ error: String(err) }, { status: 500 }); + } +} diff --git a/apps/web/app/api/v1/admin/users/[id]/route.ts b/apps/web/app/api/v1/admin/users/[id]/route.ts new file mode 100644 index 0000000..b0be0df --- /dev/null +++ b/apps/web/app/api/v1/admin/users/[id]/route.ts @@ -0,0 +1,56 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireAdmin } from "@/lib/api-auth"; +import { db, users, auditLogs, eq } from "@epicure/db"; +import { randomUUID } from "crypto"; + +interface RouteContext { + params: Promise<{ id: string }>; +} + +export async function PATCH(req: NextRequest, { params }: RouteContext) { + const { session, response } = await requireAdmin(); + if (response) return response; + + const { id } = await params; + const body = await req.json() as { role?: string; tier?: string }; + const { role, tier } = body; + + const validRoles = ["user", "moderator", "admin"] as const; + const validTiers = ["free", "pro"] as const; + + if (role !== undefined && !validRoles.includes(role as typeof validRoles[number])) { + return NextResponse.json({ error: "Invalid role" }, { status: 400 }); + } + if (tier !== undefined && !validTiers.includes(tier as typeof validTiers[number])) { + return NextResponse.json({ error: "Invalid tier" }, { status: 400 }); + } + + const updateData: Partial<{ role: "user" | "moderator" | "admin"; tier: "free" | "pro"; updatedAt: Date }> = { + updatedAt: new Date(), + }; + if (role) updateData.role = role as "user" | "moderator" | "admin"; + if (tier) updateData.tier = tier as "free" | "pro"; + + const [updated] = await db + .update(users) + .set(updateData) + .where(eq(users.id, id)) + .returning({ id: users.id, role: users.role, tier: users.tier }); + + if (!updated) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + + // Write audit log + await db.insert(auditLogs).values({ + id: randomUUID(), + userId: session!.user.id, + action: "admin.user.update", + targetType: "user", + targetId: id, + metadata: JSON.stringify({ role, tier }), + createdAt: new Date(), + }); + + return NextResponse.json({ user: updated }); +} diff --git a/apps/web/components/admin/admin-settings-form.tsx b/apps/web/components/admin/admin-settings-form.tsx new file mode 100644 index 0000000..2e429fa --- /dev/null +++ b/apps/web/components/admin/admin-settings-form.tsx @@ -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; +}) { + const [values, setValues] = useState>(() => + Object.fromEntries( + group.keys.map((k) => [k, settings[k]?.isSecret && settings[k]?.value ? "" : (settings[k]?.value ?? "")]) + ) + ); + const [showSecret, setShowSecret] = useState>({}); + 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 ( +
+
+

{group.title}

+

{group.description}

+
+ +
+ {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 ( +
+
+ + {hasValue && ( + + {fromDb ? "DB override" : "from .env"} + + )} + {!hasValue && ( + + not set + + )} +
+
+ setValues((prev) => ({ ...prev, [key]: e.target.value }))} + className="font-mono text-sm" + /> + {isSecret && ( + + )} + {fromDb && ( + + )} +
+
+ ); + })} +
+ + +
+ ); +} diff --git a/apps/web/components/admin/user-editor.tsx b/apps/web/components/admin/user-editor.tsx new file mode 100644 index 0000000..3561090 --- /dev/null +++ b/apps/web/components/admin/user-editor.tsx @@ -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 ( +
+
+
+ + +
+
+ + +
+
+
+ +
+
+ ); +}