diff --git a/CHANGELOG.md b/CHANGELOG.md index b3b0c2a..f5c27c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ 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.61.0 — 2026-07-20 23:15 + +### Added +- Settings → Notifications now has an email toggle for every category alongside the existing push toggle, plus a Weekly Digest toggle to opt out of the weekly email summary entirely. +- Admin → Webhooks: site-wide ops webhooks (new signups, support tickets, reports filed) — separate from the existing per-user webhooks, for Slack/Discord-style ops alerting. +- Settings → Features: hide Nutrition, Pantry, Meal Plan, Shopping Lists, Collections, or Messages from your own navigation. Cosmetic only — hidden pages stay reachable by direct link. + ## 0.60.0 — 2026-07-20 22:15 ### Security diff --git a/apps/web/app/(app)/settings/features/page.tsx b/apps/web/app/(app)/settings/features/page.tsx new file mode 100644 index 0000000..9514970 --- /dev/null +++ b/apps/web/app/(app)/settings/features/page.tsx @@ -0,0 +1,24 @@ +import type { Metadata } from "next"; +import { headers } from "next/headers"; +import { auth } from "@/lib/auth/server"; +import { FeatureTogglesForm } from "@/components/settings/feature-toggles-form"; +import { getMessages } from "@/lib/i18n/server"; + +export const metadata: Metadata = {}; + +export default async function FeaturesSettingsPage() { + const session = await auth.api.getSession({ headers: await headers() }); + const m = getMessages((session?.user as { locale?: string })?.locale); + + return ( +
+
+

{m.settingsForm.featureToggles.title}

+

+ {m.settingsForm.featureToggles.description} +

