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

@@ -42,6 +42,7 @@ import { AssessmentScenarioDetailPage } from './pages/board-planning/AssessmentS
import { ScenarioComparisonPage } from './pages/board-planning/ScenarioComparisonPage';
import { BudgetPlanningPage } from './pages/board-planning/BudgetPlanningPage';
import { PricingPage } from './pages/pricing/PricingPage';
import { PermissionSettingsPage } from './pages/settings/PermissionSettingsPage';
import { OnboardingPage } from './pages/onboarding/OnboardingPage';
import { OnboardingPendingPage } from './pages/onboarding/OnboardingPendingPage';
@@ -182,6 +183,7 @@ export function App() {
<Route path="settings" element={<SettingsPage />} />
<Route path="preferences" element={<UserPreferencesPage />} />
<Route path="org-members" element={<OrgMembersPage />} />
<Route path="settings/permissions" element={<PermissionSettingsPage />} />
</Route>
</Routes>
);

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 && (
<>

View File

@@ -41,7 +41,7 @@ import {
} from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
import { useIsReadOnly } from '../../stores/authStore';
import { useCanEdit, CAPABILITIES } from '../../permissions';
const INVESTMENT_TYPES = ['inv_cd', 'inv_money_market', 'inv_treasury', 'inv_savings', 'inv_brokerage'];
@@ -129,7 +129,7 @@ export function AccountsPage() {
const [showArchived, setShowArchived] = useState(false);
const [transferOpened, { open: openTransfer, close: closeTransfer }] = useDisclosure(false);
const queryClient = useQueryClient();
const isReadOnly = useIsReadOnly();
const isReadOnly = !useCanEdit(CAPABILITIES.FINANCIALS_ACCOUNTS_EDIT);
// ── Accounts query ──
const { data: accounts = [], isLoading } = useQuery<Account[]>({

View File

@@ -12,7 +12,7 @@ import {
} from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
import { useIsReadOnly } from '../../stores/authStore';
import { useCanEdit, CAPABILITIES } from '../../permissions';
interface AssessmentGroup {
id: string;
@@ -79,7 +79,7 @@ export function AssessmentGroupsPage() {
const [opened, { open, close }] = useDisclosure(false);
const [editing, setEditing] = useState<AssessmentGroup | null>(null);
const queryClient = useQueryClient();
const isReadOnly = useIsReadOnly();
const isReadOnly = !useCanEdit(CAPABILITIES.ASSESSMENTS_GROUPS_EDIT);
const { data: groups = [], isLoading } = useQuery<AssessmentGroup[]>({
queryKey: ['assessment-groups'],

View File

@@ -11,7 +11,7 @@ import {
} from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
import { useIsReadOnly } from '../../stores/authStore';
import { useCanEdit, CAPABILITIES } from '../../permissions';
import { usePreferencesStore } from '../../stores/preferencesStore';
interface PlanLine {
@@ -87,7 +87,7 @@ const statusColors: Record<string, string> = {
export function BudgetPlanningPage() {
const queryClient = useQueryClient();
const isReadOnly = useIsReadOnly();
const isReadOnly = !useCanEdit(CAPABILITIES.PLANNING_BUDGETS_EDIT);
const isDark = usePreferencesStore((s) => s.colorScheme) === 'dark';
const stickyBg = isDark ? 'var(--mantine-color-dark-7)' : 'white';
const stickyBorder = isDark ? 'var(--mantine-color-dark-4)' : '#e9ecef';

View File

@@ -8,7 +8,7 @@ import { IconDeviceFloppy, IconInfoCircle, IconPencil, IconX, IconArrowRight } f
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import api from '../../services/api';
import { useIsReadOnly } from '../../stores/authStore';
import { useCanEdit, CAPABILITIES } from '../../permissions';
import { usePreferencesStore } from '../../stores/preferencesStore';
interface BudgetLine {
@@ -40,7 +40,7 @@ export function BudgetsPage() {
const [editData, setEditData] = useState<BudgetLine[] | null>(null); // null = not editing
const queryClient = useQueryClient();
const navigate = useNavigate();
const isReadOnly = useIsReadOnly();
const isReadOnly = !useCanEdit(CAPABILITIES.FINANCIALS_BUDGETS_EDIT);
const isDark = usePreferencesStore((s) => s.colorScheme) === 'dark';
const stickyBg = isDark ? 'var(--mantine-color-dark-7)' : 'white';
const stickyBorder = isDark ? 'var(--mantine-color-dark-4)' : '#e9ecef';

View File

@@ -14,7 +14,7 @@ import {
import { useNavigate } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
import { useIsReadOnly } from '../../stores/authStore';
import { useCanEdit, CAPABILITIES } from '../../permissions';
// ---------------------------------------------------------------------------
// Types & constants
@@ -252,7 +252,7 @@ export function CapitalProjectsPage() {
const [dragOverYear, setDragOverYear] = useState<number | null>(null);
const printModeRef = useRef(false);
const queryClient = useQueryClient();
const isReadOnly = useIsReadOnly();
const isReadOnly = !useCanEdit(CAPABILITIES.PLANNING_PROJECTS_EDIT);
// ---- Data fetching ----

View File

@@ -21,7 +21,8 @@ import {
import { useState, useCallback } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { useAuthStore, useIsReadOnly } from '../../stores/authStore';
import { useAuthStore } from '../../stores/authStore';
import { useHasAnyCapability, CAPABILITIES } from '../../permissions';
import api from '../../services/api';
interface HealthScore {
@@ -350,7 +351,11 @@ interface DashboardData {
export function DashboardPage() {
const currentOrg = useAuthStore((s) => s.currentOrg);
const isReadOnly = useIsReadOnly();
const isReadOnly = !useHasAnyCapability(
CAPABILITIES.FINANCIALS_ACCOUNTS_EDIT,
CAPABILITIES.FINANCIALS_BUDGETS_EDIT,
CAPABILITIES.FINANCIALS_ACTUALS_EDIT,
);
const queryClient = useQueryClient();
const navigate = useNavigate();

View File

@@ -43,7 +43,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { notifications } from '@mantine/notifications';
import { useNavigate } from 'react-router-dom';
import api from '../../services/api';
import { useIsReadOnly } from '../../stores/authStore';
import { useCanEdit, CAPABILITIES } from '../../permissions';
// ── Types ──
@@ -385,7 +385,7 @@ export function InvestmentPlanningPage() {
const [targetScenarioId, setTargetScenarioId] = useState<string | null>(null);
const [newScenarioName, setNewScenarioName] = useState('');
const [investmentStartDate, setInvestmentStartDate] = useState<Date | null>(new Date());
const isReadOnly = useIsReadOnly();
const isReadOnly = !useCanEdit(CAPABILITIES.PLANNING_INVESTMENTS_EDIT);
// Load investment scenarios for the "Add to Plan" modal
const { data: investmentScenarios } = useQuery<any[]>({

View File

@@ -10,7 +10,7 @@ import { notifications } from '@mantine/notifications';
import { IconPlus, IconEdit } from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
import { useIsReadOnly } from '../../stores/authStore';
import { useCanEdit, CAPABILITIES } from '../../permissions';
interface Investment {
id: string; name: string; institution: string; account_number_last4: string;
@@ -26,7 +26,7 @@ export function InvestmentsPage() {
const [opened, { open, close }] = useDisclosure(false);
const [editing, setEditing] = useState<Investment | null>(null);
const queryClient = useQueryClient();
const isReadOnly = useIsReadOnly();
const isReadOnly = !useCanEdit(CAPABILITIES.PLANNING_INVESTMENTS_EDIT);
const { data: investments = [], isLoading } = useQuery<Investment[]>({
queryKey: ['investments'],

View File

@@ -9,7 +9,7 @@ import { notifications } from '@mantine/notifications';
import { IconSend, IconInfoCircle, IconCheck, IconX } from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
import { useIsReadOnly } from '../../stores/authStore';
import { useCanEdit, CAPABILITIES } from '../../permissions';
interface Invoice {
id: string; invoice_number: string; unit_number: string; unit_id: string;
@@ -65,7 +65,7 @@ export function InvoicesPage() {
const [preview, setPreview] = useState<Preview | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
const queryClient = useQueryClient();
const isReadOnly = useIsReadOnly();
const isReadOnly = !useCanEdit(CAPABILITIES.TRANSACTIONS_EDIT);
const { data: invoices = [], isLoading } = useQuery<Invoice[]>({
queryKey: ['invoices'],

View File

@@ -10,7 +10,7 @@ import {
} from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
import { useIsReadOnly } from '../../stores/authStore';
import { useCanEdit, CAPABILITIES } from '../../permissions';
import { usePreferencesStore } from '../../stores/preferencesStore';
import { AttachmentPanel } from '../../components/attachments/AttachmentPanel';
@@ -69,7 +69,7 @@ export function MonthlyActualsPage() {
const [isEditing, setIsEditing] = useState(false);
const [confirmOpened, { open: openConfirm, close: closeConfirm }] = useDisclosure(false);
const queryClient = useQueryClient();
const isReadOnly = useIsReadOnly();
const isReadOnly = !useCanEdit(CAPABILITIES.FINANCIALS_ACTUALS_EDIT);
const isDark = usePreferencesStore((s) => s.colorScheme) === 'dark';
const stickyBg = isDark ? 'var(--mantine-color-dark-7)' : 'white';
const stickyBorder = isDark ? 'var(--mantine-color-dark-4)' : '#e9ecef';

View File

@@ -12,8 +12,10 @@ import {
IconShieldCheck, IconInfoCircle,
} from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import api from '../../services/api';
import { useAuthStore, useIsReadOnly } from '../../stores/authStore';
import { useAuthStore } from '../../stores/authStore';
import { useCanEdit, useHasCapability, CAPABILITIES } from '../../permissions';
interface OrgMember {
id: string;
@@ -29,19 +31,21 @@ interface OrgMember {
const ROLE_OPTIONS = [
{ value: 'president', label: 'President' },
{ value: 'vice_president', label: 'Vice President' },
{ value: 'treasurer', label: 'Treasurer' },
{ value: 'secretary', label: 'Secretary' },
{ value: 'board_member', label: 'Board Member' },
{ value: 'property_manager', label: 'Property Manager' },
{ value: 'member_at_large', label: 'Member at Large' },
{ value: 'manager', label: 'Property Manager' },
{ value: 'viewer', label: 'Viewer (Read-Only)' },
];
const roleColors: Record<string, string> = {
president: 'red',
vice_president: 'grape',
treasurer: 'blue',
secretary: 'green',
board_member: 'violet',
property_manager: 'orange',
member_at_large: 'violet',
manager: 'orange',
viewer: 'gray',
admin: 'red',
};
@@ -52,7 +56,9 @@ export function OrgMembersPage() {
const [editingMember, setEditingMember] = useState<OrgMember | null>(null);
const queryClient = useQueryClient();
const { user, currentOrg } = useAuthStore();
const isReadOnly = useIsReadOnly();
const navigate = useNavigate();
const isReadOnly = !useCanEdit(CAPABILITIES.SETTINGS_MEMBERS_MANAGE);
const canManagePermissions = useHasCapability(CAPABILITIES.SETTINGS_PERMISSIONS_MANAGE);
const { data: members = [], isLoading } = useQuery<OrgMember[]>({
queryKey: ['org-members'],
@@ -68,7 +74,7 @@ export function OrgMembersPage() {
firstName: '',
lastName: '',
password: '',
role: 'board_member',
role: 'member_at_large',
},
validate: {
email: (v) => (/^\S+@\S+\.\S+$/.test(v) ? null : 'Valid email required'),
@@ -80,7 +86,7 @@ export function OrgMembersPage() {
const editForm = useForm({
initialValues: {
role: 'board_member',
role: 'member_at_large',
},
});
@@ -163,11 +169,18 @@ export function OrgMembersPage() {
<Title order={2}>Organization Members</Title>
<Text c="dimmed" size="sm">Manage who has access to {currentOrg?.name}</Text>
</div>
{!isReadOnly && (
<Button leftSection={<IconUserPlus size={16} />} onClick={openAdd}>
Add Member
</Button>
)}
<Group>
{canManagePermissions && (
<Button variant="light" leftSection={<IconShieldCheck size={16} />} onClick={() => navigate('/settings/permissions')}>
Role Permissions
</Button>
)}
{!isReadOnly && (
<Button leftSection={<IconUserPlus size={16} />} onClick={openAdd}>
Add Member
</Button>
)}
</Group>
</Group>
<SimpleGrid cols={{ base: 1, sm: 3 }}>

View File

@@ -10,7 +10,7 @@ import { notifications } from '@mantine/notifications';
import { IconPlus, IconEdit, IconTrash } from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
import { useIsReadOnly } from '../../stores/authStore';
import { useCanEdit, CAPABILITIES } from '../../permissions';
interface Payment {
id: string; unit_id: string; unit_number: string; invoice_id: string;
@@ -23,7 +23,7 @@ export function PaymentsPage() {
const [editing, setEditing] = useState<Payment | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<Payment | null>(null);
const queryClient = useQueryClient();
const isReadOnly = useIsReadOnly();
const isReadOnly = !useCanEdit(CAPABILITIES.TRANSACTIONS_EDIT);
const { data: payments = [], isLoading } = useQuery<Payment[]>({
queryKey: ['payments'],

View File

@@ -12,7 +12,7 @@ import { IconPlus, IconEdit, IconUpload, IconDownload, IconLock, IconLockOpen, I
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
import { parseCSV, downloadBlob } from '../../utils/csv';
import { useIsReadOnly } from '../../stores/authStore';
import { useCanEdit, CAPABILITIES } from '../../permissions';
// ---------------------------------------------------------------------------
// Types & constants
@@ -79,7 +79,7 @@ export function ProjectsPage() {
const [editing, setEditing] = useState<Project | null>(null);
const queryClient = useQueryClient();
const fileInputRef = useRef<HTMLInputElement>(null);
const isReadOnly = useIsReadOnly();
const isReadOnly = !useCanEdit(CAPABILITIES.PLANNING_PROJECTS_EDIT);
// ---- Data fetching ----

View File

@@ -11,7 +11,7 @@ import { notifications } from '@mantine/notifications';
import { IconPlus, IconEdit } from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
import { useIsReadOnly } from '../../stores/authStore';
import { useCanEdit, CAPABILITIES } from '../../permissions';
interface ReserveComponent {
id: string; name: string; category: string; description: string;
@@ -27,7 +27,7 @@ export function ReservesPage() {
const [opened, { open, close }] = useDisclosure(false);
const [editing, setEditing] = useState<ReserveComponent | null>(null);
const queryClient = useQueryClient();
const isReadOnly = useIsReadOnly();
const isReadOnly = !useCanEdit(CAPABILITIES.PLANNING_PROJECTS_EDIT);
const { data: components = [], isLoading } = useQuery<ReserveComponent[]>({
queryKey: ['reserve-components'],

View File

@@ -0,0 +1,250 @@
import { useState, useEffect, useMemo } from 'react';
import {
Title, Text, Card, Stack, Group, Table, Checkbox, Button, Alert,
Badge, Tooltip, Divider, Loader, Center,
} from '@mantine/core';
import { notifications } from '@mantine/notifications';
import { IconShieldCheck, IconRefresh, IconInfoCircle } from '@tabler/icons-react';
import { useAuthStore } from '../../stores/authStore';
import { CAPABILITY_AREAS } from '../../permissions/capabilities';
import { DEFAULT_ROLE_CAPABILITIES } from '../../permissions/default-role-capabilities';
import api from '../../services/api';
/** Roles shown as columns (homeowner hidden from UI per product decision) */
const DISPLAY_ROLES = [
{ value: 'president', label: 'President' },
{ value: 'vice_president', label: 'Vice President' },
{ value: 'treasurer', label: 'Treasurer' },
{ value: 'secretary', label: 'Secretary' },
{ value: 'member_at_large', label: 'Member at Large' },
{ value: 'manager', label: 'Property Manager' },
{ value: 'viewer', label: 'Viewer' },
];
interface PermissionOverrides {
[role: string]: {
grant?: string[];
revoke?: string[];
};
}
function buildCheckedState(overrides: PermissionOverrides): Record<string, Record<string, boolean>> {
const state: Record<string, Record<string, boolean>> = {};
for (const role of DISPLAY_ROLES) {
const defaults = new Set(DEFAULT_ROLE_CAPABILITIES[role.value] || []);
const roleOverride = overrides[role.value];
if (roleOverride?.grant) {
for (const cap of roleOverride.grant) defaults.add(cap);
}
if (roleOverride?.revoke) {
for (const cap of roleOverride.revoke) defaults.delete(cap);
}
state[role.value] = {};
for (const area of CAPABILITY_AREAS) {
for (const cap of area.capabilities) {
state[role.value][cap.key] = defaults.has(cap.key);
}
}
}
return state;
}
function buildOverridesFromState(checkedState: Record<string, Record<string, boolean>>): PermissionOverrides {
const overrides: PermissionOverrides = {};
for (const role of DISPLAY_ROLES) {
const defaults = new Set(DEFAULT_ROLE_CAPABILITIES[role.value] || []);
const grant: string[] = [];
const revoke: string[] = [];
for (const [cap, checked] of Object.entries(checkedState[role.value] || {})) {
const isDefault = defaults.has(cap);
if (checked && !isDefault) grant.push(cap);
if (!checked && isDefault) revoke.push(cap);
}
if (grant.length > 0 || revoke.length > 0) {
overrides[role.value] = {};
if (grant.length > 0) overrides[role.value].grant = grant;
if (revoke.length > 0) overrides[role.value].revoke = revoke;
}
}
return overrides;
}
export function PermissionSettingsPage() {
const { currentOrg, setOrgSettings } = useAuthStore();
const [saving, setSaving] = useState(false);
const [loaded, setLoaded] = useState(false);
const existingOverrides: PermissionOverrides = useMemo(
() => currentOrg?.settings?.permissionOverrides || {},
[currentOrg?.settings?.permissionOverrides],
);
const [checkedState, setCheckedState] = useState<Record<string, Record<string, boolean>>>(() =>
buildCheckedState(existingOverrides),
);
useEffect(() => {
setCheckedState(buildCheckedState(existingOverrides));
setLoaded(true);
}, [existingOverrides]);
const currentOverrides = useMemo(() => buildOverridesFromState(checkedState), [checkedState]);
const hasChanges = JSON.stringify(currentOverrides) !== JSON.stringify(existingOverrides);
const toggleCapability = (role: string, cap: string) => {
setCheckedState((prev) => ({
...prev,
[role]: {
...prev[role],
[cap]: !prev[role]?.[cap],
},
}));
};
const resetRole = (roleValue: string) => {
const defaults = new Set(DEFAULT_ROLE_CAPABILITIES[roleValue] || []);
const newRoleState: Record<string, boolean> = {};
for (const area of CAPABILITY_AREAS) {
for (const cap of area.capabilities) {
newRoleState[cap.key] = defaults.has(cap.key);
}
}
setCheckedState((prev) => ({ ...prev, [roleValue]: newRoleState }));
};
const handleSave = async () => {
setSaving(true);
try {
const overrides = buildOverridesFromState(checkedState);
const res = await api.patch('/organizations/settings', { permissionOverrides: overrides });
setOrgSettings(res.data);
notifications.show({ title: 'Saved', message: 'Permission settings updated. Members will see changes on next login or page refresh.', color: 'green' });
} catch (err: any) {
notifications.show({ title: 'Error', message: err.response?.data?.message || 'Failed to save', color: 'red' });
} finally {
setSaving(false);
}
};
const isOverridden = (role: string, cap: string) => {
const isDefault = (DEFAULT_ROLE_CAPABILITIES[role] || []).includes(cap);
const isChecked = checkedState[role]?.[cap] ?? false;
return isChecked !== isDefault;
};
if (!loaded) {
return <Center mt="xl"><Loader /></Center>;
}
return (
<Stack gap="md">
<Group justify="space-between" align="center">
<Group gap="xs">
<IconShieldCheck size={28} />
<Title order={2}>Role Permissions</Title>
</Group>
<Group>
<Button
variant="default"
leftSection={<IconRefresh size={16} />}
onClick={() => setCheckedState(buildCheckedState(existingOverrides))}
disabled={!hasChanges}
>
Discard Changes
</Button>
<Button
onClick={handleSave}
loading={saving}
disabled={!hasChanges}
>
Save Changes
</Button>
</Group>
</Group>
<Alert icon={<IconInfoCircle size={16} />} color="blue" variant="light">
Customize which capabilities each role has in your organization.
Highlighted cells differ from the system defaults. Use "Reset" to revert a role to defaults.
The <strong>Viewer</strong> role is always read-only regardless of settings.
</Alert>
<Card withBorder p={0} style={{ overflow: 'auto' }}>
<Table striped highlightOnHover withColumnBorders style={{ minWidth: 900 }}>
<Table.Thead>
<Table.Tr>
<Table.Th style={{ position: 'sticky', left: 0, background: 'var(--mantine-color-body)', zIndex: 1, minWidth: 200 }}>
Capability
</Table.Th>
{DISPLAY_ROLES.map((role) => (
<Table.Th key={role.value} style={{ textAlign: 'center', minWidth: 110 }}>
<Stack gap={4} align="center">
<Text size="xs" fw={600}>{role.label}</Text>
<Tooltip label={`Reset ${role.label} to defaults`}>
<Button
variant="subtle"
size="compact-xs"
onClick={() => resetRole(role.value)}
>
Reset
</Button>
</Tooltip>
</Stack>
</Table.Th>
))}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{CAPABILITY_AREAS.map((area) => (
<>
<Table.Tr key={`area-${area.label}`}>
<Table.Td
colSpan={DISPLAY_ROLES.length + 1}
style={{ background: 'var(--mantine-color-gray-1)', fontWeight: 700 }}
>
<Text size="sm" fw={700} tt="uppercase">{area.label}</Text>
</Table.Td>
</Table.Tr>
{area.capabilities.map((cap) => (
<Table.Tr key={cap.key}>
<Table.Td style={{ position: 'sticky', left: 0, background: 'var(--mantine-color-body)', zIndex: 1 }}>
<Text size="sm">{cap.label}</Text>
</Table.Td>
{DISPLAY_ROLES.map((role) => {
const checked = checkedState[role.value]?.[cap.key] ?? false;
const overridden = isOverridden(role.value, cap.key);
return (
<Table.Td
key={role.value}
style={{
textAlign: 'center',
background: overridden ? 'var(--mantine-color-yellow-0)' : undefined,
}}
>
<Checkbox
checked={checked}
onChange={() => toggleCapability(role.value, cap.key)}
styles={{ input: { cursor: 'pointer' } }}
/>
</Table.Td>
);
})}
</Table.Tr>
))}
</>
))}
</Table.Tbody>
</Table>
</Card>
{hasChanges && (
<Alert color="yellow" variant="light">
You have unsaved changes. Click "Save Changes" to apply.
</Alert>
)}
</Stack>
);
}

View File

@@ -12,7 +12,7 @@ import { IconPlus, IconEye, IconCheck, IconX, IconTrash, IconShieldCheck } from
import { AttachmentPanel } from '../../components/attachments/AttachmentPanel';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
import { useIsReadOnly } from '../../stores/authStore';
import { useCanEdit, CAPABILITIES } from '../../permissions';
interface JournalEntryLine {
id?: string;
@@ -49,7 +49,7 @@ export function TransactionsPage() {
const [opened, { open, close }] = useDisclosure(false);
const [viewId, setViewId] = useState<string | null>(null);
const queryClient = useQueryClient();
const isReadOnly = useIsReadOnly();
const isReadOnly = !useCanEdit(CAPABILITIES.TRANSACTIONS_EDIT);
const { data: entries = [], isLoading } = useQuery<JournalEntry[]>({
queryKey: ['journal-entries'],

View File

@@ -10,7 +10,7 @@ import { IconPlus, IconEdit, IconSearch, IconTrash, IconInfoCircle, IconUpload,
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
import { parseCSV, downloadBlob } from '../../utils/csv';
import { useIsReadOnly } from '../../stores/authStore';
import { useCanEdit, CAPABILITIES } from '../../permissions';
interface Unit {
id: string;
@@ -43,7 +43,7 @@ export function UnitsPage() {
const [deleteConfirm, setDeleteConfirm] = useState<Unit | null>(null);
const queryClient = useQueryClient();
const fileInputRef = useRef<HTMLInputElement>(null);
const isReadOnly = useIsReadOnly();
const isReadOnly = !useCanEdit(CAPABILITIES.ASSESSMENTS_UNITS_EDIT);
const { data: units = [], isLoading } = useQuery<Unit[]>({
queryKey: ['units'],

View File

@@ -10,7 +10,7 @@ import { notifications } from '@mantine/notifications';
import { IconPlus, IconEdit, IconSearch, IconUpload, IconDownload, IconUsers, IconBulb, IconRocket } from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
import { useIsReadOnly } from '../../stores/authStore';
import { useCanEdit, CAPABILITIES } from '../../permissions';
import { parseCSV, downloadBlob } from '../../utils/csv';
interface Vendor {
@@ -26,7 +26,7 @@ export function VendorsPage() {
const [search, setSearch] = useState('');
const queryClient = useQueryClient();
const fileInputRef = useRef<HTMLInputElement>(null);
const isReadOnly = useIsReadOnly();
const isReadOnly = !useCanEdit(CAPABILITIES.REFERENCE_VENDORS_EDIT);
const { data: vendors = [], isLoading } = useQuery<Vendor[]>({
queryKey: ['vendors'],

View File

@@ -0,0 +1,22 @@
import { ReactNode } from 'react';
import { useHasCapability, useHasAnyCapability } from './useCapability';
interface CapabilityGateProps {
/** Single capability required */
capability?: string;
/** Multiple capabilities — user needs at least one */
anyOf?: string[];
/** Content shown when user has the capability */
children: ReactNode;
/** Optional fallback shown when user lacks the capability */
fallback?: ReactNode;
}
export function CapabilityGate({ capability, anyOf, children, fallback = null }: CapabilityGateProps) {
const hasSingle = useHasCapability(capability || '');
const hasAny = useHasAnyCapability(...(anyOf || []));
const allowed = capability ? hasSingle : anyOf ? hasAny : true;
return allowed ? <>{children}</> : <>{fallback}</>;
}

View File

@@ -0,0 +1,131 @@
/**
* Capability taxonomy for the HOA Financial Platform.
*
* This file mirrors backend/src/common/permissions/capabilities.ts.
* Keep both files in sync when adding new capabilities.
*/
export const CAPABILITIES = {
DASHBOARD_VIEW: 'dashboard.view',
FINANCIALS_ACCOUNTS_VIEW: 'financials.accounts.view',
FINANCIALS_ACCOUNTS_EDIT: 'financials.accounts.edit',
FINANCIALS_CASHFLOW_VIEW: 'financials.cashflow.view',
FINANCIALS_CASHFLOW_EDIT: 'financials.cashflow.edit',
FINANCIALS_ACTUALS_VIEW: 'financials.actuals.view',
FINANCIALS_ACTUALS_EDIT: 'financials.actuals.edit',
FINANCIALS_BUDGETS_VIEW: 'financials.budgets.view',
FINANCIALS_BUDGETS_EDIT: 'financials.budgets.edit',
FINANCIALS_BUDGETS_APPROVE: 'financials.budgets.approve',
ASSESSMENTS_UNITS_VIEW: 'assessments.units.view',
ASSESSMENTS_UNITS_EDIT: 'assessments.units.edit',
ASSESSMENTS_GROUPS_VIEW: 'assessments.groups.view',
ASSESSMENTS_GROUPS_EDIT: 'assessments.groups.edit',
PLANNING_BUDGETS_VIEW: 'planning.budgets.view',
PLANNING_BUDGETS_EDIT: 'planning.budgets.edit',
PLANNING_PROJECTS_VIEW: 'planning.projects.view',
PLANNING_PROJECTS_EDIT: 'planning.projects.edit',
PLANNING_SCENARIOS_VIEW: 'planning.scenarios.view',
PLANNING_SCENARIOS_EDIT: 'planning.scenarios.edit',
PLANNING_SCENARIOS_APPROVE: 'planning.scenarios.approve',
PLANNING_INVESTMENTS_VIEW: 'planning.investments.view',
PLANNING_INVESTMENTS_EDIT: 'planning.investments.edit',
REFERENCE_VENDORS_VIEW: 'reference.vendors.view',
REFERENCE_VENDORS_EDIT: 'reference.vendors.edit',
TRANSACTIONS_VIEW: 'transactions.view',
TRANSACTIONS_EDIT: 'transactions.edit',
TRANSACTIONS_APPROVE: 'transactions.approve',
REPORTS_VIEW: 'reports.view',
SETTINGS_ORG_VIEW: 'settings.org.view',
SETTINGS_ORG_EDIT: 'settings.org.edit',
SETTINGS_MEMBERS_VIEW: 'settings.members.view',
SETTINGS_MEMBERS_MANAGE: 'settings.members.manage',
SETTINGS_PERMISSIONS_MANAGE: 'settings.permissions.manage',
} as const;
export type Capability = (typeof CAPABILITIES)[keyof typeof CAPABILITIES];
export const ALL_CAPABILITIES = new Set<string>(Object.values(CAPABILITIES));
/** Human-readable labels for capability areas (for admin UI) */
export const CAPABILITY_AREAS: { label: string; capabilities: { key: string; label: string }[] }[] = [
{
label: 'Dashboard',
capabilities: [
{ key: CAPABILITIES.DASHBOARD_VIEW, label: 'View Dashboard' },
],
},
{
label: 'Financials',
capabilities: [
{ key: CAPABILITIES.FINANCIALS_ACCOUNTS_VIEW, label: 'View Accounts' },
{ key: CAPABILITIES.FINANCIALS_ACCOUNTS_EDIT, label: 'Edit Accounts' },
{ key: CAPABILITIES.FINANCIALS_CASHFLOW_VIEW, label: 'View Cash Flow' },
{ key: CAPABILITIES.FINANCIALS_CASHFLOW_EDIT, label: 'Edit Cash Flow' },
{ key: CAPABILITIES.FINANCIALS_ACTUALS_VIEW, label: 'View Monthly Actuals' },
{ key: CAPABILITIES.FINANCIALS_ACTUALS_EDIT, label: 'Edit Monthly Actuals' },
{ key: CAPABILITIES.FINANCIALS_BUDGETS_VIEW, label: 'View Budgets' },
{ key: CAPABILITIES.FINANCIALS_BUDGETS_EDIT, label: 'Edit Budgets' },
{ key: CAPABILITIES.FINANCIALS_BUDGETS_APPROVE, label: 'Approve Budgets' },
],
},
{
label: 'Assessments',
capabilities: [
{ key: CAPABILITIES.ASSESSMENTS_UNITS_VIEW, label: 'View Units' },
{ key: CAPABILITIES.ASSESSMENTS_UNITS_EDIT, label: 'Edit Units' },
{ key: CAPABILITIES.ASSESSMENTS_GROUPS_VIEW, label: 'View Assessment Groups' },
{ key: CAPABILITIES.ASSESSMENTS_GROUPS_EDIT, label: 'Edit Assessment Groups' },
],
},
{
label: 'Board Planning',
capabilities: [
{ key: CAPABILITIES.PLANNING_BUDGETS_VIEW, label: 'View Budget Planning' },
{ key: CAPABILITIES.PLANNING_BUDGETS_EDIT, label: 'Edit Budget Planning' },
{ key: CAPABILITIES.PLANNING_PROJECTS_VIEW, label: 'View Projects' },
{ key: CAPABILITIES.PLANNING_PROJECTS_EDIT, label: 'Edit Projects' },
{ key: CAPABILITIES.PLANNING_SCENARIOS_VIEW, label: 'View Scenarios' },
{ key: CAPABILITIES.PLANNING_SCENARIOS_EDIT, label: 'Edit Scenarios' },
{ key: CAPABILITIES.PLANNING_SCENARIOS_APPROVE, label: 'Approve Scenarios' },
{ key: CAPABILITIES.PLANNING_INVESTMENTS_VIEW, label: 'View Investments' },
{ key: CAPABILITIES.PLANNING_INVESTMENTS_EDIT, label: 'Edit Investments' },
],
},
{
label: 'Board Reference',
capabilities: [
{ key: CAPABILITIES.REFERENCE_VENDORS_VIEW, label: 'View Vendors' },
{ key: CAPABILITIES.REFERENCE_VENDORS_EDIT, label: 'Edit Vendors' },
],
},
{
label: 'Transactions',
capabilities: [
{ key: CAPABILITIES.TRANSACTIONS_VIEW, label: 'View Transactions' },
{ key: CAPABILITIES.TRANSACTIONS_EDIT, label: 'Edit Transactions' },
{ key: CAPABILITIES.TRANSACTIONS_APPROVE, label: 'Approve Transactions' },
],
},
{
label: 'Reports',
capabilities: [
{ key: CAPABILITIES.REPORTS_VIEW, label: 'View Reports' },
],
},
{
label: 'Administration',
capabilities: [
{ key: CAPABILITIES.SETTINGS_ORG_VIEW, label: 'View Org Settings' },
{ key: CAPABILITIES.SETTINGS_ORG_EDIT, label: 'Edit Org Settings' },
{ key: CAPABILITIES.SETTINGS_MEMBERS_VIEW, label: 'View Members' },
{ key: CAPABILITIES.SETTINGS_MEMBERS_MANAGE, label: 'Manage Members' },
{ key: CAPABILITIES.SETTINGS_PERMISSIONS_MANAGE, label: 'Manage Permissions' },
],
},
];

View File

@@ -0,0 +1,155 @@
import { CAPABILITIES } from './capabilities';
const C = CAPABILITIES;
/**
* Default capability sets per role.
*
* Mirrors backend/src/common/permissions/default-role-capabilities.ts.
* Keep both files in sync.
*/
export const DEFAULT_ROLE_CAPABILITIES: Record<string, readonly string[]> = {
president: [
C.DASHBOARD_VIEW,
C.FINANCIALS_ACCOUNTS_VIEW, C.FINANCIALS_ACCOUNTS_EDIT,
C.FINANCIALS_CASHFLOW_VIEW, C.FINANCIALS_CASHFLOW_EDIT,
C.FINANCIALS_ACTUALS_VIEW, C.FINANCIALS_ACTUALS_EDIT,
C.FINANCIALS_BUDGETS_VIEW, C.FINANCIALS_BUDGETS_EDIT, C.FINANCIALS_BUDGETS_APPROVE,
C.ASSESSMENTS_UNITS_VIEW, C.ASSESSMENTS_UNITS_EDIT,
C.ASSESSMENTS_GROUPS_VIEW, C.ASSESSMENTS_GROUPS_EDIT,
C.PLANNING_BUDGETS_VIEW, C.PLANNING_BUDGETS_EDIT,
C.PLANNING_PROJECTS_VIEW, C.PLANNING_PROJECTS_EDIT,
C.PLANNING_SCENARIOS_VIEW, C.PLANNING_SCENARIOS_EDIT, C.PLANNING_SCENARIOS_APPROVE,
C.PLANNING_INVESTMENTS_VIEW, C.PLANNING_INVESTMENTS_EDIT,
C.REFERENCE_VENDORS_VIEW, C.REFERENCE_VENDORS_EDIT,
C.TRANSACTIONS_VIEW, C.TRANSACTIONS_EDIT, C.TRANSACTIONS_APPROVE,
C.REPORTS_VIEW,
C.SETTINGS_ORG_VIEW, C.SETTINGS_ORG_EDIT,
C.SETTINGS_MEMBERS_VIEW, C.SETTINGS_MEMBERS_MANAGE,
C.SETTINGS_PERMISSIONS_MANAGE,
],
admin: [
C.DASHBOARD_VIEW,
C.FINANCIALS_ACCOUNTS_VIEW, C.FINANCIALS_ACCOUNTS_EDIT,
C.FINANCIALS_CASHFLOW_VIEW, C.FINANCIALS_CASHFLOW_EDIT,
C.FINANCIALS_ACTUALS_VIEW, C.FINANCIALS_ACTUALS_EDIT,
C.FINANCIALS_BUDGETS_VIEW, C.FINANCIALS_BUDGETS_EDIT, C.FINANCIALS_BUDGETS_APPROVE,
C.ASSESSMENTS_UNITS_VIEW, C.ASSESSMENTS_UNITS_EDIT,
C.ASSESSMENTS_GROUPS_VIEW, C.ASSESSMENTS_GROUPS_EDIT,
C.PLANNING_BUDGETS_VIEW, C.PLANNING_BUDGETS_EDIT,
C.PLANNING_PROJECTS_VIEW, C.PLANNING_PROJECTS_EDIT,
C.PLANNING_SCENARIOS_VIEW, C.PLANNING_SCENARIOS_EDIT, C.PLANNING_SCENARIOS_APPROVE,
C.PLANNING_INVESTMENTS_VIEW, C.PLANNING_INVESTMENTS_EDIT,
C.REFERENCE_VENDORS_VIEW, C.REFERENCE_VENDORS_EDIT,
C.TRANSACTIONS_VIEW, C.TRANSACTIONS_EDIT, C.TRANSACTIONS_APPROVE,
C.REPORTS_VIEW,
C.SETTINGS_ORG_VIEW, C.SETTINGS_ORG_EDIT,
C.SETTINGS_MEMBERS_VIEW, C.SETTINGS_MEMBERS_MANAGE,
C.SETTINGS_PERMISSIONS_MANAGE,
],
vice_president: [
C.DASHBOARD_VIEW,
C.FINANCIALS_ACCOUNTS_VIEW,
C.FINANCIALS_CASHFLOW_VIEW,
C.FINANCIALS_ACTUALS_VIEW,
C.FINANCIALS_BUDGETS_VIEW,
C.ASSESSMENTS_UNITS_VIEW,
C.ASSESSMENTS_GROUPS_VIEW,
C.PLANNING_BUDGETS_VIEW,
C.PLANNING_PROJECTS_VIEW,
C.PLANNING_SCENARIOS_VIEW,
C.PLANNING_INVESTMENTS_VIEW,
C.REFERENCE_VENDORS_VIEW,
C.TRANSACTIONS_VIEW,
C.REPORTS_VIEW,
C.SETTINGS_ORG_VIEW,
C.SETTINGS_MEMBERS_VIEW,
],
treasurer: [
C.DASHBOARD_VIEW,
C.FINANCIALS_ACCOUNTS_VIEW, C.FINANCIALS_ACCOUNTS_EDIT,
C.FINANCIALS_CASHFLOW_VIEW, C.FINANCIALS_CASHFLOW_EDIT,
C.FINANCIALS_ACTUALS_VIEW, C.FINANCIALS_ACTUALS_EDIT,
C.FINANCIALS_BUDGETS_VIEW, C.FINANCIALS_BUDGETS_EDIT,
C.ASSESSMENTS_UNITS_VIEW, C.ASSESSMENTS_UNITS_EDIT,
C.ASSESSMENTS_GROUPS_VIEW, C.ASSESSMENTS_GROUPS_EDIT,
C.PLANNING_BUDGETS_VIEW, C.PLANNING_BUDGETS_EDIT,
C.PLANNING_PROJECTS_VIEW, C.PLANNING_PROJECTS_EDIT,
C.PLANNING_SCENARIOS_VIEW, C.PLANNING_SCENARIOS_EDIT,
C.PLANNING_INVESTMENTS_VIEW, C.PLANNING_INVESTMENTS_EDIT,
C.REFERENCE_VENDORS_VIEW, C.REFERENCE_VENDORS_EDIT,
C.TRANSACTIONS_VIEW, C.TRANSACTIONS_EDIT,
C.REPORTS_VIEW,
C.SETTINGS_MEMBERS_VIEW,
],
secretary: [
C.DASHBOARD_VIEW,
C.FINANCIALS_ACCOUNTS_VIEW,
C.FINANCIALS_CASHFLOW_VIEW,
C.FINANCIALS_ACTUALS_VIEW,
C.FINANCIALS_BUDGETS_VIEW,
C.ASSESSMENTS_UNITS_VIEW,
C.ASSESSMENTS_GROUPS_VIEW,
C.PLANNING_BUDGETS_VIEW,
C.PLANNING_PROJECTS_VIEW,
C.PLANNING_SCENARIOS_VIEW,
C.PLANNING_INVESTMENTS_VIEW,
C.REFERENCE_VENDORS_VIEW,
C.REPORTS_VIEW,
],
member_at_large: [
C.DASHBOARD_VIEW,
C.FINANCIALS_ACCOUNTS_VIEW,
C.FINANCIALS_CASHFLOW_VIEW,
C.FINANCIALS_ACTUALS_VIEW,
C.FINANCIALS_BUDGETS_VIEW,
C.ASSESSMENTS_UNITS_VIEW,
C.ASSESSMENTS_GROUPS_VIEW,
C.PLANNING_BUDGETS_VIEW,
C.PLANNING_PROJECTS_VIEW,
C.PLANNING_SCENARIOS_VIEW,
C.PLANNING_INVESTMENTS_VIEW,
C.REFERENCE_VENDORS_VIEW,
C.REPORTS_VIEW,
],
manager: [
C.DASHBOARD_VIEW,
C.FINANCIALS_ACCOUNTS_VIEW,
C.FINANCIALS_CASHFLOW_VIEW,
C.FINANCIALS_ACTUALS_VIEW, C.FINANCIALS_ACTUALS_EDIT,
C.FINANCIALS_BUDGETS_VIEW,
C.ASSESSMENTS_UNITS_VIEW, C.ASSESSMENTS_UNITS_EDIT,
C.ASSESSMENTS_GROUPS_VIEW,
C.REFERENCE_VENDORS_VIEW, C.REFERENCE_VENDORS_EDIT,
C.TRANSACTIONS_VIEW, C.TRANSACTIONS_EDIT,
C.REPORTS_VIEW,
],
homeowner: [
C.DASHBOARD_VIEW,
C.REPORTS_VIEW,
],
viewer: [
C.DASHBOARD_VIEW,
C.FINANCIALS_ACCOUNTS_VIEW,
C.FINANCIALS_CASHFLOW_VIEW,
C.FINANCIALS_ACTUALS_VIEW,
C.FINANCIALS_BUDGETS_VIEW,
C.ASSESSMENTS_UNITS_VIEW,
C.ASSESSMENTS_GROUPS_VIEW,
C.PLANNING_BUDGETS_VIEW,
C.PLANNING_PROJECTS_VIEW,
C.PLANNING_SCENARIOS_VIEW,
C.PLANNING_INVESTMENTS_VIEW,
C.REFERENCE_VENDORS_VIEW,
C.TRANSACTIONS_VIEW,
C.REPORTS_VIEW,
],
};

View File

@@ -0,0 +1,7 @@
export { CAPABILITIES, ALL_CAPABILITIES, CAPABILITY_AREAS } from './capabilities';
export type { Capability } from './capabilities';
export { DEFAULT_ROLE_CAPABILITIES } from './default-role-capabilities';
export { resolveCapabilities } from './resolve-permissions';
export type { PermissionOverrides } from './resolve-permissions';
export { useHasCapability, useHasAnyCapability, useHasAllCapabilities, useCanEdit } from './useCapability';
export { CapabilityGate } from './CapabilityGate';

View File

@@ -0,0 +1,42 @@
import { ALL_CAPABILITIES } from './capabilities';
import { DEFAULT_ROLE_CAPABILITIES } from './default-role-capabilities';
export interface PermissionOverrides {
[role: string]: {
grant?: string[];
revoke?: string[];
};
}
/**
* Resolve effective capabilities for a role, applying tenant overrides.
*
* Mirrors backend/src/common/permissions/resolve-permissions.ts.
*/
export function resolveCapabilities(
role: string,
overrides?: PermissionOverrides | null,
): Set<string> {
const defaults = DEFAULT_ROLE_CAPABILITIES[role] || [];
const result = new Set<string>(defaults);
if (overrides && overrides[role]) {
const roleOverride = overrides[role];
if (roleOverride.grant) {
for (const cap of roleOverride.grant) {
if (ALL_CAPABILITIES.has(cap)) {
result.add(cap);
}
}
}
if (roleOverride.revoke) {
for (const cap of roleOverride.revoke) {
result.delete(cap);
}
}
}
return result;
}

View File

@@ -0,0 +1,44 @@
import { useAuthStore } from '../stores/authStore';
/**
* Check if the current user has a specific capability.
* Superadmins always return true.
*/
export function useHasCapability(capability: string): boolean {
const user = useAuthStore((s) => s.user);
const capabilities = useAuthStore((s) => s.currentOrg?.capabilities);
if (user?.isSuperadmin) return true;
return capabilities?.includes(capability) ?? false;
}
/**
* Check if the current user has ANY of the given capabilities.
* Superadmins always return true.
*/
export function useHasAnyCapability(...caps: string[]): boolean {
const user = useAuthStore((s) => s.user);
const capabilities = useAuthStore((s) => s.currentOrg?.capabilities);
if (user?.isSuperadmin) return true;
if (!capabilities) return false;
return caps.some((c) => capabilities.includes(c));
}
/**
* Check if the current user has ALL of the given capabilities.
* Superadmins always return true.
*/
export function useHasAllCapabilities(...caps: string[]): boolean {
const user = useAuthStore((s) => s.user);
const capabilities = useAuthStore((s) => s.currentOrg?.capabilities);
if (user?.isSuperadmin) return true;
if (!capabilities) return false;
return caps.every((c) => capabilities.includes(c));
}
/**
* Check if a specific capability string matches the user's capability for edit actions.
* This replaces the old useIsReadOnly() for more granular checks.
*/
export function useCanEdit(editCapability: string): boolean {
return useHasCapability(editCapability);
}

View File

@@ -8,6 +8,7 @@ interface Organization {
status?: string;
planLevel?: string;
settings?: Record<string, any>;
capabilities?: string[];
}
interface User {
@@ -119,7 +120,7 @@ export const useAuthStore = create<AuthState>()(
}),
{
name: 'ledgeriq-auth',
version: 5,
version: 6,
migrate: () => ({
token: null,
user: null,