Files
hackerspace.sh/app/(app)/customize/panels/permissions-panel.tsx
T
Moheeb Zara 9447b5ee80 feat: customizable permissions, per-item Ops ACLs, area-lead roles
Migration 023 (folded into schema.sql section 15). ADDITIVE and
backward compatible: with no permission/ACL rows the app behaves
exactly as before.

Schema:
- space_role_permissions (space_id, subject, permission); ops_acl
  (space_id, entity_type secret|kb|process|area_lead, entity_id,
  role). Full RLS, writes admin/board.
- user_effective_roles(uid,sid): built-in role + custom-role slugs +
  area_lead:<id> for areas the member leads. SECURITY DEFINER.
- user_has_permission(uid,sid,perm): admin implicit-all else a grant
  via an effective role. SECURITY DEFINER.
- secrets/knowledge_base SELECT rewritten as
  `exact existing rule OR ops_acl match` (only widens; zero change
  with no ACL rows). Seed + backfill default role permissions.

Area-lead roles reuse the existing area_leads table rather than
duplicating the concept: a row is a role, lead_id NULL = Vacant,
assigning sets lead_id, the member then holds area_lead:<id>.

App:
- lib/permissions-catalog.ts (codes + per-role defaults + guardrails),
  Zod schemas, lib/actions/permissions.ts (setRolePermissions,
  setOpsAcl, createAreaLeadRole, assign/unassign/delete).
- Customize gains Permissions (role x permission matrix, admin shown
  implicit-all/locked) and Area leads (create/Vacant/assign) panels,
  modular under customize/panels/. Reusable
  components/ops/ops-acl-editor.tsx wired into the Secrets list.

Audit-found latent fixes:
- revealSecret errors on encrypted-but-no-ciphertext corruption
  instead of silently returning the stale plaintext column.
- upsertAreaLead rejects null/empty area_code (NULLs are distinct in
  unique constraints; allowed duplicate rows).

types/database.ts: added the two new tables. 271/271 tests pass,
build clean. KB-modal ACL wiring + members-row shortcut deferred and
tracked in HANDOFF.
2026-05-15 17:15:14 -07:00

131 lines
4.6 KiB
TypeScript

'use client'
import { useState } from 'react'
import { toast } from 'sonner'
import { setRolePermissions } from '@/lib/actions'
import { PERMISSIONS, PERMISSION_GROUPS } from '@/lib/permissions-catalog'
import { BUILTIN_ROLES } from '@/lib/role-labels'
import { Card } from './card'
import type { CustomRole } from './types'
interface Props {
isAdmin: boolean
customRoles: CustomRole[]
rolePerms: Array<{ subject: string; permission: string }>
}
export function PermissionsPanel({ isAdmin, customRoles, rolePerms }: Props) {
// subjects: built-in roles except admin (implicit-all), plus custom roles.
const subjects = [
...BUILTIN_ROLES.filter(r => r !== 'admin').map(r => ({ key: r, label: r })),
...customRoles.map(c => ({ key: c.slug, label: c.name })),
]
const initial: Record<string, Set<string>> = {}
for (const s of subjects) initial[s.key] = new Set()
for (const rp of rolePerms) {
if (!initial[rp.subject]) initial[rp.subject] = new Set()
initial[rp.subject].add(rp.permission)
}
const [grants, setGrants] = useState(initial)
const [savingSubject, setSavingSubject] = useState<string | null>(null)
async function toggle(subject: string, permission: string, on: boolean) {
const next = new Set(grants[subject] ?? [])
if (on) next.add(permission)
else next.delete(permission)
setGrants(g => ({ ...g, [subject]: next }))
setSavingSubject(subject)
const result = await setRolePermissions({ subject, permissions: Array.from(next) })
setSavingSubject(null)
if (result.error) {
toast.error(result.error)
// revert
setGrants(g => {
const back = new Set(g[subject] ?? [])
if (on) back.delete(permission)
else back.add(permission)
return { ...g, [subject]: back }
})
}
}
return (
<Card
title="Permissions"
blurb="Grant capabilities to each role. Admin always holds every permission and cannot be locked out. Permissions widen what a role can do through the surfaces that consult them; they never override the database tenant isolation."
>
<div className="mb-4 flex items-center gap-2 flex-wrap">
<span className="font-mono text-[10px] px-1.5 py-0.5 rounded bg-primary/10 text-primary">admin</span>
<span className="font-sans text-xs text-muted-foreground">holds all permissions (implicit, locked)</span>
</div>
<div className="overflow-x-auto">
<table className="w-full border-collapse">
<thead>
<tr>
<th className="text-left font-mono text-[10px] tracking-widest text-muted-foreground uppercase py-2 pr-4 sticky left-0 bg-card">Permission</th>
{subjects.map(s => (
<th key={s.key} className="px-2 py-2 font-mono text-[10px] text-muted-foreground text-center min-w-[88px]">
{s.label}{savingSubject === s.key && <span className="text-primary"> …</span>}
</th>
))}
</tr>
</thead>
<tbody>
{PERMISSION_GROUPS.map(group => (
<PermGroup
key={group}
group={group}
subjects={subjects}
grants={grants}
isAdmin={isAdmin}
onToggle={toggle}
/>
))}
</tbody>
</table>
</div>
</Card>
)
}
function PermGroup({
group, subjects, grants, isAdmin, onToggle,
}: {
group: string
subjects: { key: string; label: string }[]
grants: Record<string, Set<string>>
isAdmin: boolean
onToggle: (subject: string, permission: string, on: boolean) => void
}) {
const perms = PERMISSIONS.filter(p => p.group === group)
return (
<>
<tr>
<td colSpan={subjects.length + 1} className="pt-4 pb-1">
<span className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase">{group}</span>
</td>
</tr>
{perms.map(p => (
<tr key={p.code} className="border-t border-border">
<td className="py-2 pr-4 sticky left-0 bg-card">
<span className="font-sans text-sm text-foreground">{p.label}</span>
<span className="font-mono text-[10px] text-muted-foreground/60 ml-2">{p.code}</span>
</td>
{subjects.map(s => (
<td key={s.key} className="px-2 py-2 text-center">
<input
type="checkbox"
disabled={!isAdmin}
checked={grants[s.key]?.has(p.code) ?? false}
onChange={e => onToggle(s.key, p.code, e.target.checked)}
/>
</td>
))}
</tr>
))}
</>
)
}