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,74 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { setOpsAcl } from '@/lib/actions'
|
||||
|
||||
export interface AclRoleOption { value: string; label: string }
|
||||
|
||||
interface Props {
|
||||
entityType: 'secret' | 'kb' | 'process'
|
||||
entityId: string
|
||||
options: AclRoleOption[]
|
||||
initial: string[]
|
||||
}
|
||||
|
||||
// Toggling roles here writes ops_acl. An empty selection means the item
|
||||
// falls back to its default visibility (admin/board for secrets; the
|
||||
// visibility column for KB). Selecting roles WIDENS read access to those
|
||||
// roles in addition to the default.
|
||||
export function OpsAclEditor({ entityType, entityId, options, initial }: Props) {
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set(initial))
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
async function toggle(value: string, on: boolean) {
|
||||
const next = new Set(selected)
|
||||
if (on) next.add(value)
|
||||
else next.delete(value)
|
||||
setSelected(next)
|
||||
setSaving(true)
|
||||
const result = await setOpsAcl({ entity_type: entityType, entity_id: entityId, roles: Array.from(next) })
|
||||
setSaving(false)
|
||||
if (result.error) {
|
||||
toast.error(result.error)
|
||||
setSelected(prev => {
|
||||
const back = new Set(prev)
|
||||
if (on) back.delete(value)
|
||||
else back.add(value)
|
||||
return back
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border border-border rounded p-3 bg-background/60 mt-2">
|
||||
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase mb-2">
|
||||
Who can access {saving && <span className="text-primary">…</span>}
|
||||
</p>
|
||||
{options.length === 0 ? (
|
||||
<p className="font-sans text-xs text-muted-foreground">No roles available.</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{options.map(o => {
|
||||
const on = selected.has(o.value)
|
||||
return (
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
onClick={() => toggle(o.value, !on)}
|
||||
className={`font-mono text-[10px] px-2 py-1 rounded border transition ${
|
||||
on ? 'bg-primary/10 text-primary border-primary/30' : 'border-border text-muted-foreground hover:border-primary hover:text-primary'
|
||||
}`}
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<p className="font-sans text-[11px] text-muted-foreground mt-2">
|
||||
Admins and board always have access. Empty selection keeps the default visibility.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user