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 ( +
+
+
+ + +
+
+ + +
+
+
+ +
+
+ ); +}