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:
250
frontend/src/pages/settings/PermissionSettingsPage.tsx
Normal file
250
frontend/src/pages/settings/PermissionSettingsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user