From ed8d6294720178b02e76e842745d81fc2cf2b830 Mon Sep 17 00:00:00 2001 From: Moheeb Zara Date: Sat, 16 May 2026 23:50:52 -0700 Subject: [PATCH] feat(forms): per-member 'Forms submitted' view on the members page listMemberSubmissions (forms.manage, RLS-honoring, metadata only) + a FORMS column/button -> MemberFormsDialog listing the member's submissions (title, kind, version, date) with a link to the audited forms results surface. Gated by forms.manage (matches existing submission-read RLS; no new exposure). --- app/(app)/members/members-client.tsx | 29 +++++++- app/(app)/members/page.tsx | 6 ++ components/forms/member-forms-dialog.tsx | 86 ++++++++++++++++++++++++ lib/actions/forms.ts | 39 +++++++++++ lib/validations.ts | 4 ++ 5 files changed, 162 insertions(+), 2 deletions(-) create mode 100644 components/forms/member-forms-dialog.tsx diff --git a/app/(app)/members/members-client.tsx b/app/(app)/members/members-client.tsx index f020cb4..ed7feaf 100644 --- a/app/(app)/members/members-client.tsx +++ b/app/(app)/members/members-client.tsx @@ -12,6 +12,7 @@ import { PageTitle } from '@/components/ui/page-title' import { useConfirm } from '@/components/ui/confirm' import { MemberCertificationsDialog } from '@/components/certifications/member-certifications-dialog' import { MemberCardsDialog } from '@/components/door/member-cards-dialog' +import { MemberFormsDialog } from '@/components/forms/member-forms-dialog' type Member = Tables<'space_members'> type AreaLeadRole = { id: string; area_name: string; lead_id: string | null } @@ -31,6 +32,9 @@ interface Props { // True when the viewer holds door.manage. Adds a per-member access-cards // panel (card UID is a credential; managers only). canManageCards?: boolean + // True when the viewer holds forms.manage. Adds a per-member panel + // listing the forms that member has submitted. + canViewForms?: boolean } const TIER_COLORS: Record = { @@ -42,10 +46,11 @@ const TIER_COLORS: Record = { const isAdmin = (role: string) => role === 'admin' || role === 'board' -export function MembersClient({ members: initialMembers, currentRole, areaLeadRoles = [], inviteSlot, canGrantCerts = false, canManageCards = false }: Props) { +export function MembersClient({ members: initialMembers, currentRole, areaLeadRoles = [], inviteSlot, canGrantCerts = false, canManageCards = false, canViewForms = false }: Props) { const router = useRouter() const [certMember, setCertMember] = useState(null) const [cardMember, setCardMember] = useState(null) + const [formsMember, setFormsMember] = useState(null) const confirm = useConfirm() const [members, setMembers] = useState(initialMembers) @@ -288,6 +293,9 @@ export function MembersClient({ members: initialMembers, currentRole, areaLeadRo {canManageCards && ( CARDS )} + {canViewForms && ( + FORMS + )} {isAdmin(currentRole) && ( ACTIONS )} @@ -362,6 +370,16 @@ export function MembersClient({ members: initialMembers, currentRole, areaLeadRo )} + {canViewForms && ( + + + + )} {isAdmin(currentRole) && (
@@ -407,7 +425,7 @@ export function MembersClient({ members: initialMembers, currentRole, areaLeadRo ) }) : ( - + @@ -475,6 +493,13 @@ export function MembersClient({ members: initialMembers, currentRole, areaLeadRo onClose={() => setCardMember(null)} /> )} + + {canViewForms && ( + setFormsMember(null)} + /> + )}
) } diff --git a/app/(app)/members/page.tsx b/app/(app)/members/page.tsx index e803ff6..e70a695 100644 --- a/app/(app)/members/page.tsx +++ b/app/(app)/members/page.tsx @@ -100,6 +100,11 @@ export default async function MembersPage() { sid: self.space_id, perm: 'door.manage', }) + const { data: canViewForms } = await supabase.rpc('user_has_permission', { + uid: user.id, + sid: self.space_id, + perm: 'forms.manage', + }) return ( } inviteSlot={ canInvite ? ( diff --git a/components/forms/member-forms-dialog.tsx b/components/forms/member-forms-dialog.tsx new file mode 100644 index 0000000..d460603 --- /dev/null +++ b/components/forms/member-forms-dialog.tsx @@ -0,0 +1,86 @@ +'use client' + +import { useEffect, useState, useCallback } from 'react' +import Link from 'next/link' +import { toast } from 'sonner' +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { Badge } from '@/components/ui/badge' +import { listMemberSubmissions } from '@/lib/actions' + +type Submission = { + id: string + formId: string + title: string + kind: string + version: number + submittedAt: string +} + +export function MemberFormsDialog({ + member, + onClose, +}: { + member: { id: string; display_name: string | null } | null + onClose: () => void +}) { + const open = !!member + const [loading, setLoading] = useState(false) + const [rows, setRows] = useState([]) + + const load = useCallback(async (memberId: string) => { + setLoading(true) + const res = await listMemberSubmissions({ memberId }) + setLoading(false) + if ('error' in res && res.error) { + toast.error(res.error) + return + } + setRows(((res as { data: Submission[] }).data) ?? []) + }, []) + + useEffect(() => { + if (member) load(member.id) + }, [member, load]) + + return ( + { if (!o) onClose() }}> + + + Forms submitted — {member?.display_name ?? 'Member'} + + + {loading ? ( +

Loading…

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

+ This member has no submitted forms on record. Submissions link automatically + when the submitter email matches the member. +

+ ) : ( +
    + {rows.map(s => ( +
  • +
    +
    + {s.title} + {s.kind === 'waiver' && waiver} + v{s.version} +
    +

    + {new Date(s.submittedAt).toLocaleString()} +

    +
    + + View + +
  • + ))} +
+ )} +
+
+ ) +} diff --git a/lib/actions/forms.ts b/lib/actions/forms.ts index b47ac7f..a6cde59 100644 --- a/lib/actions/forms.ts +++ b/lib/actions/forms.ts @@ -19,6 +19,7 @@ import { formIdSchema, submitFormSchema, linkSubmissionsSchema, + memberSubmissionsSchema, getPublicFormSchema, } from '@/lib/validations' import { parseFormSchema, validateAnswers } from '@/lib/forms-schema' @@ -538,3 +539,41 @@ export async function linkSubmissionsForMember(input: unknown) { ) return { data: { linked: linked?.length ?? 0 } } } + +/** + * The forms a given member has submitted (for the per-member panel on the + * members page). forms.manage gated; the gate's client honors the + * form_submissions/forms RLS (forms.manage), so no service-client bypass. + * Metadata only -- answers stay in the audited results surface. + */ +export async function listMemberSubmissions(input: unknown) { + const gate = await requireFormsManager() + if (!gate.ok) return { error: gate.error } + const { supabase, member } = gate + + const v = parseInput(memberSubmissionsSchema, input) + if (!v.ok) return { error: v.error } + + const { data, error } = await supabase + .from('form_submissions') + .select('id, form_id, form_version, created_at, forms(title, kind, slug)') + .eq('space_id', member.space_id) + .eq('member_id', v.data.memberId) + .order('created_at', { ascending: false }) + if (error) return { error: error.message } + + return { + data: (data ?? []).map(s => { + const f = s.forms as { title: string; kind: string; slug: string } | { title: string; kind: string; slug: string }[] | null + const form = Array.isArray(f) ? f[0] : f + return { + id: s.id as string, + formId: s.form_id as string, + title: form?.title ?? 'Form', + kind: form?.kind ?? 'form', + version: s.form_version as number, + submittedAt: s.created_at as string, + } + }), + } +} diff --git a/lib/validations.ts b/lib/validations.ts index e48c8fc..ffbfe09 100644 --- a/lib/validations.ts +++ b/lib/validations.ts @@ -673,6 +673,10 @@ export const linkSubmissionsSchema = z.object({ email: emailField(), }) +export const memberSubmissionsSchema = z.object({ + memberId: z.string().uuid('Invalid member ID'), +}) + export const getPublicFormSchema = z.object({ space: z.string().min(1).max(100), slug: formSlug,