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
+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 } } } } });