feat: import row validation + KB/process per-item ACLs

Continuing the audit backlog and finishing the permissions UI.

Import validation (recurring audit finding):
- Added importMembersSchema / importPaymentsCsvSchema. importMembers
  and importPaymentsCsv now validate per row (a single bad row no
  longer rejects the whole file), lowercase emails, normalize dates
  through flexibleDateTime(), enforce tier/platform enums and
  positive finite amounts, and return a `skipped` count instead of
  silently dropping invalid rows.

Permissions UI completion:
- OpsAclEditor is now wired into the KB/process viewer modal
  (admin/board), with entity type derived from the `process` tag.
  Per-item Ops access control now covers secrets + KB + processes,
  matching "all ops facilities stuff".

Left as-is with rationale in HANDOFF: /ops + /import denial paths
(unreachable behind the layout gate or already a clear non-leaking
denial); members-row make-area-lead shortcut (Customize -> Area
leads fully covers assignment); types/database.ts + docs regen
(pure hygiene, zero runtime/security impact, still tracked).

271/271 tests pass. Build clean. No migration this pass.
This commit is contained in:
Moheeb Zara
2026-05-15 18:33:53 -07:00
parent f5d670477b
commit 86d44f6efc
6 changed files with 111 additions and 54 deletions
+25
View File
@@ -430,12 +430,20 @@ function KbEntryRow({
entry,
onEdit,
onDelete,
canManageAcl,
aclRoleOptions,
aclByEntity,
}: {
entry: KbEntry
onEdit: (e: KbEntry) => void
onDelete: (id: string) => void
canManageAcl?: boolean
aclRoleOptions?: { value: string; label: string }[]
aclByEntity?: Record<string, string[]>
}) {
const [viewing, setViewing] = useState(false)
const aclEntityType: 'kb' | 'process' = entry.tags?.includes('process') ? 'process' : 'kb'
const aclInitial = aclByEntity?.[`${aclEntityType}:${entry.id}`] ?? []
async function handleDelete(e: React.MouseEvent) {
e.stopPropagation()
@@ -525,6 +533,14 @@ function KbEntryRow({
<div className="prose prose-sm prose-neutral dark:prose-invert max-w-none break-words">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{entry.content ?? ''}</ReactMarkdown>
</div>
{canManageAcl && (
<OpsAclEditor
entityType={aclEntityType}
entityId={entry.id}
options={aclRoleOptions ?? []}
initial={aclInitial}
/>
)}
</div>
</div>
</div>
@@ -671,6 +687,9 @@ export function OpsClient({ member, spaceId, kbEntries: initial, areaLeads: init
entry={entry}
onEdit={e => { setEditingKb(e); setShowKbModal(true) }}
onDelete={id => setKbEntries(prev => prev.filter(e => e.id !== id))}
canManageAcl={canManageAcl}
aclRoleOptions={aclRoleOptions}
aclByEntity={aclByEntity}
/>
))}
</div>
@@ -686,6 +705,9 @@ export function OpsClient({ member, spaceId, kbEntries: initial, areaLeads: init
entry={entry}
onEdit={e => { setEditingKb(e); setShowKbModal(true) }}
onDelete={id => setKbEntries(prev => prev.filter(e => e.id !== id))}
canManageAcl={canManageAcl}
aclRoleOptions={aclRoleOptions}
aclByEntity={aclByEntity}
/>
))}
</div>
@@ -720,6 +742,9 @@ export function OpsClient({ member, spaceId, kbEntries: initial, areaLeads: init
entry={entry}
onEdit={e => { setEditingKb(e); setShowKbModal(true) }}
onDelete={id => setKbEntries(prev => prev.filter(e => e.id !== id))}
canManageAcl={canManageAcl}
aclRoleOptions={aclRoleOptions}
aclByEntity={aclByEntity}
/>
))}
</div>