From 540ad1bf259105982435b958084497347a4da91c Mon Sep 17 00:00:00 2001 From: Musiker15 Date: Thu, 9 Jul 2026 16:45:40 +0200 Subject: [PATCH] feat(members): let a member fully manage a single form Adds a per-form manage grant so a viewer can be given full management of one specific form (edit the form and handle its submissions) without becoming a guild-wide manager. The grant lives on form_reviewers via a new can_manage column; a manage grant implies review access. - schema: form_reviewers.can_manage (+ migration) - access: manageScopeFromRole + getManageScope/canManageForm in lib/guild - gate the per-form edit, delete and definition-export routes + the edit and preview pages on canManageForm (guild manager OR per-form manager) - forms list shows the edit/preview/delete/export buttons per form based on the manage scope; guild-only actions (new form, import, billing) stay guild-gated - members UI: per-form access is now a None/Review/Manage select; the grants API accepts { grants: [{ formId, manage }] } (legacy formIds still accepted) Localized in all 7 languages. Added access-scope unit tests. --- .../forms/[formId]/definition/route.ts | 7 ++- .../guilds/[guildId]/forms/[formId]/route.ts | 13 +++-- .../[guildId]/members/[userId]/forms/route.ts | 38 ++++++++++--- .../[guildId]/forms/[formId]/edit/page.tsx | 4 +- .../[guildId]/forms/[formId]/preview/page.tsx | 4 +- .../app/dashboard/[guildId]/forms/page.tsx | 16 ++++-- .../app/dashboard/[guildId]/members/page.tsx | 11 ++-- .../components/members/members-manager.tsx | 55 ++++++++++++------- apps/web/src/i18n/dictionaries.ts | 21 ++++--- apps/web/src/lib/access.test.ts | 18 ++++++ apps/web/src/lib/access.ts | 10 ++++ apps/web/src/lib/guild.ts | 41 ++++++++++++++ .../migration.sql | 3 + packages/db/prisma/schema.prisma | 7 ++- 14 files changed, 187 insertions(+), 61 deletions(-) create mode 100644 packages/db/prisma/migrations/20260709120000_form_manager_grant/migration.sql diff --git a/apps/web/src/app/api/guilds/[guildId]/forms/[formId]/definition/route.ts b/apps/web/src/app/api/guilds/[guildId]/forms/[formId]/definition/route.ts index d3377fa..0f2b230 100644 --- a/apps/web/src/app/api/guilds/[guildId]/forms/[formId]/definition/route.ts +++ b/apps/web/src/app/api/guilds/[guildId]/forms/[formId]/definition/route.ts @@ -8,7 +8,7 @@ import { NextResponse, type NextRequest } from "next/server"; import { getCurrentUser } from "@/lib/auth"; import { parseFormSpec } from "@/lib/forms"; -import { canManageForms } from "@/lib/guild"; +import { canManageForm } from "@/lib/guild"; import { isGuildPro } from "@/lib/plan"; export const runtime = "nodejs"; @@ -16,7 +16,8 @@ export const dynamic = "force-dynamic"; /** * Export a form's definition (structure + settings, not submissions) as a - * portable JSON file. Manager-only and Pro+, mirroring the import endpoint. + * portable JSON file. Requires a guild manager or per-form manager, and Pro+, + * mirroring the import endpoint. */ export async function GET( _request: NextRequest, @@ -26,7 +27,7 @@ export async function GET( const user = await getCurrentUser(); if (!user) return NextResponse.json({ error: "Unauthorized." }, { status: 401 }); - if (!(await canManageForms(guildId, user.id))) { + if (!(await canManageForm(guildId, user.id, formId))) { return NextResponse.json({ error: "Forbidden." }, { status: 403 }); } if (!(await isGuildPro(guildId))) { diff --git a/apps/web/src/app/api/guilds/[guildId]/forms/[formId]/route.ts b/apps/web/src/app/api/guilds/[guildId]/forms/[formId]/route.ts index 820185b..3aa4113 100644 --- a/apps/web/src/app/api/guilds/[guildId]/forms/[formId]/route.ts +++ b/apps/web/src/app/api/guilds/[guildId]/forms/[formId]/route.ts @@ -4,14 +4,14 @@ import { NextResponse, type NextRequest } from "next/server"; import { getCurrentUser } from "@/lib/auth"; import { formInputSchema } from "@/lib/form-input"; import { resolveGuildCategoryId } from "@/lib/forms"; -import { canManageForms } from "@/lib/guild"; +import { canManageForm } from "@/lib/guild"; import { isGuildPro } from "@/lib/plan"; import { deleteObject } from "@/lib/s3"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; -/** Update an existing form. Requires owner/admin and form ∈ guild. */ +/** Update an existing form. Requires a guild manager or per-form manager, form ∈ guild. */ export async function PATCH( request: NextRequest, { params }: { params: Promise<{ guildId: string; formId: string }> }, @@ -20,7 +20,7 @@ export async function PATCH( const user = await getCurrentUser(); if (!user) return NextResponse.json({ error: "Unauthorized." }, { status: 401 }); - if (!(await canManageForms(guildId, user.id))) { + if (!(await canManageForm(guildId, user.id, formId))) { return NextResponse.json({ error: "Forbidden." }, { status: 403 }); } @@ -83,8 +83,9 @@ export async function PATCH( /** * Delete a form and everything under it (versions, submissions, events, files, - * status defs all cascade). Owner/admin only, form ∈ guild. Stored file objects - * are purged from object storage best-effort after the row is gone. + * status defs all cascade). Requires a guild manager or per-form manager, form ∈ + * guild. Stored file objects are purged from object storage best-effort after + * the row is gone. */ export async function DELETE( _request: NextRequest, @@ -94,7 +95,7 @@ export async function DELETE( const user = await getCurrentUser(); if (!user) return NextResponse.json({ error: "Unauthorized." }, { status: 401 }); - if (!(await canManageForms(guildId, user.id))) { + if (!(await canManageForm(guildId, user.id, formId))) { return NextResponse.json({ error: "Forbidden." }, { status: 403 }); } diff --git a/apps/web/src/app/api/guilds/[guildId]/members/[userId]/forms/route.ts b/apps/web/src/app/api/guilds/[guildId]/members/[userId]/forms/route.ts index 316432a..5eca80d 100644 --- a/apps/web/src/app/api/guilds/[guildId]/members/[userId]/forms/route.ts +++ b/apps/web/src/app/api/guilds/[guildId]/members/[userId]/forms/route.ts @@ -25,10 +25,25 @@ export async function PUT( return NextResponse.json({ error: "Forbidden." }, { status: 403 }); } - const body = (await request.json().catch(() => null)) as { formIds?: unknown } | null; - const requested = Array.isArray(body?.formIds) - ? body.formIds.filter((x): x is string => typeof x === "string") - : null; + // Each grant is a form the member may access; `manage` upgrades it from + // review-only to full management of that single form. Legacy `formIds` + // (review-only) is still accepted for compatibility. + const body = (await request.json().catch(() => null)) as { + grants?: unknown; + formIds?: unknown; + } | null; + const requested: { formId: string; manage: boolean }[] | null = Array.isArray(body?.grants) + ? body.grants + .filter( + (g): g is { formId: string; manage?: unknown } => + typeof g === "object" && g !== null && typeof (g as { formId?: unknown }).formId === "string", + ) + .map((g) => ({ formId: g.formId, manage: (g as { manage?: unknown }).manage === true })) + : Array.isArray(body?.formIds) + ? body.formIds + .filter((x): x is string => typeof x === "string") + .map((formId) => ({ formId, manage: false })) + : null; if (requested === null) { return NextResponse.json({ error: "Invalid request." }, { status: 422 }); } @@ -41,15 +56,16 @@ export async function PUT( // Keep only ids that are actually this guild's forms. const guildForms = await prisma.form.findMany({ - where: { guildId, id: { in: requested } }, + where: { guildId, id: { in: requested.map((g) => g.formId) } }, select: { id: true }, }); - const formIds = guildForms.map((f) => f.id); + const valid = new Set(guildForms.map((f) => f.id)); + const grants = requested.filter((g) => valid.has(g.formId)); // Plan member cap: only when this grant newly adds the user to the team. const currentGrants = await prisma.formReviewer.count({ where: { userId, form: { guildId } } }); const countedBefore = countsTowardTeam(member.role, currentGrants > 0); - const countsAfter = countsTowardTeam(member.role, formIds.length > 0); + const countsAfter = countsTowardTeam(member.role, grants.length > 0); if (countsAfter && !countedBefore) { const { memberLimit } = await getGuildPlan(guildId); if (memberLimit !== null && (await countTeamMembers(guildId)) >= memberLimit) { @@ -63,8 +79,12 @@ export async function PUT( // Replace the grants atomically. await prisma.$transaction([ prisma.formReviewer.deleteMany({ where: { userId, form: { guildId } } }), - ...(formIds.length > 0 - ? [prisma.formReviewer.createMany({ data: formIds.map((formId) => ({ formId, userId })) })] + ...(grants.length > 0 + ? [ + prisma.formReviewer.createMany({ + data: grants.map((g) => ({ formId: g.formId, userId, canManage: g.manage })), + }), + ] : []), ]); return NextResponse.json({ ok: true }); diff --git a/apps/web/src/app/dashboard/[guildId]/forms/[formId]/edit/page.tsx b/apps/web/src/app/dashboard/[guildId]/forms/[formId]/edit/page.tsx index c4f06b9..062962f 100644 --- a/apps/web/src/app/dashboard/[guildId]/forms/[formId]/edit/page.tsx +++ b/apps/web/src/app/dashboard/[guildId]/forms/[formId]/edit/page.tsx @@ -11,7 +11,7 @@ import { getStatusOptionsForGuild, parseFormSpec, } from "@/lib/forms"; -import { canManageForms } from "@/lib/guild"; +import { canManageForm } from "@/lib/guild"; import { isGuildPro } from "@/lib/plan"; import { getDict } from "@/i18n"; @@ -26,7 +26,7 @@ export default async function EditFormPage({ const { guildId, formId } = await params; const user = await requireUser(`/dashboard/${guildId}/forms/${formId}/edit`); const t = await getDict(); - if (!(await canManageForms(guildId, user.id))) { + if (!(await canManageForm(guildId, user.id, formId))) { return (

{t.dashboard.noPermEdit}

diff --git a/apps/web/src/app/dashboard/[guildId]/forms/[formId]/preview/page.tsx b/apps/web/src/app/dashboard/[guildId]/forms/[formId]/preview/page.tsx index 1d1a156..b1a770f 100644 --- a/apps/web/src/app/dashboard/[guildId]/forms/[formId]/preview/page.tsx +++ b/apps/web/src/app/dashboard/[guildId]/forms/[formId]/preview/page.tsx @@ -9,7 +9,7 @@ import { FormRenderer } from "@/components/form/form-renderer"; import { requireUser } from "@/lib/auth"; import { brandStyle, logoUrl, parseBranding } from "@/lib/branding"; import { getFormForEdit, parseFormSpec } from "@/lib/forms"; -import { canManageForms } from "@/lib/guild"; +import { canManageForm } from "@/lib/guild"; import { getDict } from "@/i18n"; export const runtime = "nodejs"; @@ -31,7 +31,7 @@ export default async function FormPreviewPage({ const dict = await getDict(); const t = dict.form; - if (!(await canManageForms(guildId, user.id))) { + if (!(await canManageForm(guildId, user.id, formId))) { return (

{dict.dashboard.noPermEdit}

diff --git a/apps/web/src/app/dashboard/[guildId]/forms/page.tsx b/apps/web/src/app/dashboard/[guildId]/forms/page.tsx index f3c8806..0414eb4 100644 --- a/apps/web/src/app/dashboard/[guildId]/forms/page.tsx +++ b/apps/web/src/app/dashboard/[guildId]/forms/page.tsx @@ -13,7 +13,7 @@ import { ShareButton } from "@/components/dashboard/share-button"; import { LocalDateTime } from "@/components/public/local-datetime"; import { requireUser } from "@/lib/auth"; import { appBaseUrl } from "@/lib/url"; -import { canManageForms, getGuildForms, getReviewScope } from "@/lib/guild"; +import { canManageForms, getGuildForms, getManageScope, getReviewScope } from "@/lib/guild"; import { getGuildPlan } from "@/lib/plan"; import { enterpriseEnabled, stripeEnabled } from "@/lib/stripe"; import { getDict } from "@/i18n"; @@ -36,11 +36,15 @@ export default async function GuildFormsPage({ const { guildId } = await params; const user = await requireUser(`/dashboard/${guildId}/forms`); const scope = await getReviewScope(guildId, user.id); - const [forms, canManage, plan] = await Promise.all([ + const [forms, canManage, manageScope, plan] = await Promise.all([ getGuildForms(guildId, scope), canManageForms(guildId, user.id), + getManageScope(guildId, user.id), getGuildPlan(guildId), ]); + // Whether the viewer may fully manage a given form (guild manager or a per-form + // manage grant). Drives the per-form edit/preview/delete/export-definition buttons. + const manages = (formId: string) => manageScope.all || manageScope.formIds.includes(formId); const base = appBaseUrl(); const dict = await getDict(); const t = dict.dashboard; @@ -206,7 +210,7 @@ export default async function GuildFormsPage({ )} )} - {canManage && plan.isPro && ( + {manages(form.id) && plan.isPro && ( )} - {canManage && ( + {manages(form.id) && ( )} - {canManage && ( + {manages(form.id) && ( )} - {canManage && ( + {manages(form.id) && ( = {}; - for (const g of grants) (grantsByUser[g.userId] ??= []).push(g.formId); + // Per-user, per-form access level: "manage" (full) or "review" (read). + const accessByUser: Record> = {}; + for (const g of grants) { + (accessByUser[g.userId] ??= {})[g.formId] = g.canManage ? "manage" : "review"; + } const rows: MemberRow[] = members .map((m) => ({ @@ -56,7 +59,7 @@ export default async function MembersPage({ username: m.user.username, avatar: m.user.avatar, role: m.role, - formIds: grantsByUser[m.user.id] ?? [], + access: accessByUser[m.user.id] ?? {}, })) .sort((a, b) => (ROLE_ORDER[a.role] ?? 9) - (ROLE_ORDER[b.role] ?? 9) || a.username.localeCompare(b.username)); diff --git a/apps/web/src/components/members/members-manager.tsx b/apps/web/src/components/members/members-manager.tsx index 2874aaf..f41b4a6 100644 --- a/apps/web/src/components/members/members-manager.tsx +++ b/apps/web/src/components/members/members-manager.tsx @@ -1,18 +1,22 @@ "use client"; -import { Button, Card, Checkbox, Field, Input, Select } from "@msk-forms/ui"; +import { Button, Card, Field, Input, Select } from "@msk-forms/ui"; import { useRouter } from "next/navigation"; import { useState } from "react"; import { ConfirmDialog } from "@/components/confirm-dialog"; import type { Dictionary } from "@/i18n"; +/** Per-form access a member holds: none, review-only, or full management. */ +export type FormAccess = "none" | "review" | "manage"; + export interface MemberRow { userId: string; username: string; avatar: string | null; role: string; - formIds: string[]; + /** Per-form access level (absent form id = no access). */ + access: Record; } type MembersDict = Dictionary["members"]; @@ -38,9 +42,9 @@ export function MembersManager({ const [confirmRemove, setConfirmRemove] = useState(null); const [addId, setAddId] = useState(""); const [addRole, setAddRole] = useState("viewer"); - // Local per-user form selection, seeded from props. - const [grants, setGrants] = useState>>( - () => Object.fromEntries(members.map((m) => [m.userId, new Set(m.formIds)])), + // Local per-user, per-form access map, seeded from props. + const [access, setAccess] = useState>>( + () => Object.fromEntries(members.map((m) => [m.userId, { ...m.access }])), ); const roleLabel: Record = { @@ -90,14 +94,15 @@ export function MembersManager({ ); } - function toggleForm(m: MemberRow, formId: string) { - const next = new Set(grants[m.userId] ?? []); - if (next.has(formId)) next.delete(formId); - else next.add(formId); - setGrants((g) => ({ ...g, [m.userId]: next })); + function setFormAccess(m: MemberRow, formId: string, level: FormAccess) { + const next = { ...(access[m.userId] ?? {}) }; + if (level === "none") delete next[formId]; + else next[formId] = level; + setAccess((a) => ({ ...a, [m.userId]: next })); + const grants = Object.entries(next).map(([id, lvl]) => ({ formId: id, manage: lvl === "manage" })); void call( `/api/guilds/${guildId}/members/${m.userId}/forms`, - { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ formIds: [...next] }) }, + { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ grants }) }, m.userId, ); } @@ -186,21 +191,31 @@ export function MembersManager({ )} - {/* Per-form reviewer grants only matter for viewers (reviewers see all). */} + {/* Per-form grants only matter for viewers (reviewers already see all). */} {m.role === "viewer" && forms.length > 0 && (

{t.formAccess}

{t.formAccessHint}

{forms.map((f) => ( - toggleForm(m, f.id)} - /> +
+ + {f.title} + +
+