From 4c3880e07f59ec837cb18c4b5b0dc90151e6cc3e Mon Sep 17 00:00:00 2001 From: Arnaud Date: Tue, 21 Jul 2026 23:31:47 +0200 Subject: [PATCH] feat: moderator-scoped admin access + fix push notifications not displaying (v0.66.0) Moderator role existed in the schema and was already respected by comment deletion, but every admin page/route treated moderator identically to a regular user (403/redirect). Wires it up narrowly: admin/layout.tsx now lets admin+moderator through and filters the nav by role, while every admin-only page (users, tiers, settings, webhooks, insights, etc.) explicitly redirects moderators away via a new requireFullAdminPage() helper -- the nav filter is UX, this is the actual gate. Moderators land on Reports and Recipes: reports GET/PATCH now accept requireAdmin({allowModerator: true}), and a new PATCH /api/v1/admin/recipes/[id] lets admin+moderator unpublish a public recipe (flip to private) as a takedown action, audit-logged. Also found and fixed a real bug while auditing the PWA push pipeline for a "push click-through" gap: public/sw.js had no `push` event listener at all, so incoming push messages never displayed anything -- push was silently non-functional end-to-end despite the subscribe/send plumbing all working. Added the push listener (showNotification) and a notificationclick listener that focuses an existing tab or opens one at the payload's url. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 6 +++ FEATURE_AUDIT.md | 8 ++-- apps/web/app/admin/ai-config/page.tsx | 2 + apps/web/app/admin/audit-logs/page.tsx | 2 + apps/web/app/admin/changelog/page.tsx | 4 +- apps/web/app/admin/insights/page.tsx | 2 + apps/web/app/admin/invites/page.tsx | 2 + apps/web/app/admin/layout.tsx | 48 +++++++++---------- apps/web/app/admin/page.tsx | 2 + apps/web/app/admin/recipes/page.tsx | 7 ++- apps/web/app/admin/settings/page.tsx | 2 + apps/web/app/admin/storage/page.tsx | 2 + apps/web/app/admin/support/page.tsx | 2 + apps/web/app/admin/tiers/page.tsx | 2 + apps/web/app/admin/users/[id]/page.tsx | 2 + apps/web/app/admin/users/page.tsx | 2 + apps/web/app/admin/webhooks/page.tsx | 2 + .../app/api/v1/admin/recipes/[id]/route.ts | 43 +++++++++++++++++ .../app/api/v1/admin/reports/[id]/route.ts | 2 +- apps/web/app/api/v1/admin/reports/route.ts | 2 +- .../admin/unpublish-recipe-button.tsx | 36 ++++++++++++++ apps/web/lib/api-auth.ts | 5 +- apps/web/lib/changelog.ts | 10 +++- apps/web/lib/openapi.ts | 7 ++- apps/web/lib/require-admin-page.ts | 29 +++++++++++ apps/web/package.json | 2 +- apps/web/public/sw.js | 35 ++++++++++++++ package.json | 2 +- 28 files changed, 231 insertions(+), 39 deletions(-) create mode 100644 apps/web/app/api/v1/admin/recipes/[id]/route.ts create mode 100644 apps/web/components/admin/unpublish-recipe-button.tsx create mode 100644 apps/web/lib/require-admin-page.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index fcac1f4..4282131 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ 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.66.0 — 2026-07-21 09:00 + +### Added +- Moderator role is now wired up: moderators get access to Admin → Reports (view/resolve) and Admin → Recipes (view + unpublish a public recipe as a takedown action). Every other admin section stays admin-only, enforced server-side on each page, not just hidden from the nav. +- Push notifications now actually display. The service worker had no `push` event listener at all, so incoming push messages never showed anything — fixed, plus a `notificationclick` handler that focuses an existing tab or opens one at the notification's target URL. + ## 0.65.3 — 2026-07-21 01:20 ### Fixed diff --git a/FEATURE_AUDIT.md b/FEATURE_AUDIT.md index 2221176..a289ad7 100644 --- a/FEATURE_AUDIT.md +++ b/FEATURE_AUDIT.md @@ -88,10 +88,10 @@ Status legend: **Exists** (fully working) · **Partial** (works but with a real | Trending feed / explore | Exists | Favorite-count driven, no editorial curation, **no separate "featured" surface** | `apps/web/app/api/v1/feed/trending` | | Notification categories | Exists | 8 categories, independent push + email toggle each, plus email-only weekly digest | `packages/db/src/schema/users.ts` (`userNotificationPrefs`) | | Push notifications | Exists | Web Push + VAPID, end-to-end, real trigger call sites (not just plumbing) | `apps/web/lib/push.ts` | -| Push click-through handling | **Missing** | No `notificationclick` listener in the service worker — the `url` field encoded in push payloads isn't consumed client-side | `apps/web/public/sw.js` | +| Push display + click-through | Exists | `sw.js` previously had no `push` listener at all — push messages never displayed. Now has both `push` (calls `showNotification`) and `notificationclick` (focuses an existing tab on the payload's `url`, or opens one) | `apps/web/public/sw.js` | | Email notifications + weekly digest cron | Exists | | `apps/web/lib/notifications.ts`, `apps/web/app/api/internal/cron/weekly-digest` | | Reports/moderation | Exists | Report → admin webhook → admin queue → resolve (reviewed/dismissed) + audit log | `apps/web/app/api/v1/reports`, `apps/web/app/api/v1/admin/reports` | -| Moderator role | Partial | Enum value exists (`user`/`moderator`/`admin`) but no moderator-scoped route was found — report review currently requires full admin | `packages/db/src/schema/users.ts` | +| Moderator role | Exists | Moderators get `/admin/reports` (view/resolve) and `/admin/recipes` (view + unpublish takedown); every other `/admin/*` page redirects them out via `requireFullAdminPage()`. Comment deletion already allowed moderator before this. | `apps/web/lib/require-admin-page.ts`, `apps/web/app/admin/layout.tsx`, `apps/web/app/api/v1/admin/recipes/[id]/route.ts` | --- @@ -145,11 +145,11 @@ Ranked roughly by likely value: 3. **Nutrition trend/history view** — diary is single-day only, no multi-day chart. 4. **USDA/nutrition-database lookup** — all nutrition numbers are AI-estimated; barcode scan only gets product name, not nutrition facts. 5. **OS Share Target** — can't share a recipe link into Epicure from another app's share sheet. -6. **Push click-through handling** in the service worker — payload carries a `url`, nothing consumes it. +6. ~~Push click-through handling~~ — closed 2026-07-21 (`push`/`notificationclick` listeners added to `sw.js`). 7. **Anonymous public link for meal plans** — shopping lists have this, meal plans don't. 8. **Grocery delivery/live pricing** beyond the Instacart stub (which needs a partnership agreement to go live). 9. **Offline coverage for shopping lists / meal plan** — currently recipe-viewing + mark-cooked only. -10. **Moderator-scoped admin routes** — role exists in the schema, not wired to any route. +10. ~~Moderator-scoped admin routes~~ — closed 2026-07-21 (reports + recipe-unpublish). ## Deliberate design choices (not gaps) - Direct messaging is 1:1 only — enforced at the schema level, not a missing feature. diff --git a/apps/web/app/admin/ai-config/page.tsx b/apps/web/app/admin/ai-config/page.tsx index efeafc8..217723c 100644 --- a/apps/web/app/admin/ai-config/page.tsx +++ b/apps/web/app/admin/ai-config/page.tsx @@ -3,6 +3,7 @@ import { Badge } from "@/components/ui/badge"; import { getAllSiteSettings } from "@/lib/site-settings"; import { AdminSettingsForm } from "@/components/admin/admin-settings-form"; import { AdminDefaultModelForm } from "@/components/admin/admin-default-model-form"; +import { requireFullAdminPage } from "@/lib/require-admin-page"; export const metadata: Metadata = {}; @@ -21,6 +22,7 @@ function resolveProvider(settings: Record) { } export default async function AdminAiConfigPage() { + await requireFullAdminPage(); const settings = await getAllSiteSettings(); const active = resolveProvider(settings); diff --git a/apps/web/app/admin/audit-logs/page.tsx b/apps/web/app/admin/audit-logs/page.tsx index 03d1d2b..399f3ca 100644 --- a/apps/web/app/admin/audit-logs/page.tsx +++ b/apps/web/app/admin/audit-logs/page.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; import { db, auditLogs, users, eq, desc } from "@epicure/db"; +import { requireFullAdminPage } from "@/lib/require-admin-page"; export const metadata: Metadata = {}; @@ -10,6 +11,7 @@ interface PageProps { } export default async function AdminAuditLogsPage({ searchParams }: PageProps) { + await requireFullAdminPage(); const { page: pageParam } = await searchParams; const page = Math.max(1, parseInt(pageParam ?? "1", 10)); const offset = (page - 1) * PAGE_SIZE; diff --git a/apps/web/app/admin/changelog/page.tsx b/apps/web/app/admin/changelog/page.tsx index 65e248c..3fede38 100644 --- a/apps/web/app/admin/changelog/page.tsx +++ b/apps/web/app/admin/changelog/page.tsx @@ -1,10 +1,12 @@ import type { Metadata } from "next"; import { ChangelogList } from "@/components/shared/changelog-list"; import { APP_VERSION } from "@/lib/changelog"; +import { requireFullAdminPage } from "@/lib/require-admin-page"; export const metadata: Metadata = {}; -export default function AdminChangelogPage() { +export default async function AdminChangelogPage() { + await requireFullAdminPage(); return (
diff --git a/apps/web/app/admin/insights/page.tsx b/apps/web/app/admin/insights/page.tsx index 394fb24..37860f4 100644 --- a/apps/web/app/admin/insights/page.tsx +++ b/apps/web/app/admin/insights/page.tsx @@ -3,6 +3,7 @@ import { db, users, recipes, userUsage, supportTickets, gte, sql } from "@epicur import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; import { BarChart } from "@/components/admin/charts/bar-chart"; import { TimeSeriesChart } from "@/components/admin/charts/time-series-chart"; +import { requireFullAdminPage } from "@/lib/require-admin-page"; export const metadata: Metadata = {}; @@ -35,6 +36,7 @@ function lastNMonths(n: number): string[] { } export default async function AdminInsightsPage() { + await requireFullAdminPage(); const since = new Date(); since.setDate(since.getDate() - DAYS); diff --git a/apps/web/app/admin/invites/page.tsx b/apps/web/app/admin/invites/page.tsx index 0ae8222..96de24b 100644 --- a/apps/web/app/admin/invites/page.tsx +++ b/apps/web/app/admin/invites/page.tsx @@ -1,10 +1,12 @@ import type { Metadata } from "next"; import { listInvites } from "@/lib/invites"; import { InvitesManager } from "@/components/admin/invites-manager"; +import { requireFullAdminPage } from "@/lib/require-admin-page"; export const metadata: Metadata = {}; export default async function AdminInvitesPage() { + await requireFullAdminPage(); const invites = await listInvites(); const appUrl = process.env["BETTER_AUTH_URL"] ?? "http://localhost:3000"; diff --git a/apps/web/app/admin/layout.tsx b/apps/web/app/admin/layout.tsx index ce06328..4d597d2 100644 --- a/apps/web/app/admin/layout.tsx +++ b/apps/web/app/admin/layout.tsx @@ -1,34 +1,34 @@ -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, Gauge, Mail, Flag, History, LifeBuoy, TrendingUp, Webhook } from "lucide-react"; import { cn } from "@/lib/utils"; +import { getStaffRole } from "@/lib/require-admin-page"; +import { redirect } from "next/navigation"; +// adminOnly items are hidden from moderators (and, redundantly but safely, +// each of those pages also redirects moderators away server-side via +// requireFullAdminPage — the nav filter here is UX, not the real gate). const adminNav = [ - { href: "/admin", label: "Overview", icon: BarChart3 }, - { href: "/admin/insights", label: "Insights", icon: TrendingUp }, - { href: "/admin/users", label: "Users", icon: Users }, - { href: "/admin/invites", label: "Invites", icon: Mail }, - { href: "/admin/recipes", label: "Recipes", icon: BookOpen }, - { 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 }, - { href: "/admin/settings", label: "Settings", icon: Settings }, - { href: "/admin/changelog", label: "Changelog", icon: History }, + { href: "/admin", label: "Overview", icon: BarChart3, adminOnly: true }, + { href: "/admin/insights", label: "Insights", icon: TrendingUp, adminOnly: true }, + { href: "/admin/users", label: "Users", icon: Users, adminOnly: true }, + { href: "/admin/invites", label: "Invites", icon: Mail, adminOnly: true }, + { href: "/admin/recipes", label: "Recipes", icon: BookOpen, adminOnly: false }, + { href: "/admin/reports", label: "Reports", icon: Flag, adminOnly: false }, + { href: "/admin/support", label: "Support", icon: LifeBuoy, adminOnly: true }, + { href: "/admin/tiers", label: "Tier Limits", icon: Gauge, adminOnly: true }, + { href: "/admin/webhooks", label: "Webhooks", icon: Webhook, adminOnly: true }, + { href: "/admin/audit-logs", label: "Audit Logs", icon: ClipboardList, adminOnly: true }, + { href: "/admin/storage", label: "Storage", icon: HardDrive, adminOnly: true }, + { href: "/admin/ai-config", label: "AI Config", icon: Bot, adminOnly: true }, + { href: "/admin/settings", label: "Settings", icon: Settings, adminOnly: true }, + { href: "/admin/changelog", label: "Changelog", icon: History, adminOnly: true }, ]; export default async function AdminLayout({ children }: { children: React.ReactNode }) { - const session = await auth.api.getSession({ headers: await headers() }); - if (!session) redirect("/login"); + const role = await getStaffRole(); + if (!role) redirect("/recipes"); - const [dbUser] = await db.select({ role: users.role }).from(users).where(eq(users.id, session.user.id)); - if (dbUser?.role !== "admin") redirect("/recipes"); + const visibleNav = role === "admin" ? adminNav : adminNav.filter((item) => !item.adminOnly); return (
@@ -36,7 +36,7 @@ export default async function AdminLayout({ children }: { children: React.ReactN
- Admin + {role === "admin" ? "Admin" : "Moderator"}