diff --git a/apps/web/app/admin/layout.tsx b/apps/web/app/admin/layout.tsx
index 4bdefbc..d4bac18 100644
--- a/apps/web/app/admin/layout.tsx
+++ b/apps/web/app/admin/layout.tsx
@@ -3,13 +3,14 @@ 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 { Shield, Users, BookOpen, Settings, BarChart3, ClipboardList, HardDrive, Bot, ArrowLeft, Gauge } 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/tiers", label: "Tier Limits", icon: Gauge },
{ 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 },
diff --git a/apps/web/app/admin/tiers/page.tsx b/apps/web/app/admin/tiers/page.tsx
new file mode 100644
index 0000000..9730401
--- /dev/null
+++ b/apps/web/app/admin/tiers/page.tsx
@@ -0,0 +1,24 @@
+import type { Metadata } from "next";
+import { db, tierDefinitions } from "@epicure/db";
+import { TierLimitsForm } from "@/components/admin/tier-limits-form";
+
+export const metadata: Metadata = { title: "Tier Limits – Admin" };
+
+export default async function AdminTiersPage() {
+ const tiers = await db.select().from(tierDefinitions);
+
+ return (
+
+
+
Tier Limits
+
+ Change monthly quotas per tier. Takes effect immediately for all users on that tier.
+
+
+
+ {tiers.map((tierDefinition) => (
+
+ ))}
+
+ );
+}
diff --git a/apps/web/app/admin/users/[id]/page.tsx b/apps/web/app/admin/users/[id]/page.tsx
index b11f764..3fbf7ed 100644
--- a/apps/web/app/admin/users/[id]/page.tsx
+++ b/apps/web/app/admin/users/[id]/page.tsx
@@ -6,6 +6,7 @@ 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 { ResetUsageButton } from "@/components/admin/reset-usage-button";
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
@@ -118,6 +119,9 @@ export default async function AdminUserDetailPage({ params }: PageProps) {
Storage Used
{usage?.storageUsedMb ?? 0} MB
+
+
+
diff --git a/apps/web/app/api/v1/admin/tiers/[tier]/route.ts b/apps/web/app/api/v1/admin/tiers/[tier]/route.ts
new file mode 100644
index 0000000..8df0472
--- /dev/null
+++ b/apps/web/app/api/v1/admin/tiers/[tier]/route.ts
@@ -0,0 +1,55 @@
+import { NextRequest, NextResponse } from "next/server";
+import { requireAdmin } from "@/lib/api-auth";
+import { db, tierDefinitions, auditLogs, eq } from "@epicure/db";
+import { randomUUID } from "crypto";
+
+interface RouteContext {
+ params: Promise<{ tier: string }>;
+}
+
+const NUMERIC_FIELDS = ["maxRecipes", "aiCallsPerMonth", "storageMb", "maxPublicRecipes"] as const;
+type NumericField = (typeof NUMERIC_FIELDS)[number];
+
+export async function PATCH(req: NextRequest, { params }: RouteContext) {
+ const { session, response } = await requireAdmin();
+ if (response) return response;
+
+ const { tier } = await params;
+ if (tier !== "free" && tier !== "pro") {
+ return NextResponse.json({ error: "Invalid tier" }, { status: 400 });
+ }
+
+ const body = (await req.json()) as Partial>;
+ const updateData: Partial> = {};
+
+ for (const field of NUMERIC_FIELDS) {
+ const value = body[field];
+ if (value === undefined) continue;
+ if (!Number.isInteger(value) || value < 0) {
+ return NextResponse.json({ error: `Invalid value for ${field}` }, { status: 400 });
+ }
+ updateData[field] = value;
+ }
+
+ const [updated] = await db
+ .update(tierDefinitions)
+ .set(updateData)
+ .where(eq(tierDefinitions.tier, tier))
+ .returning();
+
+ if (!updated) {
+ return NextResponse.json({ error: "Tier not found" }, { status: 404 });
+ }
+
+ await db.insert(auditLogs).values({
+ id: randomUUID(),
+ userId: session!.user.id,
+ action: "admin.tier.update",
+ targetType: "tier_definition",
+ targetId: tier,
+ metadata: JSON.stringify(updateData),
+ createdAt: new Date(),
+ });
+
+ return NextResponse.json({ tierDefinition: updated });
+}
diff --git a/apps/web/app/api/v1/admin/users/[id]/usage/route.ts b/apps/web/app/api/v1/admin/users/[id]/usage/route.ts
new file mode 100644
index 0000000..2461ae9
--- /dev/null
+++ b/apps/web/app/api/v1/admin/users/[id]/usage/route.ts
@@ -0,0 +1,39 @@
+import { NextRequest, NextResponse } from "next/server";
+import { requireAdmin } from "@/lib/api-auth";
+import { db, userUsage, auditLogs, eq, and } from "@epicure/db";
+import { randomUUID } from "crypto";
+
+interface RouteContext {
+ params: Promise<{ id: string }>;
+}
+
+function currentMonth() {
+ const now = new Date();
+ return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
+}
+
+export async function PATCH(req: NextRequest, { params }: RouteContext) {
+ const { session, response } = await requireAdmin();
+ if (response) return response;
+
+ const { id } = await params;
+ const month = currentMonth();
+
+ const [updated] = await db
+ .update(userUsage)
+ .set({ aiCallsUsed: 0, recipeCount: 0, storageUsedMb: 0 })
+ .where(and(eq(userUsage.userId, id), eq(userUsage.month, month)))
+ .returning();
+
+ await db.insert(auditLogs).values({
+ id: randomUUID(),
+ userId: session!.user.id,
+ action: "admin.user.reset_usage",
+ targetType: "user",
+ targetId: id,
+ metadata: JSON.stringify({ month }),
+ createdAt: new Date(),
+ });
+
+ return NextResponse.json({ usage: updated ?? { userId: id, month, aiCallsUsed: 0, recipeCount: 0, storageUsedMb: 0 } });
+}
diff --git a/apps/web/components/admin/reset-usage-button.tsx b/apps/web/components/admin/reset-usage-button.tsx
new file mode 100644
index 0000000..6a9643b
--- /dev/null
+++ b/apps/web/components/admin/reset-usage-button.tsx
@@ -0,0 +1,38 @@
+"use client";
+
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+import { toast } from "sonner";
+import { Button } from "@/components/ui/button";
+
+export function ResetUsageButton({ userId }: { userId: string }) {
+ const router = useRouter();
+ const [resetting, setResetting] = useState(false);
+
+ async function handleReset() {
+ if (!confirm("Reset this user's usage counters for the current month?")) return;
+ setResetting(true);
+ try {
+ const res = await fetch(`/api/v1/admin/users/${userId}/usage`, { method: "PATCH" });
+ if (!res.ok) throw new Error("Reset failed");
+ toast.success("Usage reset");
+ router.refresh();
+ } catch {
+ toast.error("Failed to reset usage");
+ } finally {
+ setResetting(false);
+ }
+ }
+
+ return (
+
+ );
+}
diff --git a/apps/web/components/admin/tier-limits-form.tsx b/apps/web/components/admin/tier-limits-form.tsx
new file mode 100644
index 0000000..8db53ab
--- /dev/null
+++ b/apps/web/components/admin/tier-limits-form.tsx
@@ -0,0 +1,79 @@
+"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";
+
+const FIELDS = [
+ { key: "maxRecipes", label: "Max Recipes" },
+ { key: "aiCallsPerMonth", label: "AI Calls / Month" },
+ { key: "storageMb", label: "Storage (MB)" },
+ { key: "maxPublicRecipes", label: "Max Public Recipes" },
+] as const;
+
+type TierDefinition = {
+ tier: string;
+ maxRecipes: number;
+ aiCallsPerMonth: number;
+ storageMb: number;
+ maxPublicRecipes: number;
+};
+
+export function TierLimitsForm({ tierDefinition }: { tierDefinition: TierDefinition }) {
+ const [values, setValues] = useState>({
+ maxRecipes: tierDefinition.maxRecipes,
+ aiCallsPerMonth: tierDefinition.aiCallsPerMonth,
+ storageMb: tierDefinition.storageMb,
+ maxPublicRecipes: tierDefinition.maxPublicRecipes,
+ });
+ const [saving, setSaving] = useState(false);
+
+ async function handleSave() {
+ setSaving(true);
+ try {
+ const res = await fetch(`/api/v1/admin/tiers/${tierDefinition.tier}`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(values),
+ });
+ if (!res.ok) {
+ const data = await res.json().catch(() => ({}));
+ throw new Error((data as { error?: string }).error ?? "Save failed");
+ }
+ toast.success(`${tierDefinition.tier} tier limits saved`);
+ } catch (err) {
+ toast.error(err instanceof Error ? err.message : "Failed to save");
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ return (
+
+ {tierDefinition.tier}
+
+
+ {FIELDS.map(({ key, label }) => (
+
+
+
+ setValues((prev) => ({ ...prev, [key]: Number(e.target.value) }))
+ }
+ />
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/apps/web/components/recipe/ai-generate-dialog.tsx b/apps/web/components/recipe/ai-generate-dialog.tsx
index eb43f81..164f819 100644
--- a/apps/web/components/recipe/ai-generate-dialog.tsx
+++ b/apps/web/components/recipe/ai-generate-dialog.tsx
@@ -75,7 +75,7 @@ export function AiGenerateDialog({
const tCommon = useTranslations("common");
const tRecipe = useTranslations("recipe");
const router = useRouter();
- const locale = useLocale();
+ const { locale } = useLocale();
const [tab, setTab] = useState("describe");
// describe tab
diff --git a/apps/web/lib/auth/server.ts b/apps/web/lib/auth/server.ts
index 7f7ea8e..07e0dfa 100644
--- a/apps/web/lib/auth/server.ts
+++ b/apps/web/lib/auth/server.ts
@@ -5,6 +5,8 @@ import { db, users, sessions, accounts, verifications, eq, count } from "@epicur
import { sendEmail, verifyEmailHtml, resetPasswordHtml, welcomeHtml } from "@/lib/email";
export const auth = betterAuth({
+ trustedOrigins: [process.env["BETTER_AUTH_URL"] ?? "http://localhost:3000"],
+
database: drizzleAdapter(db, {
provider: "pg",
schema: { user: users, session: sessions, account: accounts, verification: verifications },