+
+ +
+ ); +} diff --git a/apps/web/app/admin/layout.tsx b/apps/web/app/admin/layout.tsx index 4a6d36f..ce06328 100644 --- a/apps/web/app/admin/layout.tsx +++ b/apps/web/app/admin/layout.tsx @@ -3,7 +3,7 @@ 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, Gauge, Mail, Flag, History, LifeBuoy, TrendingUp } from "lucide-react"; +import { Shield, Users, BookOpen, Settings, BarChart3, ClipboardList, HardDrive, Bot, ArrowLeft, Gauge, Mail, Flag, History, LifeBuoy, TrendingUp, Webhook } from "lucide-react"; import { cn } from "@/lib/utils"; const adminNav = [ @@ -15,6 +15,7 @@ const adminNav = [ { href: "/admin/reports", label: "Reports", icon: Flag }, { href: "/admin/support", label: "Support", icon: LifeBuoy }, { href: "/admin/tiers", label: "Tier Limits", icon: Gauge }, + { href: "/admin/webhooks", label: "Webhooks", icon: Webhook }, { 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/webhooks/page.tsx b/apps/web/app/admin/webhooks/page.tsx new file mode 100644 index 0000000..0d6d9dc --- /dev/null +++ b/apps/web/app/admin/webhooks/page.tsx @@ -0,0 +1,31 @@ +import type { Metadata } from "next"; +import { db, adminWebhooks } from "@epicure/db"; +import { AdminWebhooksManager } from "@/components/admin/admin-webhooks-manager"; + +export const metadata: Metadata = {}; + +export default async function AdminWebhooksPage() { + const rows = await db + .select({ + id: adminWebhooks.id, + url: adminWebhooks.url, + events: adminWebhooks.events, + active: adminWebhooks.active, + createdAt: adminWebhooks.createdAt, + }) + .from(adminWebhooks); + + const webhooks = rows.map((w) => ({ ...w, createdAt: w.createdAt.toISOString() })); + + return ( +
+
+

Webhooks

+

+ Site-wide ops events (new signups, support tickets, reports) — deliver to any HTTP endpoint (Slack/Discord incoming webhook, internal alerting, etc). Fires regardless of who's involved, unlike the per-user webhooks under Settings. +

+
+ +
+ ); +} diff --git a/apps/web/app/api/internal/cron/weekly-digest/route.ts b/apps/web/app/api/internal/cron/weekly-digest/route.ts index f5b8116..15ad868 100644 --- a/apps/web/app/api/internal/cron/weekly-digest/route.ts +++ b/apps/web/app/api/internal/cron/weekly-digest/route.ts @@ -8,6 +8,7 @@ import { ratings, userFollows, favorites, + userNotificationPrefs, eq, and, gte, @@ -21,10 +22,11 @@ import { sendEmail, weeklyDigestHtml } from "@/lib/email"; // schedule (see compose.prod.yml). Not part of the public API surface; // protected by a shared secret rather than user auth. // -// Computes, for every user: new followers / new comments / new ratings on -// their recipes in the last 7 days, plus a site-wide top-3 trending list, and -// emails a summary. Sends to all users (all users have a non-null email) — -// there's no per-user opt-out preference yet; out of scope for this pass. +// Computes, for every opted-in user: new followers / new comments / new +// ratings on their recipes in the last 7 days, plus a site-wide top-3 +// trending list, and emails a summary. Excludes users who turned off +// "Weekly digest" in Settings → Notifications (userNotificationPrefs.weeklyDigestEmail, +// default true — no row means opted in). const CHUNK_SIZE = 20; @@ -56,8 +58,12 @@ export async function POST(req: NextRequest) { const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); const baseUrl = process.env["BETTER_AUTH_URL"] ?? "http://localhost:3000"; - const [allUsers, followerRows, commentRows, ratingRows, trending] = await Promise.all([ + const [allUsers, optedOutRows, followerRows, commentRows, ratingRows, trending] = await Promise.all([ db.select({ id: users.id, email: users.email }).from(users), + db + .select({ userId: userNotificationPrefs.userId }) + .from(userNotificationPrefs) + .where(eq(userNotificationPrefs.weeklyDigestEmail, false)), db .select({ userId: userFollows.followingId, n: count() }) .from(userFollows) @@ -92,6 +98,9 @@ export async function POST(req: NextRequest) { .limit(3), ]); + const optedOut = new Set(optedOutRows.map((r) => r.userId)); + const recipients = allUsers.filter((u) => !optedOut.has(u.id)); + const followerMap = new Map(followerRows.map((r) => [r.userId, r.n])); const commentMap = new Map(commentRows.map((r) => [r.userId, r.n])); const ratingMap = new Map(ratingRows.map((r) => [r.userId, r.n])); @@ -100,7 +109,7 @@ export async function POST(req: NextRequest) { let sent = 0; let failed = 0; - for (const batch of chunk(allUsers, CHUNK_SIZE)) { + for (const batch of chunk(recipients, CHUNK_SIZE)) { const results = await Promise.allSettled( batch.map((user) => { const newFollowers = followerMap.get(user.id) ?? 0; @@ -127,5 +136,5 @@ export async function POST(req: NextRequest) { } } - return NextResponse.json({ ok: true, totalUsers: allUsers.length, sent, failed }); + return NextResponse.json({ ok: true, totalUsers: allUsers.length, optedOut: optedOut.size, sent, failed }); } diff --git a/apps/web/app/api/v1/admin/webhooks/[id]/deliveries/route.ts b/apps/web/app/api/v1/admin/webhooks/[id]/deliveries/route.ts new file mode 100644 index 0000000..c6383a5 --- /dev/null +++ b/apps/web/app/api/v1/admin/webhooks/[id]/deliveries/route.ts @@ -0,0 +1,24 @@ +import { NextRequest, NextResponse } from "next/server"; +import { db, adminWebhooks, adminWebhookDeliveries, eq, desc } from "@epicure/db"; +import { requireAdmin } from "@/lib/api-auth"; + +type Params = { params: Promise<{ id: string }> }; + +export async function GET(_req: NextRequest, { params }: Params) { + const { response } = await requireAdmin(); + if (response) return response; + + const { id } = await params; + + const hook = await db.query.adminWebhooks.findFirst({ where: eq(adminWebhooks.id, id), columns: { id: true } }); + if (!hook) return NextResponse.json({ error: "Not found" }, { status: 404 }); + + const deliveries = await db + .select() + .from(adminWebhookDeliveries) + .where(eq(adminWebhookDeliveries.webhookId, id)) + .orderBy(desc(adminWebhookDeliveries.createdAt)) + .limit(20); + + return NextResponse.json(deliveries); +} diff --git a/apps/web/app/api/v1/admin/webhooks/[id]/redeliver/route.ts b/apps/web/app/api/v1/admin/webhooks/[id]/redeliver/route.ts new file mode 100644 index 0000000..cdc817c --- /dev/null +++ b/apps/web/app/api/v1/admin/webhooks/[id]/redeliver/route.ts @@ -0,0 +1,31 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { db, adminWebhooks, adminWebhookDeliveries, eq, and } from "@epicure/db"; +import { requireAdmin } from "@/lib/api-auth"; +import { dispatchAdminWebhook, type AdminWebhookEvent } from "@/lib/admin-webhooks"; + +const Schema = z.object({ deliveryId: z.string().uuid() }); + +type Params = { params: Promise<{ id: string }> }; + +export async function POST(req: NextRequest, { params }: Params) { + const { response } = await requireAdmin(); + if (response) return response; + + const { id } = await params; + + const hook = await db.query.adminWebhooks.findFirst({ where: eq(adminWebhooks.id, id), columns: { id: true } }); + if (!hook) return NextResponse.json({ error: "Not found" }, { status: 404 }); + + const body = Schema.safeParse(await req.json()); + if (!body.success) return NextResponse.json({ error: "Validation error", issues: body.error.issues }, { status: 400 }); + + const delivery = await db.query.adminWebhookDeliveries.findFirst({ + where: and(eq(adminWebhookDeliveries.id, body.data.deliveryId), eq(adminWebhookDeliveries.webhookId, id)), + }); + if (!delivery) return NextResponse.json({ error: "Delivery not found" }, { status: 404 }); + + void dispatchAdminWebhook(delivery.event as AdminWebhookEvent, (delivery.payload ?? {}) as object); + + return NextResponse.json({ ok: true }); +} diff --git a/apps/web/app/api/v1/admin/webhooks/[id]/route.ts b/apps/web/app/api/v1/admin/webhooks/[id]/route.ts new file mode 100644 index 0000000..f42f61c --- /dev/null +++ b/apps/web/app/api/v1/admin/webhooks/[id]/route.ts @@ -0,0 +1,73 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { db, adminWebhooks, eq } from "@epicure/db"; +import { requireAdmin } from "@/lib/api-auth"; +import { validateWebhookUrl } from "@/lib/validate-webhook-url"; +import { ADMIN_WEBHOOK_EVENTS } from "@/lib/admin-webhooks"; + +const UpdateWebhookBody = z.object({ + url: z.string().min(1).max(2048).optional(), + events: z.array(z.enum(ADMIN_WEBHOOK_EVENTS)).optional(), + active: z.boolean().optional(), +}); + +type Params = { params: Promise<{ id: string }> }; + +export async function DELETE(_req: NextRequest, { params }: Params) { + const { response } = await requireAdmin(); + if (response) return response; + + const { id } = await params; + + const existing = await db.select({ id: adminWebhooks.id }).from(adminWebhooks).where(eq(adminWebhooks.id, id)).limit(1); + if (existing.length === 0) return NextResponse.json({ error: "Not found" }, { status: 404 }); + + await db.delete(adminWebhooks).where(eq(adminWebhooks.id, id)); + return new NextResponse(null, { status: 204 }); +} + +export async function PATCH(req: NextRequest, { params }: Params) { + const { response } = await requireAdmin(); + if (response) return response; + + const { id } = await params; + + const existing = await db.select({ id: adminWebhooks.id }).from(adminWebhooks).where(eq(adminWebhooks.id, id)).limit(1); + if (existing.length === 0) return NextResponse.json({ error: "Not found" }, { status: 404 }); + + const body = await req.json() as unknown; + const parsed = UpdateWebhookBody.safeParse(body); + if (!parsed.success) { + return NextResponse.json({ error: "Validation error", issues: parsed.error.issues }, { status: 400 }); + } + + if (parsed.data.url) { + const ssrfError = await validateWebhookUrl(parsed.data.url); + if (ssrfError) return NextResponse.json({ error: ssrfError }, { status: 400 }); + } + + const updates: Partial<{ url: string; events: string[]; active: boolean }> = {}; + if (parsed.data.url !== undefined) updates.url = parsed.data.url; + if (parsed.data.events !== undefined) updates.events = parsed.data.events; + if (parsed.data.active !== undefined) updates.active = parsed.data.active; + + if (Object.keys(updates).length === 0) { + return NextResponse.json({ error: "No fields to update" }, { status: 400 }); + } + + await db.update(adminWebhooks).set(updates).where(eq(adminWebhooks.id, id)); + + const updated = await db + .select({ + id: adminWebhooks.id, + url: adminWebhooks.url, + events: adminWebhooks.events, + active: adminWebhooks.active, + createdAt: adminWebhooks.createdAt, + }) + .from(adminWebhooks) + .where(eq(adminWebhooks.id, id)) + .limit(1); + + return NextResponse.json(updated[0]); +} diff --git a/apps/web/app/api/v1/admin/webhooks/route.ts b/apps/web/app/api/v1/admin/webhooks/route.ts new file mode 100644 index 0000000..6971831 --- /dev/null +++ b/apps/web/app/api/v1/admin/webhooks/route.ts @@ -0,0 +1,65 @@ +import { NextRequest, NextResponse } from "next/server"; +import crypto from "node:crypto"; +import { z } from "zod"; +import { db, adminWebhooks } from "@epicure/db"; +import { requireAdmin } from "@/lib/api-auth"; +import { validateWebhookUrl } from "@/lib/validate-webhook-url"; +import { ADMIN_WEBHOOK_EVENTS } from "@/lib/admin-webhooks"; +import { encrypt } from "@/lib/encrypt"; + +const CreateWebhookBody = z.object({ + url: z.string().min(1).max(2048), + events: z.array(z.enum(ADMIN_WEBHOOK_EVENTS)).default([]), +}); + +export async function GET() { + const { response } = await requireAdmin(); + if (response) return response; + + const rows = await db + .select({ + id: adminWebhooks.id, + url: adminWebhooks.url, + events: adminWebhooks.events, + active: adminWebhooks.active, + createdAt: adminWebhooks.createdAt, + }) + .from(adminWebhooks); + + return NextResponse.json(rows); +} + +export async function POST(req: NextRequest) { + const { session, response } = await requireAdmin(); + if (response) return response; + + const body = await req.json() as unknown; + const parsed = CreateWebhookBody.safeParse(body); + if (!parsed.success) { + return NextResponse.json({ error: "Validation error", issues: parsed.error.issues }, { status: 400 }); + } + + const ssrfError = await validateWebhookUrl(parsed.data.url); + if (ssrfError) { + return NextResponse.json({ error: ssrfError }, { status: 400 }); + } + + const secret = crypto.randomBytes(32).toString("hex"); + const id = crypto.randomUUID(); + const now = new Date(); + + await db.insert(adminWebhooks).values({ + id, + createdById: session!.user.id, + url: parsed.data.url, + events: parsed.data.events, + secret: encrypt(secret), + active: true, + createdAt: now, + }); + + return NextResponse.json( + { id, url: parsed.data.url, events: parsed.data.events, secret, active: true, createdAt: now.toISOString() }, + { status: 201 } + ); +} diff --git a/apps/web/app/api/v1/reports/route.ts b/apps/web/app/api/v1/reports/route.ts index 9711cc8..3774305 100644 --- a/apps/web/app/api/v1/reports/route.ts +++ b/apps/web/app/api/v1/reports/route.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { db, reports, comments, recipes, users, eq } from "@epicure/db"; import { requireSession } from "@/lib/api-auth"; import { applyRateLimit } from "@/lib/rate-limit"; +import { dispatchAdminWebhook } from "@/lib/admin-webhooks"; import { randomUUID } from "crypto"; const Schema = z.object({ @@ -33,13 +34,16 @@ export async function POST(req: NextRequest) { if (!exists) return NextResponse.json({ error: "Target not found" }, { status: 404 }); + const id = randomUUID(); await db.insert(reports).values({ - id: randomUUID(), + id, reporterId: session!.user.id, targetType, targetId, reason, }); + void dispatchAdminWebhook("report.filed", { id, reporterId: session!.user.id, targetType, targetId, reason }); + return NextResponse.json({ ok: true }, { status: 201 }); } diff --git a/apps/web/app/api/v1/support/route.ts b/apps/web/app/api/v1/support/route.ts index a75b006..aa01b3f 100644 --- a/apps/web/app/api/v1/support/route.ts +++ b/apps/web/app/api/v1/support/route.ts @@ -6,6 +6,7 @@ import { requireSession } from "@/lib/api-auth"; import { sendEmail, supportTicketReceivedHtml } from "@/lib/email"; import { createGiteaIssue, buildGiteaIssueBody } from "@/lib/gitea"; import { getPublicUrl, isOwnedSupportAttachmentKey } from "@/lib/storage"; +import { dispatchAdminWebhook } from "@/lib/admin-webhooks"; const MAX_ATTACHMENTS = 5; @@ -84,6 +85,8 @@ export async function POST(req: NextRequest) { updatedAt: now, }); + void dispatchAdminWebhook("support_ticket.created", { id, userId: session!.user.id, type, title }); + if (attachments.length > 0) { await db.insert(supportTicketAttachments).values( attachments.map((a) => ({ diff --git a/apps/web/app/api/v1/users/me/feature-prefs/route.ts b/apps/web/app/api/v1/users/me/feature-prefs/route.ts new file mode 100644 index 0000000..2e0e123 --- /dev/null +++ b/apps/web/app/api/v1/users/me/feature-prefs/route.ts @@ -0,0 +1,45 @@ +import { NextRequest, NextResponse } from "next/server"; +import { db, userFeaturePrefs } from "@epicure/db"; +import { requireSession } from "@/lib/api-auth"; +import { getFeaturePrefs } from "@/lib/feature-prefs"; +import { z } from "zod"; + +const PutSchema = z.object({ + nutrition: z.boolean().optional(), + pantry: z.boolean().optional(), + mealPlan: z.boolean().optional(), + shoppingLists: z.boolean().optional(), + collections: z.boolean().optional(), + messages: z.boolean().optional(), +}); + +export async function GET() { + const { session, response } = await requireSession(); + if (response) return response; + + const prefs = await getFeaturePrefs(session!.user.id); + return NextResponse.json({ data: prefs }); +} + +export async function PUT(req: NextRequest) { + const { session, response } = await requireSession(); + if (response) return response; + + const parsed = PutSchema.safeParse(await req.json()); + if (!parsed.success) { + return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }); + } + + const body = parsed.data; + const userId = session!.user.id; + + await db + .insert(userFeaturePrefs) + .values({ id: crypto.randomUUID(), userId, ...body, updatedAt: new Date() }) + .onConflictDoUpdate({ + target: userFeaturePrefs.userId, + set: { ...body, updatedAt: new Date() }, + }); + + return NextResponse.json({ ok: true }); +} diff --git a/apps/web/app/api/v1/users/me/notification-prefs/route.ts b/apps/web/app/api/v1/users/me/notification-prefs/route.ts index 888465f..5f22f51 100644 --- a/apps/web/app/api/v1/users/me/notification-prefs/route.ts +++ b/apps/web/app/api/v1/users/me/notification-prefs/route.ts @@ -13,6 +13,15 @@ const PutSchema = z.object({ mention: z.boolean().optional(), leftoverExpiring: z.boolean().optional(), shoppingList: z.boolean().optional(), + followEmail: z.boolean().optional(), + commentEmail: z.boolean().optional(), + replyEmail: z.boolean().optional(), + reactionEmail: z.boolean().optional(), + ratingEmail: z.boolean().optional(), + mentionEmail: z.boolean().optional(), + leftoverExpiringEmail: z.boolean().optional(), + shoppingListEmail: z.boolean().optional(), + weeklyDigestEmail: z.boolean().optional(), }); export async function GET() { diff --git a/apps/web/components/admin/admin-webhooks-manager.tsx b/apps/web/components/admin/admin-webhooks-manager.tsx new file mode 100644 index 0000000..7c0634d --- /dev/null +++ b/apps/web/components/admin/admin-webhooks-manager.tsx @@ -0,0 +1,313 @@ +"use client"; + +import { useState } from "react"; +import { toast } from "sonner"; +import { ChevronDown, ChevronUp, RotateCcw } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; + +const ALL_EVENTS = ["user.signed_up", "support_ticket.created", "report.filed"] as const; + +type AdminWebhookEventType = (typeof ALL_EVENTS)[number]; + +type Webhook = { + id: string; + url: string; + events: string[]; + active: boolean; + createdAt: string; +}; + +type Delivery = { + id: string; + event: string; + statusCode: number | null; + success: boolean; + attempts: number; + createdAt: string; +}; + +type CreateWebhookResponse = { + id: string; + url: string; + events: string[]; + secret: string; + active: boolean; + createdAt: string; +}; + +function formatDate(iso: string) { + return new Date(iso).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }); +} + +function formatDateTime(iso: string) { + return new Date(iso).toLocaleString(undefined, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }); +} + +export function AdminWebhooksManager({ initialWebhooks }: { initialWebhooks: Webhook[] }) { + const [webhookList, setWebhookList] = useState(initialWebhooks); + const [url, setUrl] = useState(""); + const [selectedEvents, setSelectedEvents] = useState([]); + const [creating, setCreating] = useState(false); + const [newSecret, setNewSecret] = useState<{ id: string; secret: string } | null>(null); + const [copied, setCopied] = useState(false); + const [deliveries, setDeliveries] = useState>({}); + const [loadingDeliveries, setLoadingDeliveries] = useState(null); + const [expandedDeliveries, setExpandedDeliveries] = useState>(new Set()); + const [redelivering, setRedelivering] = useState(null); + + function toggleEvent(event: AdminWebhookEventType) { + setSelectedEvents((prev) => (prev.includes(event) ? prev.filter((e) => e !== event) : [...prev, event])); + } + + async function handleCreate(e: React.FormEvent) { + e.preventDefault(); + if (!url.trim()) return; + setCreating(true); + try { + const res = await fetch("/api/v1/admin/webhooks", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ url: url.trim(), events: selectedEvents }), + }); + if (!res.ok) { + const data = await res.json() as { error?: string }; + throw new Error(data.error ?? "Failed to add webhook"); + } + const data = await res.json() as CreateWebhookResponse; + setNewSecret({ id: data.id, secret: data.secret }); + setWebhookList((prev) => [ + { id: data.id, url: data.url, events: data.events, active: data.active, createdAt: data.createdAt }, + ...prev, + ]); + setUrl(""); + setSelectedEvents([]); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Failed to add webhook"); + } finally { + setCreating(false); + } + } + + async function handleDelete(id: string) { + try { + const res = await fetch(`/api/v1/admin/webhooks/${id}`, { method: "DELETE" }); + if (!res.ok && res.status !== 204) { + const data = await res.json() as { error?: string }; + throw new Error(data.error ?? "Failed to delete webhook"); + } + setWebhookList((prev) => prev.filter((w) => w.id !== id)); + if (newSecret?.id === id) setNewSecret(null); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Failed to delete webhook"); + } + } + + async function handleToggleActive(id: string, currentActive: boolean) { + try { + const res = await fetch(`/api/v1/admin/webhooks/${id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ active: !currentActive }), + }); + if (!res.ok) { + const data = await res.json() as { error?: string }; + throw new Error(data.error ?? "Failed to update webhook"); + } + setWebhookList((prev) => prev.map((w) => (w.id === id ? { ...w, active: !currentActive } : w))); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Failed to update webhook"); + } + } + + async function handleCopySecret() { + if (!newSecret) return; + try { + await navigator.clipboard.writeText(newSecret.secret); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + toast.error("Copy failed"); + } + } + + async function toggleDeliveries(webhookId: string) { + const isExpanded = expandedDeliveries.has(webhookId); + if (isExpanded) { + setExpandedDeliveries((prev) => { const s = new Set(prev); s.delete(webhookId); return s; }); + return; + } + + setExpandedDeliveries((prev) => new Set([...prev, webhookId])); + if (deliveries[webhookId]) return; + + setLoadingDeliveries(webhookId); + try { + const res = await fetch(`/api/v1/admin/webhooks/${webhookId}/deliveries`); + if (res.ok) { + const data = await res.json() as Delivery[]; + setDeliveries((prev) => ({ ...prev, [webhookId]: data })); + } + } finally { + setLoadingDeliveries(null); + } + } + + async function handleRedeliver(webhookId: string, deliveryId: string) { + setRedelivering(deliveryId); + try { + const res = await fetch(`/api/v1/admin/webhooks/${webhookId}/redeliver`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ deliveryId }), + }); + if (res.ok) toast.success("Redelivered"); + else toast.error("Redelivery failed"); + } finally { + setRedelivering(null); + } + } + + return ( +
+
+
+ + setUrl(e.target.value)} + maxLength={2048} + required + /> +
+ +
+ +

Leave all unchecked to receive every event.

+
+ {ALL_EVENTS.map((event) => { + const checked = selectedEvents.includes(event); + return ( + + ); + })} +
+
+ + +
+ + {newSecret && ( +
+

+ Save this secret now — it won't be shown again. +

+

+ Used to verify the X-Epicure-Signature header on each delivery. +

+
+ + {newSecret.secret} + + +
+ +
+ )} + + {webhookList.length === 0 ? ( +

No webhooks configured yet.

+ ) : ( +
+ {webhookList.map((w) => { + const isExpanded = expandedDeliveries.has(w.id); + const wDeliveries = deliveries[w.id] ?? []; + return ( +
+
+
+

{w.url}

+
+ Added {formatDate(w.createdAt)} + · + + {w.active ? "Active" : "Inactive"} + +
+ {w.events.length > 0 ? ( +
+ {w.events.map((ev) => ( + {ev} + ))} +
+ ) : ( +

All events

+ )} +
+
+ + + +
+
+ + {isExpanded && ( +
+ {loadingDeliveries === w.id ? ( +

Loading…

+ ) : wDeliveries.length === 0 ? ( +

No deliveries yet.

+ ) : ( +
+ {wDeliveries.map((d) => ( +
+ + {d.statusCode ?? "err"} + + {d.event} + {formatDateTime(d.createdAt)} + +
+ ))} +
+ )} +
+ )} +
+ ); + })} +
+ )} +
+ ); +} diff --git a/apps/web/components/layout/nav.tsx b/apps/web/components/layout/nav.tsx index 0660fee..d14b2ea 100644 --- a/apps/web/components/layout/nav.tsx +++ b/apps/web/components/layout/nav.tsx @@ -1,11 +1,13 @@ "use client"; +import { useEffect, useState } from "react"; import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; import { useTheme } from "next-themes"; import { BookOpen, Calendar, Package, ChefHat, User, FolderOpen, ShoppingCart, Shield, Search, Compass, Menu, Sun, Moon, Monitor, Apple, LifeBuoy, Settings, LogOut } from "lucide-react"; import { cn } from "@/lib/utils"; import { Button, buttonVariants } from "@/components/ui/button"; +import type { FeaturePrefs } from "@/lib/feature-prefs"; import { DropdownMenu, DropdownMenuContent, @@ -28,13 +30,13 @@ import { authClient } from "@/lib/auth/client"; import { useTranslations } from "next-intl"; const NAV_ITEMS = [ - { href: "/recipes", key: "recipes", icon: BookOpen }, - { href: "/explore", key: "explore", icon: Search }, - { href: "/collections", key: "collections", icon: FolderOpen }, - { href: "/meal-plan", key: "mealPlan", icon: Calendar }, - { href: "/nutrition", key: "nutrition", icon: Apple }, - { href: "/pantry", key: "pantry", icon: Package }, - { href: "/shopping-lists", key: "shopping", icon: ShoppingCart }, + { href: "/recipes", key: "recipes", icon: BookOpen, feature: null }, + { href: "/explore", key: "explore", icon: Search, feature: null }, + { href: "/collections", key: "collections", icon: FolderOpen, feature: "collections" }, + { href: "/meal-plan", key: "mealPlan", icon: Calendar, feature: "mealPlan" }, + { href: "/nutrition", key: "nutrition", icon: Apple, feature: "nutrition" }, + { href: "/pantry", key: "pantry", icon: Package, feature: "pantry" }, + { href: "/shopping-lists", key: "shopping", icon: ShoppingCart, feature: "shoppingLists" }, ] as const; export function Nav() { @@ -45,6 +47,30 @@ export function Nav() { const username = (session?.user as { username?: string } | undefined)?.username; const { theme, setTheme } = useTheme(); const t = useTranslations("nav"); + + // Defaults to "everything on" until the fetch resolves, matching the + // backend default — avoids a flash of items disappearing on load for the + // (much more common) case where a user hasn't hidden anything. + const [featurePrefs, setFeaturePrefs] = useState({ + nutrition: true, pantry: true, mealPlan: true, shoppingLists: true, collections: true, messages: true, + }); + + useEffect(() => { + function load() { + fetch("/api/v1/users/me/feature-prefs") + .then((res) => (res.ok ? res.json() : null)) + .then((json) => { if (json?.data) setFeaturePrefs(json.data); }) + .catch(() => {}); + } + load(); + // Settings → Features saves on the same origin without a full nav + // remount, so it fires this event to make the change visible immediately + // rather than only on the next page load. + window.addEventListener("epicure:feature-prefs-changed", load); + return () => window.removeEventListener("epicure:feature-prefs-changed", load); + }, []); + + const visibleNavItems = NAV_ITEMS.filter((item) => item.feature === null || featurePrefs[item.feature]); const THEME_OPTIONS = [ { value: "light", icon: Sun, label: t("lightMode") }, { value: "dark", icon: Moon, label: t("darkMode") }, @@ -68,7 +94,7 @@ export function Nav() {