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.
This commit is contained in:
Moheeb Zara
2026-05-15 17:15:14 -07:00
parent 109947552f
commit 9447b5ee80
19 changed files with 1192 additions and 29 deletions
+19 -1
View File
@@ -2,22 +2,35 @@
import { useState } from 'react'
import { RolesPanel } from './panels/roles-panel'
import { PermissionsPanel } from './panels/permissions-panel'
import { TiersPanel } from './panels/tiers-panel'
import { AreasPanel } from './panels/areas-panel'
import { AreaLeadsPanel } from './panels/area-leads-panel'
import { InvitesPanel } from './panels/invites-panel'
import { OnboardingPanel } from './panels/onboarding-panel'
import type { RoleLabelRow, CustomRole, Tier, Invite, Step, Area } from './panels/types'
type Section = 'roles' | 'tiers' | 'areas' | 'invites' | 'onboarding'
type Section = 'roles' | 'permissions' | 'tiers' | 'areas' | 'area-leads' | 'invites' | 'onboarding'
const SECTIONS: { key: Section; label: string }[] = [
{ key: 'roles', label: 'Roles' },
{ key: 'permissions', label: 'Permissions' },
{ key: 'tiers', label: 'Membership tiers' },
{ key: 'areas', label: 'Areas' },
{ key: 'area-leads', label: 'Area leads' },
{ key: 'invites', label: 'Invite codes' },
{ key: 'onboarding', label: 'Onboarding' },
]
interface AreaLead {
id: string
area_code: string | null
area_name: string
lead_id: string | null
lead_handle: string | null
status: string
}
interface Props {
isAdmin: boolean
roleLabels: RoleLabelRow[]
@@ -26,6 +39,9 @@ interface Props {
invites: Invite[]
onboardingSteps: Step[]
areas: Area[]
rolePerms: Array<{ subject: string; permission: string }>
areaLeads: AreaLead[]
members: Array<{ id: string; display_name: string | null; handle: string | null }>
}
export function CustomizeClient(props: Props) {
@@ -57,8 +73,10 @@ export function CustomizeClient(props: Props) {
<div className="flex-1 min-w-0">
{section === 'roles' && <RolesPanel isAdmin={props.isAdmin} roleLabels={props.roleLabels} customRoles={props.customRoles} />}
{section === 'permissions' && <PermissionsPanel isAdmin={props.isAdmin} customRoles={props.customRoles} rolePerms={props.rolePerms} />}
{section === 'tiers' && <TiersPanel isAdmin={props.isAdmin} tiers={props.tiers} />}
{section === 'areas' && <AreasPanel isAdmin={props.isAdmin} areas={props.areas} />}
{section === 'area-leads' && <AreaLeadsPanel isAdmin={props.isAdmin} areaLeads={props.areaLeads} members={props.members} />}
{section === 'invites' && <InvitesPanel isAdmin={props.isAdmin} invites={props.invites} />}
{section === 'onboarding' && <OnboardingPanel isAdmin={props.isAdmin} steps={props.onboardingSteps} />}
</div>
+9
View File
@@ -28,6 +28,9 @@ export default async function CustomizePage() {
{ data: invites },
{ data: onboardingSteps },
{ data: areas },
{ data: rolePerms },
{ data: areaLeads },
{ data: members },
] = await Promise.all([
supabase.from('space_role_labels').select('role, display_name, description, color').eq('space_id', member.space_id),
supabase.from('space_custom_roles').select('id, slug, name, description, color, sort_order').eq('space_id', member.space_id).order('sort_order'),
@@ -35,6 +38,9 @@ export default async function CustomizePage() {
supabase.from('space_invites').select('id, code, label, expires_at, max_uses, uses_count, is_enabled, created_at').eq('space_id', member.space_id).order('created_at', { ascending: false }),
supabase.from('space_onboarding_steps').select('id, step_key, step_type, title, body, config, is_enabled, is_required, is_system, sort_order').eq('space_id', member.space_id).order('sort_order'),
supabase.from('space_areas').select('id, code, name, icon, sort_order, is_archived').eq('space_id', member.space_id).order('sort_order').order('name'),
supabase.from('space_role_permissions').select('subject, permission').eq('space_id', member.space_id),
supabase.from('area_leads').select('id, area_code, area_name, lead_id, lead_handle, status').eq('space_id', member.space_id).order('area_name'),
supabase.from('space_members').select('id, display_name, handle').eq('space_id', member.space_id).in('status', ['current', 'unverified', 'late']).order('display_name'),
])
return (
@@ -46,6 +52,9 @@ export default async function CustomizePage() {
invites={(invites ?? []) as Array<{ id: string; code: string; label: string | null; expires_at: string | null; max_uses: number | null; uses_count: number; is_enabled: boolean; created_at: string }>}
onboardingSteps={(onboardingSteps ?? []) as Array<{ id: string; step_key: string; step_type: 'welcome' | 'code_of_conduct' | 'profile' | 'payment' | 'content'; title: string; body: string | null; config: Record<string, unknown>; is_enabled: boolean; is_required: boolean; is_system: boolean; sort_order: number }>}
areas={(areas ?? []) as Array<{ id: string; code: string; name: string; icon: string | null; sort_order: number; is_archived: boolean }>}
rolePerms={(rolePerms ?? []) as Array<{ subject: string; permission: string }>}
areaLeads={(areaLeads ?? []) as Array<{ id: string; area_code: string | null; area_name: string; lead_id: string | null; lead_handle: string | null; status: string }>}
members={(members ?? []) as Array<{ id: string; display_name: string | null; handle: string | null }>}
/>
)
}
@@ -0,0 +1,129 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { toast } from 'sonner'
import { createAreaLeadRole, assignAreaLead, unassignAreaLead, deleteAreaLeadRole } from '@/lib/actions'
import { Card } from './card'
interface AreaLead {
id: string
area_code: string | null
area_name: string
lead_id: string | null
lead_handle: string | null
status: string
}
interface Member { id: string; display_name: string | null; handle: string | null }
interface Props {
isAdmin: boolean
areaLeads: AreaLead[]
members: Member[]
}
export function AreaLeadsPanel({ isAdmin, areaLeads: initial, members }: Props) {
const router = useRouter()
const [rows, setRows] = useState<AreaLead[]>(initial)
const [showNew, setShowNew] = useState(false)
const [d, setD] = useState({ area_code: '', name: '' })
function memberLabel(id: string | null) {
if (!id) return null
const m = members.find(x => x.id === id)
return m ? (m.display_name || m.handle || 'Member') : 'Member'
}
return (
<Card
title="Area-lead roles"
blurb="Each row is an area-lead role. Unassigned roles show as Vacant. Assign a member here (or from the member directory). The assigned member effectively holds this role, so any Ops item whose access list includes it becomes available to them."
action={isAdmin ? (
<button onClick={() => setShowNew(v => !v)} className="font-mono text-[10px] border border-border px-3 py-1.5 rounded hover:border-primary hover:text-primary transition whitespace-nowrap">
{showNew ? 'Cancel' : '+ Area-lead role'}
</button>
) : undefined}
>
{showNew && (
<form
onSubmit={async e => {
e.preventDefault()
const result = await createAreaLeadRole({ area_code: d.area_code.trim().toLowerCase(), name: d.name.trim() })
if ('error' in result && result.error) { toast.error(result.error); return }
toast.success('Area-lead role created')
const created = (result as { data?: AreaLead }).data
if (created) setRows(prev => [...prev, created])
setShowNew(false); setD({ area_code: '', name: '' })
}}
className="mb-4 p-4 border border-border rounded bg-background grid grid-cols-1 md:grid-cols-2 gap-3"
>
<input type="text" required value={d.area_code} onChange={e => setD({ ...d, area_code: e.target.value.toLowerCase().replace(/[^a-z0-9_-]/g, '-') })} placeholder="area code (e.g. woodshop)" className="bg-background border border-border text-foreground font-mono text-xs rounded px-2 py-1.5 focus:outline-none focus:border-primary" />
<input type="text" required value={d.name} onChange={e => setD({ ...d, name: e.target.value })} placeholder="Role name (e.g. Woodshop Lead)" className="bg-background border border-border text-foreground font-sans text-sm rounded px-2 py-1.5 focus:outline-none focus:border-primary" />
<button type="submit" className="md:col-span-2 bg-primary text-white text-xs font-sans px-3 py-1.5 rounded hover:bg-primary/90 transition">Create</button>
</form>
)}
{rows.length === 0 ? (
<p className="font-sans text-sm text-muted-foreground py-2">No area-lead roles yet.</p>
) : (
<ul className="divide-y divide-border">
{rows.map(r => (
<li key={r.id} className="py-3 flex items-center gap-3 flex-wrap">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-sans text-sm font-medium text-foreground">{r.area_name}</span>
{r.area_code && <span className="font-mono text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground">{r.area_code}</span>}
{r.lead_id ? (
<span className="font-mono text-[10px] text-primary">{memberLabel(r.lead_id)}</span>
) : (
<span className="font-mono text-[10px] text-amber-600">Vacant</span>
)}
</div>
</div>
{isAdmin && (
<div className="flex items-center gap-1.5">
<select
value={r.lead_id ?? ''}
onChange={async e => {
const memberId = e.target.value
if (!memberId) {
const res = await unassignAreaLead(r.id)
if (res.error) { toast.error(res.error); return }
setRows(prev => prev.map(x => x.id === r.id ? { ...x, lead_id: null, lead_handle: null, status: 'vacant' } : x))
toast.success('Set to vacant')
} else {
const res = await assignAreaLead({ area_lead_role_id: r.id, member_id: memberId })
if (res.error) { toast.error(res.error); return }
setRows(prev => prev.map(x => x.id === r.id ? { ...x, lead_id: memberId, status: 'active' } : x))
toast.success('Lead assigned')
router.refresh()
}
}}
className="bg-background border border-border text-foreground font-sans text-xs rounded px-2 py-1 focus:outline-none focus:border-primary"
>
<option value="">Vacant</option>
{members.map(m => (
<option key={m.id} value={m.id}>{m.display_name || m.handle || 'Member'}</option>
))}
</select>
<button
onClick={async () => {
if (!confirm(`Delete area-lead role "${r.area_name}"?`)) return
const res = await deleteAreaLeadRole(r.id)
if (res.error) { toast.error(res.error); return }
setRows(prev => prev.filter(x => x.id !== r.id))
toast.success('Deleted')
}}
className="font-mono text-[10px] border border-border px-2 py-1 rounded hover:border-red-500 hover:text-red-500 transition"
>
Delete
</button>
</div>
)}
</li>
))}
</ul>
)}
</Card>
)
}
@@ -0,0 +1,130 @@
'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>
))}
</>
)
}