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:
@@ -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>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user