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 <noreply@anthropic.com>
This commit is contained in:
Arnaud
2026-07-21 23:31:47 +02:00
parent e3f2cf6834
commit 4c3880e07f
28 changed files with 231 additions and 39 deletions
+6
View File
@@ -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
+4 -4
View File
@@ -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.
+2
View File
@@ -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<string, { value: string | null }>) {
}
export default async function AdminAiConfigPage() {
await requireFullAdminPage();
const settings = await getAllSiteSettings();
const active = resolveProvider(settings);
+2
View File
@@ -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;
+3 -1
View File
@@ -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 (
<div className="space-y-6">
<div>
+2
View File
@@ -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);
+2
View File
@@ -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";
+24 -24
View File
@@ -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 (
<div className="flex flex-col md:flex-row min-h-screen">
@@ -36,7 +36,7 @@ export default async function AdminLayout({ children }: { children: React.ReactN
<div className="flex items-center justify-between gap-2 p-4 border-b font-semibold">
<span className="flex items-center gap-2">
<Shield className="h-4 w-4 text-destructive" />
Admin
{role === "admin" ? "Admin" : "Moderator"}
</span>
<Link
href="/recipes"
@@ -47,7 +47,7 @@ export default async function AdminLayout({ children }: { children: React.ReactN
</Link>
</div>
<nav className="flex overflow-x-auto gap-1 p-2 md:flex-col md:overflow-visible md:flex-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{adminNav.map(({ href, label, icon: Icon }) => (
{visibleNav.map(({ href, label, icon: Icon }) => (
<Link
key={href}
href={href}
+2
View File
@@ -6,10 +6,12 @@ import { count, eq, gte, sql } from "@epicure/db";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Users, BookOpen, Sparkles, Image, History, UserPlus, ChefHat, Flag, HardDrive, Webhook, KeyRound } from "lucide-react";
import { APP_VERSION, CHANGELOG } from "@/lib/changelog";
import { requireFullAdminPage } from "@/lib/require-admin-page";
export const metadata: Metadata = {};
export default async function AdminPage() {
await requireFullAdminPage();
const currentMonth = new Date().toISOString().slice(0, 7);
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
+6 -1
View File
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { db, recipes, users, eq, desc, count } from "@epicure/db";
import { Badge } from "@/components/ui/badge";
import Link from "next/link";
import { UnpublishRecipeButton } from "@/components/admin/unpublish-recipe-button";
export const metadata: Metadata = {};
@@ -61,12 +62,13 @@ export default async function AdminRecipesPage({ searchParams }: PageProps) {
<th className="px-4 py-3 text-left font-medium">Visibility</th>
<th className="px-4 py-3 text-left font-medium">Created</th>
<th className="px-4 py-3 text-left font-medium">Link</th>
<th className="px-4 py-3 text-left font-medium">Action</th>
</tr>
</thead>
<tbody>
{publicRecipes.length === 0 ? (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-muted-foreground">
<td colSpan={6} className="px-4 py-8 text-center text-muted-foreground">
No public recipes yet.
</td>
</tr>
@@ -104,6 +106,9 @@ export default async function AdminRecipesPage({ searchParams }: PageProps) {
View
</Link>
</td>
<td className="px-4 py-3">
<UnpublishRecipeButton recipeId={recipe.id} />
</td>
</tr>
))
)}
+2
View File
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { getAllSiteSettings, isSignupsDisabled } from "@/lib/site-settings";
import { AdminSettingsForm } from "@/components/admin/admin-settings-form";
import { SignupsToggle } from "@/components/admin/signups-toggle";
import { requireFullAdminPage } from "@/lib/require-admin-page";
export const metadata: Metadata = {};
@@ -25,6 +26,7 @@ const SETTING_GROUPS = [
];
export default async function AdminSettingsPage() {
await requireFullAdminPage();
const settings = await getAllSiteSettings();
const signupsDisabled = await isSignupsDisabled();
+2
View File
@@ -3,10 +3,12 @@ import { db, users, recipePhotos, recipes, eq, desc, sql, count } from "@epicure
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { HardDrive } from "lucide-react";
import { requireFullAdminPage } from "@/lib/require-admin-page";
export const metadata: Metadata = {};
export default async function AdminStoragePage() {
await requireFullAdminPage();
const [totalPhotos, recipesWithPhotos, photosByTier, topUsers] = await Promise.all([
db.select({ count: count() }).from(recipePhotos).then((r) => r[0]),
db
+2
View File
@@ -2,10 +2,12 @@ import type { Metadata } from "next";
import { db, supportTickets, desc } from "@epicure/db";
import { AdminSupportManager } from "@/components/admin/admin-support-manager";
import { getPublicUrl } from "@/lib/storage";
import { requireFullAdminPage } from "@/lib/require-admin-page";
export const metadata: Metadata = {};
export default async function AdminSupportPage() {
await requireFullAdminPage();
const rows = await db.query.supportTickets.findMany({
orderBy: desc(supportTickets.createdAt),
with: {
+2
View File
@@ -3,10 +3,12 @@ import { db, tierDefinitions } from "@epicure/db";
import { TierLimitsForm } from "@/components/admin/tier-limits-form";
import { FeatureFlagsForm } from "@/components/admin/feature-flags-form";
import { getFeatureFlagMatrix, FEATURE_DEFINITIONS } from "@/lib/feature-flags";
import { requireFullAdminPage } from "@/lib/require-admin-page";
export const metadata: Metadata = {};
export default async function AdminTiersPage() {
await requireFullAdminPage();
const [tiers, featureFlagMatrix] = await Promise.all([
db.select().from(tierDefinitions),
getFeatureFlagMatrix(),
+2
View File
@@ -10,6 +10,7 @@ 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";
import { requireFullAdminPage } from "@/lib/require-admin-page";
export const metadata: Metadata = {};
@@ -30,6 +31,7 @@ const TIER_COLORS = {
} as const;
export default async function AdminUserDetailPage({ params }: PageProps) {
await requireFullAdminPage();
const { id } = await params;
const [user] = await db.select().from(users).where(eq(users.id, id)).limit(1);
+2
View File
@@ -6,6 +6,7 @@ import { Badge } from "@/components/ui/badge";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { CreateUserDialog } from "@/components/admin/create-user-dialog";
import Link from "next/link";
import { requireFullAdminPage } from "@/lib/require-admin-page";
export const metadata: Metadata = {};
@@ -28,6 +29,7 @@ const TIER_COLORS = {
} as const;
export default async function AdminUsersPage({ searchParams }: PageProps) {
await requireFullAdminPage();
const { page: pageParam } = await searchParams;
const page = Math.max(1, parseInt(pageParam ?? "1", 10) || 1);
const offset = (page - 1) * PAGE_SIZE;
+2
View File
@@ -1,10 +1,12 @@
import type { Metadata } from "next";
import { db, adminWebhooks } from "@epicure/db";
import { AdminWebhooksManager } from "@/components/admin/admin-webhooks-manager";
import { requireFullAdminPage } from "@/lib/require-admin-page";
export const metadata: Metadata = {};
export default async function AdminWebhooksPage() {
await requireFullAdminPage();
const rows = await db
.select({
id: adminWebhooks.id,
@@ -0,0 +1,43 @@
import { NextRequest, NextResponse } from "next/server";
import { randomUUID } from "crypto";
import { z } from "zod";
import { requireAdmin } from "@/lib/api-auth";
import { db, recipes, auditLogs, eq } from "@epicure/db";
const PatchSchema = z.object({
action: z.literal("unpublish"),
});
/** Admin/moderator takedown action — flips a public recipe to private.
* Deliberately narrow (just this one transition) rather than exposing full
* visibility management here, since that's the recipe owner's call. */
export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { session, response } = await requireAdmin({ allowModerator: true });
if (response) return response;
const { id } = await params;
const parsed = PatchSchema.safeParse(await req.json());
if (!parsed.success) {
return NextResponse.json({ error: "Validation error", issues: parsed.error.issues }, { status: 400 });
}
const [updated] = await db
.update(recipes)
.set({ visibility: "private" })
.where(eq(recipes.id, id))
.returning({ id: recipes.id });
if (!updated) return NextResponse.json({ error: "Not found" }, { status: 404 });
await db.insert(auditLogs).values({
id: randomUUID(),
userId: session!.user.id,
action: "admin.recipe.unpublish",
targetType: "recipe",
targetId: id,
metadata: null,
createdAt: new Date(),
});
return NextResponse.json({ ok: true });
}
@@ -8,7 +8,7 @@ interface RouteContext {
}
export async function PATCH(req: NextRequest, { params }: RouteContext) {
const { session, response } = await requireAdmin();
const { session, response } = await requireAdmin({ allowModerator: true });
if (response) return response;
const { id } = await params;
+1 -1
View File
@@ -3,7 +3,7 @@ import { requireAdmin } from "@/lib/api-auth";
import { db, reports, users, eq, desc } from "@epicure/db";
export async function GET() {
const { response } = await requireAdmin();
const { response } = await requireAdmin({ allowModerator: true });
if (response) return response;
const rows = await db
@@ -0,0 +1,36 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
export function UnpublishRecipeButton({ recipeId }: { recipeId: string }) {
const router = useRouter();
const [busy, setBusy] = useState(false);
async function unpublish() {
if (!confirm("Unpublish this recipe? It becomes private — only the author can see it.")) return;
setBusy(true);
try {
const res = await fetch(`/api/v1/admin/recipes/${recipeId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "unpublish" }),
});
if (!res.ok) throw new Error();
toast.success("Recipe unpublished");
router.refresh();
} catch {
toast.error("Failed to unpublish");
} finally {
setBusy(false);
}
}
return (
<Button type="button" variant="outline" size="sm" className="h-6 px-2 text-xs" disabled={busy} onClick={() => { void unpublish(); }}>
{busy ? "Unpublishing…" : "Unpublish"}
</Button>
);
}
+3 -2
View File
@@ -19,7 +19,7 @@ export async function getOptionalSession() {
return auth.api.getSession({ headers: await headers() });
}
export async function requireAdmin() {
export async function requireAdmin(opts?: { allowModerator?: boolean }) {
const { session, response } = await requireSession();
if (response) return { session: null, response };
@@ -32,7 +32,8 @@ export async function requireAdmin() {
.where(eq(users.id, session!.user.id))
.limit(1);
if (dbUser?.role !== "admin") {
const allowed = dbUser?.role === "admin" || (opts?.allowModerator && dbUser?.role === "moderator");
if (!allowed) {
return { session: null, response: NextResponse.json({ error: "Forbidden" }, { status: 403 }) };
}
return { session, response: null };
+9 -1
View File
@@ -1,5 +1,5 @@
// Mirrors CHANGELOG.md at the repo root — update both together.
export const APP_VERSION = "0.65.3";
export const APP_VERSION = "0.66.0";
export type ChangelogEntry = {
version: string;
@@ -11,6 +11,14 @@ export type ChangelogEntry = {
};
export const CHANGELOG: ChangelogEntry[] = [
{
version: "0.66.0",
date: "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.",
],
},
{
version: "0.65.3",
date: "2026-07-21 01:20",
+5 -2
View File
@@ -888,7 +888,10 @@ export function generateOpenApiSpec(): object {
registry.registerPath({ method: "post", path: "/api/v1/admin/invites", summary: "Create an invite", description: "Admin only.", security: adminSecurity, request: { body: { content: { "application/json": { schema: CreateInviteRef } }, required: true } }, responses: { 200: { description: "Created", content: { "application/json": { schema: z.object({ invite: InviteRef }) } } }, 400: { description: "Invalid role or tier", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "delete", path: "/api/v1/admin/invites/{id}", summary: "Revoke an invite", description: "Admin only.", security: adminSecurity, request: { params: idParam }, responses: { 200: { description: "Revoked", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Invite not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/admin/reports", summary: "List pending reports (up to 100, newest first)", description: "Admin only.", security: adminSecurity, responses: { 200: { description: "Reports", content: { "application/json": { schema: z.object({ reports: z.array(AdminReportRef) }) } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/admin/reports", summary: "List pending reports (up to 100, newest first)", description: "Admin or moderator.", security: adminSecurity, responses: { 200: { description: "Reports", content: { "application/json": { schema: z.object({ reports: z.array(AdminReportRef) }) } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } } } });
const UnpublishRecipeRef = registry.register("UnpublishRecipe", z.object({ action: z.literal("unpublish") }));
registry.registerPath({ method: "patch", path: "/api/v1/admin/recipes/{id}", summary: "Unpublish a public recipe (moderation takedown)", description: "Admin or moderator. Flips visibility to private — the only transition this endpoint allows.", security: adminSecurity, request: { params: idParam, body: { content: { "application/json": { schema: UnpublishRecipeRef } }, required: true } }, responses: { 200: { description: "Unpublished", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/admin/support", summary: "List all support tickets, newest first", description: "Admin only.", security: adminSecurity, responses: { 200: { description: "Tickets", content: { "application/json": { schema: z.array(AdminSupportTicketRef) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "patch", path: "/api/v1/admin/support/{id}", summary: "Update a support ticket's status, or retry opening its Gitea issue", description: "Admin only. A status change to/from \"closed\" also best-effort closes/reopens the linked Gitea issue.", security: adminSecurity, request: { params: idParam, body: { content: { "application/json": { schema: UpdateAdminSupportTicketRef } }, required: true } }, responses: { 200: { description: "Updated", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
@@ -901,7 +904,7 @@ export function generateOpenApiSpec(): object {
registry.registerPath({ method: "delete", path: "/api/v1/admin/webhooks/{id}", summary: "Delete a site-wide ops webhook", description: "Admin only.", security: adminSecurity, request: { params: idParam }, responses: { 204: { description: "Deleted" }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/admin/webhooks/{id}/deliveries", summary: "List recent delivery attempts for a site-wide ops webhook (most recent 20)", description: "Admin only.", security: adminSecurity, request: { params: idParam }, responses: { 200: { description: "Deliveries, newest first", content: { "application/json": { schema: z.array(AdminWebhookDeliveryRef) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/admin/webhooks/{id}/redeliver", summary: "Re-send a past delivery's payload for a site-wide ops webhook", description: "Admin only.", security: adminSecurity, request: { params: idParam, body: { content: { "application/json": { schema: z.object({ deliveryId: z.string().uuid() }) } }, required: true } }, responses: { 200: { description: "Redelivery dispatched", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Webhook or delivery not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "patch", path: "/api/v1/admin/reports/{id}", summary: "Resolve a report (mark reviewed or dismissed)", description: "Admin only.", security: adminSecurity, request: { params: idParam, body: { content: { "application/json": { schema: ResolveReportBodyRef } }, required: true } }, responses: { 200: { description: "Updated", content: { "application/json": { schema: z.object({ report: ResolvedReportRef }) } } }, 400: { description: "Invalid status", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "patch", path: "/api/v1/admin/reports/{id}", summary: "Resolve a report (mark reviewed or dismissed)", description: "Admin or moderator.", security: adminSecurity, request: { params: idParam, body: { content: { "application/json": { schema: ResolveReportBodyRef } }, required: true } }, responses: { 200: { description: "Updated", content: { "application/json": { schema: z.object({ report: ResolvedReportRef }) } } }, 400: { description: "Invalid status", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "put", path: "/api/v1/admin/settings", summary: "Update site settings (AI provider keys, VAPID keys, signups toggle)", description: "Admin only. Secret-flagged keys are encrypted at rest and never echoed back by any endpoint; this route only accepts new values, it does not return current ones.", security: adminSecurity, request: { body: { content: { "application/json": { schema: UpdateSiteSettingsRef } }, required: true } }, responses: { 200: { description: "Saved", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } } } });
+29
View File
@@ -0,0 +1,29 @@
import { redirect } from "next/navigation";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
import { db, users, eq } from "@epicure/db";
export type StaffRole = "admin" | "moderator";
/** Server-component equivalent of requireAdmin/lib/api-auth.ts used by
* admin/layout.tsx to gate entry to the whole /admin tree (admin AND
* moderator both pass) and by individual page components that need to
* additionally restrict themselves to admin only. Always re-queries the
* role fresh, same reasoning as requireAdmin: session.user.role comes from
* a 5-minute cookieCache. */
export async function getStaffRole(): Promise<StaffRole | null> {
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)).limit(1);
if (dbUser?.role === "admin" || dbUser?.role === "moderator") return dbUser.role;
return null;
}
/** Redirects moderators to /admin/reports (their only allowed landing area)
* and non-staff to /recipes. Call at the top of any admin page that should
* stay admin-only. */
export async function requireFullAdminPage(): Promise<void> {
const role = await getStaffRole();
if (role === "moderator") redirect("/admin/reports");
if (role !== "admin") redirect("/recipes");
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@epicure/web",
"version": "0.65.3",
"version": "0.66.0",
"private": true,
"scripts": {
"dev": "next dev",
+35
View File
@@ -117,3 +117,38 @@ async function replayPendingActions() {
self.addEventListener("sync", event => {
if (event.tag === SYNC_TAG) event.waitUntil(replayPendingActions());
});
// --- Push: display incoming notifications and handle taps ---
// lib/push.ts sends {title, body, url} as the payload. Without a "push"
// listener, a push message reaches the browser but never displays anything
// — showNotification() must be called explicitly.
self.addEventListener("push", event => {
let data = {};
try {
data = event.data ? event.data.json() : {};
} catch {
data = {};
}
const title = data.title || "Epicure";
event.waitUntil(
self.registration.showNotification(title, {
body: data.body || "",
icon: "/icon-192.svg",
badge: "/icon-192.svg",
data: { url: data.url || "/" },
})
);
});
self.addEventListener("notificationclick", event => {
event.notification.close();
const url = event.notification.data && event.notification.data.url ? event.notification.data.url : "/";
event.waitUntil(
self.clients.matchAll({ type: "window", includeUncontrolled: true }).then(clientList => {
const targetUrl = new URL(url, self.location.origin).href;
const existing = clientList.find(c => c.url === targetUrl);
if (existing) return existing.focus();
return self.clients.openWindow(url);
})
);
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "epicure",
"version": "0.65.3",
"version": "0.66.0",
"private": true,
"scripts": {
"dev": "pnpm --filter web dev",