From bd3d8c88f0acee43ed963ba1156b38a14e69a067 Mon Sep 17 00:00:00 2001 From: Arnaud Date: Tue, 14 Jul 2026 15:40:32 +0200 Subject: [PATCH] feat: admin panel improvements (v0.34.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Signups toggle was inverted (on = disabled) — flipped so on means open, matching how every other on/off toggle in the app reads. - Moved AI provider keys and default-model settings from Site Settings to AI Config, so all AI setup lives in one place instead of split across two pages with a cross-link. - Admin overview: added new users/recipes (7d), recipes cooked (7d), pending reports (linked, highlighted if > 0), storage used this month, active webhooks, and API keys issued — previously just 4 lifetime totals. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 9 +++ apps/web/app/admin/ai-config/page.tsx | 43 +++++++--- apps/web/app/admin/page.tsx | 83 ++++++++++++++++---- apps/web/app/admin/settings/page.tsx | 25 +----- apps/web/components/admin/signups-toggle.tsx | 22 +++--- apps/web/lib/changelog.ts | 13 ++- apps/web/package.json | 2 +- package.json | 2 +- 8 files changed, 136 insertions(+), 63 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e03de8a..7ee47b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ All notable changes to Epicure are documented here. This file is mirrored in-app at `/changelog` (and in the admin dashboard) via `apps/web/lib/changelog.ts` — update both together. +## 0.34.0 — 2026-07-14 18:05 + +### Added +- Admin overview now shows growth (new users/recipes this week), engagement (recipes cooked this week), a moderation queue count, storage used, and platform-integration counts (webhooks, API keys) — not just totals. + +### Fixed +- The admin Signups toggle was inverted — switching it "on" used to disable signups. It now reads naturally: on means open. +- AI provider keys and default-model settings have moved from Site Settings to AI Config, alongside the rest of the AI setup. + ## 0.33.1 — 2026-07-14 17:45 ### Fixed diff --git a/apps/web/app/admin/ai-config/page.tsx b/apps/web/app/admin/ai-config/page.tsx index 4eee216..570b9e4 100644 --- a/apps/web/app/admin/ai-config/page.tsx +++ b/apps/web/app/admin/ai-config/page.tsx @@ -2,11 +2,25 @@ 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"; +import { AdminSettingsForm } from "@/components/admin/admin-settings-form"; +import { AdminDefaultModelForm } from "@/components/admin/admin-default-model-form"; export const metadata: Metadata = {}; +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, + }, +]; + 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.)" }; @@ -26,21 +40,17 @@ export default async function AdminAiConfigPage() { ]; return ( -
+

AI Configuration

- Current AI provider settings. Override values in{" "} - - Site Settings - - . + Provider keys, routing, and default models for AI generation. DB values here take precedence over environment variables — clear a value to fall back to the env var.

- Active Provider + Active Provider (fallback, no default set below)
@@ -48,7 +58,7 @@ export default async function AdminAiConfigPage() { {active.description}

- Priority: OpenRouter → OpenAI → Anthropic → Ollama (first configured key wins). + Priority: OpenRouter → OpenAI → Anthropic → Ollama (first configured key wins) — only when a user has no personal preference, no BYOK key, and no default is set for that use case below.

@@ -103,6 +113,21 @@ export default async function AdminAiConfigPage() {
+ + {SETTING_GROUPS.map((group) => ( + + ))} + +
); } diff --git a/apps/web/app/admin/page.tsx b/apps/web/app/admin/page.tsx index 1939c08..f343773 100644 --- a/apps/web/app/admin/page.tsx +++ b/apps/web/app/admin/page.tsx @@ -1,52 +1,101 @@ import type { Metadata } from "next"; import Link from "next/link"; import { db } from "@epicure/db"; -import { users, recipes, userUsage, recipePhotos } from "@epicure/db"; -import { count, eq, sql } from "@epicure/db"; +import { users, recipes, userUsage, recipePhotos, reports, cookingHistory, webhooks, apiKeys } from "@epicure/db"; +import { count, eq, gte, sql } from "@epicure/db"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Users, BookOpen, Sparkles, Image, History } from "lucide-react"; +import { Users, BookOpen, Sparkles, Image, History, UserPlus, ChefHat, Flag, HardDrive, Webhook, KeyRound } from "lucide-react"; import { APP_VERSION, CHANGELOG } from "@/lib/changelog"; export const metadata: Metadata = {}; export default async function AdminPage() { const currentMonth = new Date().toISOString().slice(0, 7); + const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); - const [userCount, recipeCount, proUserCount, aiUsage, imageCount] = await Promise.all([ + const [ + userCount, + recipeCount, + proUserCount, + publicRecipeCount, + aiUsage, + imageCount, + storageUsage, + newUsers7d, + newRecipes7d, + cooked7d, + pendingReports, + webhookCount, + apiKeyCount, + ] = 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({ count: count() }).from(recipes).where(eq(recipes.visibility, "public")).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]), + db + .select({ total: sql`coalesce(sum(${userUsage.storageUsedMb}), 0)` }) + .from(userUsage) + .where(eq(userUsage.month, currentMonth)) + .then((r) => r[0]), + db.select({ count: count() }).from(users).where(gte(users.createdAt, sevenDaysAgo)).then((r) => r[0]), + db.select({ count: count() }).from(recipes).where(gte(recipes.createdAt, sevenDaysAgo)).then((r) => r[0]), + db.select({ count: count() }).from(cookingHistory).where(gte(cookingHistory.cookedAt, sevenDaysAgo)).then((r) => r[0]), + db.select({ count: count() }).from(reports).where(eq(reports.status, "pending")).then((r) => r[0]), + db.select({ count: count() }).from(webhooks).where(eq(webhooks.active, true)).then((r) => r[0]), + db.select({ count: count() }).from(apiKeys).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: "New Users (7d)", value: newUsers7d?.count ?? 0, icon: UserPlus }, + { label: "Total Recipes", value: recipeCount?.count ?? 0, sub: `${publicRecipeCount?.count ?? 0} public`, icon: BookOpen }, + { label: "New Recipes (7d)", value: newRecipes7d?.count ?? 0, icon: BookOpen }, + { label: "Cooked (7d)", value: cooked7d?.count ?? 0, icon: ChefHat }, { label: "AI Calls This Month", value: Number(aiUsage?.total ?? 0), icon: Sparkles }, { label: "Recipe Photos", value: imageCount?.count ?? 0, icon: Image }, + { label: "Storage This Month", value: Number(storageUsage?.total ?? 0), unit: "MB", icon: HardDrive }, + { + label: "Pending Reports", + value: pendingReports?.count ?? 0, + icon: Flag, + href: "/admin/reports", + highlight: (pendingReports?.count ?? 0) > 0, + }, + { label: "Active Webhooks", value: webhookCount?.count ?? 0, icon: Webhook }, + { label: "API Keys Issued", value: apiKeyCount?.count ?? 0, icon: KeyRound }, ]; return (

Overview

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

{sub}

} -
-
- ))} + {stats.map(({ label, value, sub, unit, icon: Icon, href, highlight }) => { + const card = ( + + + {label} + + + +
+ {value.toLocaleString()}{unit ? ` ${unit}` : ""} +
+ {sub &&

{sub}

} +
+
+ ); + return ( +
+ {href ? {card} : card} +
+ ); + })}
diff --git a/apps/web/app/admin/settings/page.tsx b/apps/web/app/admin/settings/page.tsx index c8106d5..50621f4 100644 --- a/apps/web/app/admin/settings/page.tsx +++ b/apps/web/app/admin/settings/page.tsx @@ -1,22 +1,11 @@ import type { Metadata } from "next"; import { getAllSiteSettings, isSignupsDisabled } from "@/lib/site-settings"; import { AdminSettingsForm } from "@/components/admin/admin-settings-form"; -import { AdminDefaultModelForm } from "@/components/admin/admin-default-model-form"; import { SignupsToggle } from "@/components/admin/signups-toggle"; export const metadata: Metadata = {}; 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", @@ -34,7 +23,8 @@ export default async function AdminSettingsPage() {

