feat: add flexible capability-based RBAC with per-tenant customization

Introduces a capability layer on top of existing roles that controls
feature visibility and access. Capabilities follow an area.feature.action
taxonomy (~35 capabilities) with sensible defaults per role. Tenant admins
can customize via grant/revoke overrides stored in org settings JSONB.

Key changes:
- Add vice_president role to DB schema
- Backend: capability constants, resolution logic, CapabilityGuard (global),
  @RequireCapability decorator on all 16 tenant controllers
- Frontend: permission hooks (useCanEdit, useHasCapability), CapabilityGate
  component, sidebar filtering by capability, all 17 pages migrated from
  useIsReadOnly to capability-based checks
- New admin UI: /settings/permissions matrix page for per-tenant role
  customization with grant/revoke delta model
- GET /organizations/my-capabilities endpoint for capability refresh
- Validation of permissionOverrides in settings updates

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-06 15:28:14 -04:00
parent 5fec296569
commit 43b10869f0
55 changed files with 1351 additions and 86 deletions

View File

@@ -77,8 +77,9 @@ export function AppLayout() {
navigate('/admin');
};
// Tenant admins (president role) can manage org members
const isTenantAdmin = currentOrg?.role === 'president' || currentOrg?.role === 'admin';
// Capability-based check: can this user manage members?
const capabilities = currentOrg?.capabilities || [];
const isTenantAdmin = user?.isSuperadmin || capabilities.includes('settings.members.manage');
return (
<AppShell

View File

@@ -23,57 +23,60 @@ import {
IconBulb,
} from '@tabler/icons-react';
import { useAuthStore } from '../../stores/authStore';
import { CAPABILITIES } from '../../permissions/capabilities';
const C = CAPABILITIES;
const navSections = [
{
items: [
{ label: 'Dashboard', icon: IconDashboard, path: '/dashboard' },
{ label: 'Dashboard', icon: IconDashboard, path: '/dashboard', capability: C.DASHBOARD_VIEW },
],
},
{
label: 'Financials',
items: [
{ label: 'Accounts', icon: IconListDetails, path: '/accounts', tourId: 'nav-accounts' },
{ label: 'Cash Flow', icon: IconChartAreaLine, path: '/cash-flow' },
{ label: 'Monthly Actuals', icon: IconClipboardCheck, path: '/monthly-actuals' },
{ label: 'Budgets', icon: IconReportAnalytics, path: '/budgets/2026', tourId: 'nav-budgets' },
{ label: 'Accounts', icon: IconListDetails, path: '/accounts', tourId: 'nav-accounts', capability: C.FINANCIALS_ACCOUNTS_VIEW },
{ label: 'Cash Flow', icon: IconChartAreaLine, path: '/cash-flow', capability: C.FINANCIALS_CASHFLOW_VIEW },
{ label: 'Monthly Actuals', icon: IconClipboardCheck, path: '/monthly-actuals', capability: C.FINANCIALS_ACTUALS_VIEW },
{ label: 'Budgets', icon: IconReportAnalytics, path: '/budgets/2026', tourId: 'nav-budgets', capability: C.FINANCIALS_BUDGETS_VIEW },
],
},
{
label: 'Assessments',
items: [
{ label: 'Units / Homeowners', icon: IconHome, path: '/units' },
{ label: 'Assessment Groups', icon: IconCategory, path: '/assessment-groups', tourId: 'nav-assessment-groups' },
{ label: 'Units / Homeowners', icon: IconHome, path: '/units', capability: C.ASSESSMENTS_UNITS_VIEW },
{ label: 'Assessment Groups', icon: IconCategory, path: '/assessment-groups', tourId: 'nav-assessment-groups', capability: C.ASSESSMENTS_GROUPS_VIEW },
],
},
{
label: 'Board Planning',
items: [
{ label: 'Budget Planning', icon: IconReportAnalytics, path: '/board-planning/budgets' },
{ label: 'Budget Planning', icon: IconReportAnalytics, path: '/board-planning/budgets', capability: C.PLANNING_BUDGETS_VIEW },
{
label: 'Projects', icon: IconShieldCheck, path: '/projects',
label: 'Projects', icon: IconShieldCheck, path: '/projects', capability: C.PLANNING_PROJECTS_VIEW,
children: [
{ label: 'Capital Planning', path: '/capital-projects' },
],
},
{
label: 'Assessment Scenarios', icon: IconCalculator, path: '/board-planning/assessments',
label: 'Assessment Scenarios', icon: IconCalculator, path: '/board-planning/assessments', capability: C.PLANNING_SCENARIOS_VIEW,
},
{ label: 'Investment Planning', icon: IconSparkles, path: '/investment-planning', tourId: 'nav-investment-planning' },
{ label: 'Investment Scenarios', icon: IconScale, path: '/board-planning/investments' },
{ label: 'Compare Scenarios', icon: IconGitCompare, path: '/board-planning/compare' },
{ label: 'Investment Planning', icon: IconSparkles, path: '/investment-planning', tourId: 'nav-investment-planning', capability: C.PLANNING_INVESTMENTS_VIEW },
{ label: 'Investment Scenarios', icon: IconScale, path: '/board-planning/investments', capability: C.PLANNING_SCENARIOS_VIEW },
{ label: 'Compare Scenarios', icon: IconGitCompare, path: '/board-planning/compare', capability: C.PLANNING_SCENARIOS_VIEW },
],
},
{
label: 'Board Reference',
items: [
{ label: 'Vendors', icon: IconUsers, path: '/vendors' },
{ label: 'Vendors', icon: IconUsers, path: '/vendors', capability: C.REFERENCE_VENDORS_VIEW },
],
},
{
label: 'Transactions',
items: [
{ label: 'Transactions', icon: IconReceipt, path: '/transactions', tourId: 'nav-transactions' },
{ label: 'Transactions', icon: IconReceipt, path: '/transactions', tourId: 'nav-transactions', capability: C.TRANSACTIONS_VIEW },
// Invoices and Payments hidden — see PARKING-LOT.md for future re-enablement
// { label: 'Invoices', icon: IconFileInvoice, path: '/invoices' },
// { label: 'Payments', icon: IconCash, path: '/payments' },
@@ -86,6 +89,7 @@ const navSections = [
label: 'Reports',
icon: IconChartSankey,
tourId: 'nav-reports',
capability: C.REPORTS_VIEW,
children: [
{ label: 'Balance Sheet', path: '/reports/balance-sheet' },
{ label: 'Income Statement', path: '/reports/income-statement' },
@@ -114,6 +118,15 @@ export function Sidebar({ onNavigate }: SidebarProps) {
const organizations = useAuthStore((s) => s.organizations);
const isAdminOnly = location.pathname.startsWith('/admin') && !currentOrg;
const capabilities = currentOrg?.capabilities || [];
const isSuperadmin = user?.isSuperadmin;
const hasCapability = (cap?: string) => {
if (!cap) return true;
if (isSuperadmin) return true;
return capabilities.includes(cap);
};
const go = (path: string) => {
navigate(path);
onNavigate?.();
@@ -164,7 +177,10 @@ export function Sidebar({ onNavigate }: SidebarProps) {
return (
<ScrollArea p="sm" data-tour="sidebar-nav">
{navSections.map((section, sIdx) => (
{navSections.map((section, sIdx) => {
const visibleItems = section.items.filter((item: any) => hasCapability(item.capability));
if (visibleItems.length === 0) return null;
return (
<div key={sIdx}>
{section.label && (
<>
@@ -174,7 +190,7 @@ export function Sidebar({ onNavigate }: SidebarProps) {
</Text>
</>
)}
{section.items.map((item: any) =>
{visibleItems.map((item: any) =>
item.children && !item.path ? (
// Collapsible group without a parent route (e.g. Reports)
<NavLink
@@ -230,7 +246,8 @@ export function Sidebar({ onNavigate }: SidebarProps) {
),
)}
</div>
))}
);
})}
{user?.isSuperadmin && (
<>