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>
251 lines
9.0 KiB
TypeScript
251 lines
9.0 KiB
TypeScript
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>
|
|
);
|
|
}
|