diff --git a/app/(app)/members/components/member-form-dialog.tsx b/app/(app)/members/components/member-form-dialog.tsx new file mode 100644 index 0000000..56b5044 --- /dev/null +++ b/app/(app)/members/components/member-form-dialog.tsx @@ -0,0 +1,109 @@ +'use client' + +import type { Dispatch, SetStateAction } from 'react' +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import type { MemberForm } from '../types' + +// Top-level (NOT defined inside the render body): keeping this stable is what +// preserves input focus across keystrokes. The add and edit dialogs both render it. +function MemberFormFields({ form, setForm }: { form: MemberForm; setForm: Dispatch> }) { + return ( + <> +
+
+ + setForm(f => ({ ...f, display_name: e.target.value }))} + className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary transition" /> +
+
+ + setForm(f => ({ ...f, email: e.target.value }))} + className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary transition" /> +
+
+
+
+ + setForm(f => ({ ...f, phone: e.target.value }))} + className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary transition" /> +
+
+ + setForm(f => ({ ...f, handle: e.target.value }))} + className="w-full bg-background border border-border rounded px-3 py-2 font-mono text-sm text-foreground focus:outline-none focus:border-primary transition" /> +
+
+
+
+ + +
+
+ + +
+
+
+
+ + setForm(f => ({ ...f, joined_at: e.target.value }))} + className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary" /> +
+
+ +
+
+ + ) +} + +interface MemberFormDialogProps { + open: boolean + // Drives title + submit-button copy; add and edit share one form-state shape. + isEdit: boolean + memberName?: string | null + form: MemberForm + setForm: Dispatch> + loading: boolean + error: string + onSubmit: (e: React.FormEvent) => void + onClose: () => void +} + +export function MemberFormDialog({ open, isEdit, memberName, form, setForm, loading, error, onSubmit, onClose }: MemberFormDialogProps) { + return ( + { if (!o) onClose() }}> + + + {isEdit ? <>Edit {memberName} : 'Add Member'} + +
+ + {error &&

{error}

} +
+ + +
+ +
+
+ ) +} diff --git a/app/(app)/members/components/members-table.tsx b/app/(app)/members/components/members-table.tsx new file mode 100644 index 0000000..a359470 --- /dev/null +++ b/app/(app)/members/components/members-table.tsx @@ -0,0 +1,344 @@ +'use client' + +import { useState, type Dispatch, type SetStateAction } from 'react' +import { Users } from 'lucide-react' +import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription } from '@/components/ui/empty' +import type { Member, SortKey, AreaLeadRole } from '../types' + +const TIER_COLORS: Record = { + plus: 'text-blue-600 bg-blue-50 border-blue-200', + basic: 'text-muted-foreground bg-muted border-border', + associate: 'text-purple-600 bg-purple-50 border-purple-200', + admin: 'text-primary bg-primary/5 border-primary/20', +} + +interface MembersTableProps { + // Already filtered by the orchestrator; the table owns only its sort order. + members: Member[] + isAdmin: boolean + canGrantCerts: boolean + canManageCards: boolean + canViewForms: boolean + areaLeadRoles: AreaLeadRole[] + selected: Set + setSelected: Dispatch>> + toggleSel: (id: string) => void + onApprove: (memberId: string) => void + onRemove: (memberId: string) => void + onEdit: (m: Member) => void + onCerts: (m: Member) => void + onCards: (m: Member) => void + onForms: (m: Member) => void + onMakeAreaLead: (memberId: string, areaLeadRoleId: string) => void +} + +export function MembersTable({ + members: filteredMembers, + isAdmin, + canGrantCerts, + canManageCards, + canViewForms, + areaLeadRoles, + selected, + setSelected, + toggleSel, + onApprove, + onRemove, + onEdit, + onCerts, + onCards, + onForms, + onMakeAreaLead, +}: MembersTableProps) { + const [sort, setSort] = useState<{ key: SortKey; dir: 'asc' | 'desc' } | null>(null) + + const sortedMembers = sort + ? [...filteredMembers].sort((a, b) => { + const dir = sort.dir === 'asc' ? 1 : -1 + const val = (m: Member): string => { + switch (sort.key) { + case 'name': return (m.display_name ?? '').toLowerCase() + case 'tier': return m.tier ?? '' + case 'status': return m.status ?? '' + case 'joined': return m.joined_at ?? '' + case 'last_payment': return (m.last_paid_at || m.last_payment_at || '') as string + default: return '' + } + } + return val(a).localeCompare(val(b)) * dir + }) + : filteredMembers + + function toggleSort(key: SortKey) { + setSort(s => + s && s.key === key + ? (s.dir === 'asc' ? { key, dir: 'desc' } : null) + : { key, dir: 'asc' }, + ) + } + const caret = (key: SortKey) => (sort?.key === key ? (sort.dir === 'asc' ? ' ↑' : ' ↓') : '') + + return ( +
+
+ + + + {isAdmin && ( + + )} + {([ + ['name', 'MEMBER', ''], + ['tier', 'TIER', ''], + ['joined', 'JOINED', 'hidden md:table-cell'], + ['last_payment', 'LAST PAYMENT', 'hidden lg:table-cell'], + ['status', 'STATUS', ''], + ] as [SortKey, string, string][]).map(([key, label, cls]) => ( + + ))} + {canGrantCerts && ( + + )} + {canManageCards && ( + + )} + {canViewForms && ( + + )} + {isAdmin && ( + + )} + + + + {sortedMembers.length > 0 ? sortedMembers.map(m => { + const initials = (m.display_name || 'U').split(' ').map((n: string) => n[0]).join('').toUpperCase().slice(0, 2) + const hasIssue = m.payment_status && m.payment_status !== 'current' + return ( + + {isAdmin && ( + + )} + + + + + + {canGrantCerts && ( + + )} + {canManageCards && ( + + )} + {canViewForms && ( + + )} + {isAdmin && ( + + )} + + ) + }) : ( + + + + )} + +
+ 0 && sortedMembers.every(m => selected.has(m.id))} + onChange={e => + setSelected(e.target.checked ? new Set(sortedMembers.map(m => m.id)) : new Set()) + } + /> + + + CERTSCARDSFORMSACTIONS
+ toggleSel(m.id)} + /> + +
+
+ {initials} +
+
+