Site Settings

Override .env values at runtime. DB values take precedence over environment variables. - Clear a value to fall back to the environment variable. + Clear a value to fall back to the environment variable. AI provider keys and model + defaults have moved to AI Config.

@@ -43,17 +33,6 @@ export default async function AdminSettingsPage() { {SETTING_GROUPS.map((group) => ( ))} - - ); } diff --git a/apps/web/components/admin/signups-toggle.tsx b/apps/web/components/admin/signups-toggle.tsx index d76f5a6..aaa4c4a 100644 --- a/apps/web/components/admin/signups-toggle.tsx +++ b/apps/web/components/admin/signups-toggle.tsx @@ -6,23 +6,23 @@ import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; export function SignupsToggle({ initialDisabled }: { initialDisabled: boolean }) { - const [disabled, setDisabled] = useState(initialDisabled); + const [enabled, setEnabled] = useState(!initialDisabled); const [saving, setSaving] = useState(false); async function handleChange(checked: boolean) { setSaving(true); - const previous = disabled; - setDisabled(checked); + const previous = enabled; + setEnabled(checked); try { const res = await fetch("/api/v1/admin/settings", { method: "PUT", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ SIGNUPS_DISABLED: checked ? "true" : null }), + body: JSON.stringify({ SIGNUPS_DISABLED: checked ? null : "true" }), }); if (!res.ok) throw new Error("Save failed"); - toast.success(checked ? "Signups disabled" : "Signups enabled"); + toast.success(checked ? "Signups enabled" : "Signups disabled"); } catch { - setDisabled(previous); + setEnabled(previous); toast.error("Failed to update"); } finally { setSaving(false); @@ -35,16 +35,16 @@ export function SignupsToggle({ initialDisabled }: { initialDisabled: boolean })

Signups

- When disabled, only people with a valid invite link can create an account. + When off, only people with a valid invite link can create an account.

-