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