{m.display_name}

+

+ {m.email}{m.handle ? ` · @${m.handle}` : ''}{m.payment_note ? ` — ${m.payment_note}` : ''} +

+
+
+
+ + {m.tier?.toUpperCase()} + + + {m.joined_at ? new Date(m.joined_at).toLocaleDateString('en-US', { month: 'short', year: 'numeric' }) : '-'} + + {(m.last_paid_at || m.last_payment_at) ? new Date((m.last_paid_at || m.last_payment_at)!).toLocaleDateString('en-US', { month: 'short', year: 'numeric' }) : '-'} + + {m.status === 'unverified' ? ( + + unverified + + ) : m.payment_status === 'current' ? ( + + current + + ) : m.payment_status ? ( + + {m.payment_status} + + ) : ( + {m.status} + )} + + + + + + + +
+ {m.status === 'unverified' && ( + + )} + + + {areaLeadRoles.length > 0 && ( + + )} +
+
+ + + + No members match this filter + Try a different tab, clear the search, or change the tier filter. + + +
+
+ + {/* Mobile: stacked cards (the table is unusable on phones). */} +
+ {sortedMembers.length > 0 ? sortedMembers.map(m => { + const hasIssue = m.payment_status && m.payment_status !== 'current' + return ( +
+
+
+ {isAdmin && ( + toggleSel(m.id)} + /> + )} +
+

{m.display_name}

+

{m.email}

+
+
+ + {m.tier?.toUpperCase()} + +
+

+ {m.status === 'unverified' ? 'unverified' : (m.payment_status || m.status)} + {m.joined_at ? ` · joined ${new Date(m.joined_at).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })}` : ''} +

+
+ {isAdmin && m.status === 'unverified' && ( + + )} + {isAdmin && ( + + )} + {canGrantCerts && ( + + )} + {canManageCards && ( + + )} + {canViewForms && ( + + )} + {isAdmin && ( + + )} +
+
+ ) + }) : ( + + + + No members match this filter + Try a different tab, clear the search, or change the tier filter. + + + )} +
+
+ ) +} diff --git a/app/(app)/members/components/members-toolbar.tsx b/app/(app)/members/components/members-toolbar.tsx new file mode 100644 index 0000000..b5106f1 --- /dev/null +++ b/app/(app)/members/components/members-toolbar.tsx @@ -0,0 +1,97 @@ +'use client' + +import type { Dispatch, SetStateAction } from 'react' +import type { MemberTab } from '../types' + +interface MembersTabsProps { + activeTab: MemberTab + setActiveTab: Dispatch> + total: number + paymentIssues: number + unverified: number +} + +// Full-bleed tab bar (sits directly under the page header, outside the padded +// content region), so it is its own component rather than part of the filters. +export function MembersTabs({ activeTab, setActiveTab, total, paymentIssues, unverified }: MembersTabsProps) { + return ( +
+ {[ + { key: 'all', label: `All ${total}` }, + { key: 'payment_issues', label: `Payment Issues ${paymentIssues}` }, + { key: 'unverified', label: `Pending Approval ${unverified}` }, + { key: 'inactive', label: 'Inactive' }, + ].map(({ key, label }) => ( + + ))} +
+ ) +} + +interface MembersToolbarProps { + search: string + setSearch: Dispatch> + tierFilter: string + setTierFilter: Dispatch> + canBulk: boolean + selectedCount: number + onBulkApprove: () => void + onClearSelection: () => void +} + +// Search + tier filter row, plus the bulk-actions bar shown when a selection +// exists. Renders as a fragment so the surrounding padded content div is +// unchanged. +export function MembersToolbar({ search, setSearch, tierFilter, setTierFilter, canBulk, selectedCount, onBulkApprove, onClearSelection }: MembersToolbarProps) { + return ( + <> +
+
+ + + + setSearch(e.target.value)} + className="w-full bg-card border border-border rounded pl-9 pr-4 py-2 font-sans text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-primary transition" + /> +
+ +
+ + {canBulk && selectedCount > 0 && ( +
+ {selectedCount} selected + + + + +
+ )} + + ) +} diff --git a/app/(app)/members/members-client.tsx b/app/(app)/members/members-client.tsx index 7fd9409..840b66c 100644 --- a/app/(app)/members/members-client.tsx +++ b/app/(app)/members/members-client.tsx @@ -1,59 +1,54 @@ 'use client' -import { useState, type ReactNode } from 'react' -import { Plus, ChevronDown, Users } from 'lucide-react' -import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription } from '@/components/ui/empty' -import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { useState } from 'react' +import { Plus } from 'lucide-react' import { addMember, updateMember, approveMember, bulkApproveMembers, removeMember, assignAreaLead } from '@/lib/actions' import { toast } from 'sonner' import { useRouter } from 'next/navigation' -import type { Tables } from '@/types/database' 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 SortKey = 'name' | 'tier' | 'joined' | 'last_payment' | 'status' -type AreaLeadRole = { id: string; area_name: string; lead_id: string | null } - -interface Props { - members: Member[] - currentRole: string - areaLeadRoles?: AreaLeadRole[] - // Composed by the server page for admin/board: the existing InvitesPanel, - // so the members page is a real place to create and share a join link - // (the first-run "Invite or add members" step lands here). - inviteSlot?: ReactNode - // True when the viewer holds certifications.grant (the Instructor - // capability), independent of admin/board. Adds a per-member - // certifications panel reachable by non-admin instructors. - canGrantCerts?: boolean - // 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 = { - plus: 'text-blue-600 bg-blue-50 border-blue-200', - basic: 'text-muted-foreground bg-muted border-border', - associate: 'text-purple-600 bg-purple-50 border-purple-200', - admin: 'text-primary bg-primary/5 border-primary/20', -} +import { MembersTabs, MembersToolbar } from './components/members-toolbar' +import { MembersTable } from './components/members-table' +import { MemberFormDialog } from './components/member-form-dialog' +import type { Member, MemberForm, MemberTab, MembersClientProps } from './types' const isAdmin = (role: string) => role === 'admin' || role === 'board' -export function MembersClient({ members: initialMembers, currentRole, areaLeadRoles = [], inviteSlot, canGrantCerts = false, canManageCards = false, canViewForms = false }: Props) { +export function MembersClient({ members: initialMembers, currentRole, areaLeadRoles = [], inviteSlot, canGrantCerts = false, canManageCards = false, canViewForms = false }: MembersClientProps) { const router = useRouter() + const confirm = useConfirm() + const admin = isAdmin(currentRole) + + const [members, setMembers] = useState(initialMembers) const [certMember, setCertMember] = useState(null) const [cardMember, setCardMember] = useState(null) const [formsMember, setFormsMember] = useState(null) - const confirm = useConfirm() - const [members, setMembers] = useState(initialMembers) + + const [activeTab, setActiveTab] = useState('all') + const [search, setSearch] = useState('') + const [tierFilter, setTierFilter] = useState('') + const [selected, setSelected] = useState>(new Set()) + + const [showAdd, setShowAdd] = useState(false) + const [editMember, setEditMember] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + + // Add/Edit form + const [form, setForm] = useState({ + display_name: '', email: '', phone: '', handle: '', + tier: 'basic', role: 'member', joined_at: '', has_card_access: false, + }) + + const toggleSel = (id: string) => + setSelected(prev => { + const n = new Set(prev) + n.has(id) ? n.delete(id) : n.add(id) + return n + }) async function makeAreaLead(memberId: string, areaLeadRoleId: string) { if (!areaLeadRoleId) return @@ -62,18 +57,7 @@ export function MembersClient({ members: initialMembers, currentRole, areaLeadRo toast.success('Assigned as area lead') router.refresh() } - const [activeTab, setActiveTab] = useState<'all' | 'payment_issues' | 'unverified' | 'inactive'>('all') - const [search, setSearch] = useState('') - const [tierFilter, setTierFilter] = useState('') - const [sort, setSort] = useState<{ key: SortKey; dir: 'asc' | 'desc' } | null>(null) - const [selected, setSelected] = useState>(new Set()) - const canBulk = isAdmin(currentRole) - const toggleSel = (id: string) => - setSelected(prev => { - const n = new Set(prev) - n.has(id) ? n.delete(id) : n.add(id) - return n - }) + async function bulkApprove() { const ids = [...selected] if (ids.length === 0) return @@ -84,16 +68,6 @@ export function MembersClient({ members: initialMembers, currentRole, areaLeadRo setSelected(new Set()) toast.success(n > 0 ? `Approved ${n} member(s)` : 'No unverified members in the selection') } - const [showAdd, setShowAdd] = useState(false) - const [editMember, setEditMember] = useState(null) - const [loading, setLoading] = useState(false) - const [error, setError] = useState('') - - // Add/Edit form - const [form, setForm] = useState({ - display_name: '', email: '', phone: '', handle: '', - tier: 'basic', role: 'member', joined_at: '', has_card_access: false, - }) const total = members.length const paymentIssues = members.filter(m => m.status === 'current' && m.payment_status && m.payment_status !== 'current').length @@ -120,32 +94,6 @@ export function MembersClient({ members: initialMembers, currentRole, areaLeadRo return matchesTab && matchesSearch && matchesTier }) - const sortedMembers = sort - ? [...filteredMembers].sort((a, b) => { - const dir = sort.dir === 'asc' ? 1 : -1 - const val = (m: Member): string => { - switch (sort.key) { - case 'name': return (m.display_name ?? '').toLowerCase() - case 'tier': return m.tier ?? '' - case 'status': return m.status ?? '' - case 'joined': return m.joined_at ?? '' - case 'last_payment': return (m.last_paid_at || m.last_payment_at || '') as string - default: return '' - } - } - return val(a).localeCompare(val(b)) * dir - }) - : filteredMembers - - function toggleSort(key: SortKey) { - setSort(s => - s && s.key === key - ? (s.dir === 'asc' ? { key, dir: 'desc' } : null) - : { key, dir: 'asc' }, - ) - } - const caret = (key: SortKey) => (sort?.key === key ? (sort.dir === 'asc' ? ' ↑' : ' ↓') : '') - async function handleAdd(e: React.FormEvent) { e.preventDefault() setLoading(true) @@ -198,71 +146,6 @@ export function MembersClient({ members: initialMembers, currentRole, areaLeadRo setError('') } - const MemberFormFields = () => ( - <> -
-
- - setForm(f => ({ ...f, display_name: e.target.value }))} - className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary transition" /> -
-
- - setForm(f => ({ ...f, email: e.target.value }))} - className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary transition" /> -
-
-
-
- - setForm(f => ({ ...f, phone: e.target.value }))} - className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary transition" /> -
-
- - setForm(f => ({ ...f, handle: e.target.value }))} - className="w-full bg-background border border-border rounded px-3 py-2 font-mono text-sm text-foreground focus:outline-none focus:border-primary transition" /> -
-
-
-
- - -
-
- - -
-
-
-
- - setForm(f => ({ ...f, joined_at: e.target.value }))} - className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary" /> -
-
- -
-
- - ) - return (
@@ -270,7 +153,7 @@ export function MembersClient({ members: initialMembers, currentRole, areaLeadRo Members {total} total
- {isAdmin(currentRole) && ( + {admin && (
-
- {[ - { key: 'all', label: `All ${total}` }, - { key: 'payment_issues', label: `Payment Issues ${paymentIssues}` }, - { key: 'unverified', label: `Pending Approval ${unverified}` }, - { key: 'inactive', label: 'Inactive' }, - ].map(({ key, label }) => ( - - ))} -
+
{inviteSlot &&
{inviteSlot}
} -
-
- - - - setSearch(e.target.value)} - className="w-full bg-card border border-border rounded pl-9 pr-4 py-2 font-sans text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-primary transition" - /> -
- -
- {canBulk && selected.size > 0 && ( -
- {selected.size} selected - - - - -
- )} + setSelected(new Set())} + /> -
-
- - - - {canBulk && ( - - )} - {([ - ['name', 'MEMBER', ''], - ['tier', 'TIER', ''], - ['joined', 'JOINED', 'hidden md:table-cell'], - ['last_payment', 'LAST PAYMENT', 'hidden lg:table-cell'], - ['status', 'STATUS', ''], - ] as [SortKey, string, string][]).map(([key, label, cls]) => ( - - ))} - {canGrantCerts && ( - - )} - {canManageCards && ( - - )} - {canViewForms && ( - - )} - {isAdmin(currentRole) && ( - - )} - - - - {sortedMembers.length > 0 ? sortedMembers.map(m => { - const initials = (m.display_name || 'U').split(' ').map((n: string) => n[0]).join('').toUpperCase().slice(0, 2) - const hasIssue = m.payment_status && m.payment_status !== 'current' - return ( - - {canBulk && ( - - )} - - - - - - {canGrantCerts && ( - - )} - {canManageCards && ( - - )} - {canViewForms && ( - - )} - {isAdmin(currentRole) && ( - - )} - - ) - }) : ( - - - - )} - -
- 0 && sortedMembers.every(m => selected.has(m.id))} - onChange={e => - setSelected(e.target.checked ? new Set(sortedMembers.map(m => m.id)) : new Set()) - } - /> - - - CERTSCARDSFORMSACTIONS
- toggleSel(m.id)} - /> - -
-
- {initials} -
-
-

{m.display_name}

-

- {m.email}{m.handle ? ` · @${m.handle}` : ''}{m.payment_note ? ` — ${m.payment_note}` : ''} -

-
-
-
- - {m.tier?.toUpperCase()} - - - {m.joined_at ? new Date(m.joined_at).toLocaleDateString('en-US', { month: 'short', year: 'numeric' }) : '-'} - - {(m.last_paid_at || m.last_payment_at) ? new Date((m.last_paid_at || m.last_payment_at)!).toLocaleDateString('en-US', { month: 'short', year: 'numeric' }) : '-'} - - {m.status === 'unverified' ? ( - - unverified - - ) : m.payment_status === 'current' ? ( - - current - - ) : m.payment_status ? ( - - {m.payment_status} - - ) : ( - {m.status} - )} - - - - - - - -
- {m.status === 'unverified' && ( - - )} - - - {areaLeadRoles.length > 0 && ( - - )} -
-
- - - - No members match this filter - Try a different tab, clear the search, or change the tier filter. - - -
-
- - {/* Mobile: stacked cards (the table is unusable on phones). */} -
- {sortedMembers.length > 0 ? sortedMembers.map(m => { - const hasIssue = m.payment_status && m.payment_status !== 'current' - return ( -
-
-
- {canBulk && ( - toggleSel(m.id)} - /> - )} -
-

{m.display_name}

-

{m.email}

-
-
- - {m.tier?.toUpperCase()} - -
-

- {m.status === 'unverified' ? 'unverified' : (m.payment_status || m.status)} - {m.joined_at ? ` · joined ${new Date(m.joined_at).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })}` : ''} -

-
- {isAdmin(currentRole) && m.status === 'unverified' && ( - - )} - {isAdmin(currentRole) && ( - - )} - {canGrantCerts && ( - - )} - {canManageCards && ( - - )} - {canViewForms && ( - - )} - {isAdmin(currentRole) && ( - - )} -
-
- ) - }) : ( - - - - No members match this filter - Try a different tab, clear the search, or change the tier filter. - - - )} -
-
+
{/* Add Member Modal */} - { if (!o) setShowAdd(false) }}> - - - Add Member - -
- - {error &&

{error}

} -
- - -
- -
-
+ setShowAdd(false)} + /> {/* Edit Member Modal */} - { if (!o) setEditMember(null) }}> - - - Edit {editMember?.display_name} - -
- - {error &&

{error}

} -
- - -
- -
-
+ setEditMember(null)} + /> {canGrantCerts && ( + +export type SortKey = 'name' | 'tier' | 'joined' | 'last_payment' | 'status' + +export type MemberTab = 'all' | 'payment_issues' | 'unverified' | 'inactive' + +export type AreaLeadRole = { id: string; area_name: string; lead_id: string | null } + +// The add/edit form shares one state shape; both dialogs write into it. +export interface MemberForm { + display_name: string + email: string + phone: string + handle: string + tier: string + role: string + joined_at: string + has_card_access: boolean +} + +export interface MembersClientProps { + members: Member[] + currentRole: string + areaLeadRoles?: AreaLeadRole[] + // Composed by the server page for admin/board: the existing InvitesPanel, + // so the members page is a real place to create and share a join link + // (the first-run "Invite or add members" step lands here). + inviteSlot?: ReactNode + // True when the viewer holds certifications.grant (the Instructor + // capability), independent of admin/board. Adds a per-member + // certifications panel reachable by non-admin instructors. + canGrantCerts?: boolean + // 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 +}