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:
@@ -7,6 +7,7 @@ import { AppController } from './app.controller';
|
|||||||
import { DatabaseModule } from './database/database.module';
|
import { DatabaseModule } from './database/database.module';
|
||||||
import { TenantMiddleware } from './database/tenant.middleware';
|
import { TenantMiddleware } from './database/tenant.middleware';
|
||||||
import { WriteAccessGuard } from './common/guards/write-access.guard';
|
import { WriteAccessGuard } from './common/guards/write-access.guard';
|
||||||
|
import { CapabilityGuard } from './common/guards/capability.guard';
|
||||||
import { NoCacheInterceptor } from './common/interceptors/no-cache.interceptor';
|
import { NoCacheInterceptor } from './common/interceptors/no-cache.interceptor';
|
||||||
import { AuthModule } from './modules/auth/auth.module';
|
import { AuthModule } from './modules/auth/auth.module';
|
||||||
import { OrganizationsModule } from './modules/organizations/organizations.module';
|
import { OrganizationsModule } from './modules/organizations/organizations.module';
|
||||||
@@ -100,6 +101,10 @@ import { ScheduleModule } from '@nestjs/schedule';
|
|||||||
provide: APP_GUARD,
|
provide: APP_GUARD,
|
||||||
useClass: WriteAccessGuard,
|
useClass: WriteAccessGuard,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: APP_GUARD,
|
||||||
|
useClass: CapabilityGuard,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
provide: APP_INTERCEPTOR,
|
provide: APP_INTERCEPTOR,
|
||||||
useClass: NoCacheInterceptor,
|
useClass: NoCacheInterceptor,
|
||||||
|
|||||||
14
backend/src/common/decorators/capability.decorator.ts
Normal file
14
backend/src/common/decorators/capability.decorator.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
|
||||||
|
export const CAPABILITIES_KEY = 'required_capabilities';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decorator to require specific capabilities on an endpoint.
|
||||||
|
* User must have ALL listed capabilities to access the endpoint.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* @RequireCapability('financials.accounts.edit')
|
||||||
|
* @RequireCapability('financials.accounts.view', 'financials.accounts.edit')
|
||||||
|
*/
|
||||||
|
export const RequireCapability = (...capabilities: string[]) =>
|
||||||
|
SetMetadata(CAPABILITIES_KEY, capabilities);
|
||||||
83
backend/src/common/guards/capability.guard.ts
Normal file
83
backend/src/common/guards/capability.guard.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import { CAPABILITIES_KEY } from '../decorators/capability.decorator';
|
||||||
|
import { resolveCapabilities } from '../permissions';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CapabilityGuard implements CanActivate {
|
||||||
|
// Cache org settings (including permissionOverrides) per orgId
|
||||||
|
private settingsCache = new Map<string, { settings: Record<string, any>; cachedAt: number }>();
|
||||||
|
private static readonly CACHE_TTL = 60_000; // 60 seconds
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private reflector: Reflector,
|
||||||
|
private dataSource: DataSource,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
const requiredCapabilities = this.reflector.getAllAndOverride<string[]>(CAPABILITIES_KEY, [
|
||||||
|
context.getHandler(),
|
||||||
|
context.getClass(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// No capabilities required — pass through (backward compatible)
|
||||||
|
if (!requiredCapabilities || requiredCapabilities.length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = context.switchToHttp().getRequest();
|
||||||
|
const user = request.user;
|
||||||
|
|
||||||
|
// No authenticated user — let other guards handle auth
|
||||||
|
if (!user) return true;
|
||||||
|
|
||||||
|
// Superadmins bypass all capability checks
|
||||||
|
if (user.isSuperadmin) return true;
|
||||||
|
|
||||||
|
const role = user.role;
|
||||||
|
const orgId = user.orgId;
|
||||||
|
|
||||||
|
if (!role || !orgId) return true;
|
||||||
|
|
||||||
|
// Get org settings (with caching)
|
||||||
|
const settings = await this.getOrgSettings(orgId);
|
||||||
|
const userCapabilities = resolveCapabilities(role, settings?.permissionOverrides);
|
||||||
|
|
||||||
|
// User must have ALL required capabilities
|
||||||
|
const hasAll = requiredCapabilities.every((cap) => userCapabilities.has(cap));
|
||||||
|
if (!hasAll) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'You do not have the required permissions for this action.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getOrgSettings(orgId: string): Promise<Record<string, any> | null> {
|
||||||
|
const cached = this.settingsCache.get(orgId);
|
||||||
|
if (cached && Date.now() - cached.cachedAt < CapabilityGuard.CACHE_TTL) {
|
||||||
|
return cached.settings;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await this.dataSource.query(
|
||||||
|
`SELECT settings FROM shared.organizations WHERE id = $1`,
|
||||||
|
[orgId],
|
||||||
|
);
|
||||||
|
if (result.length > 0) {
|
||||||
|
const settings = result[0].settings || {};
|
||||||
|
this.settingsCache.set(orgId, { settings, cachedAt: Date.now() });
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Non-critical — fall through to use defaults only
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear cached settings for an org (call after settings update) */
|
||||||
|
clearCache(orgId: string) {
|
||||||
|
this.settingsCache.delete(orgId);
|
||||||
|
}
|
||||||
|
}
|
||||||
65
backend/src/common/permissions/capabilities.ts
Normal file
65
backend/src/common/permissions/capabilities.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* Capability taxonomy for the HOA Financial Platform.
|
||||||
|
*
|
||||||
|
* Pattern: {area}.{feature}.{action}
|
||||||
|
* Actions: view, edit, approve, manage
|
||||||
|
*
|
||||||
|
* Add new capabilities here when new features are built.
|
||||||
|
* The default role matrix in ./default-role-capabilities.ts must also be updated.
|
||||||
|
*/
|
||||||
|
export const CAPABILITIES = {
|
||||||
|
// Dashboard
|
||||||
|
DASHBOARD_VIEW: 'dashboard.view',
|
||||||
|
|
||||||
|
// Financials
|
||||||
|
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
|
||||||
|
ASSESSMENTS_UNITS_VIEW: 'assessments.units.view',
|
||||||
|
ASSESSMENTS_UNITS_EDIT: 'assessments.units.edit',
|
||||||
|
ASSESSMENTS_GROUPS_VIEW: 'assessments.groups.view',
|
||||||
|
ASSESSMENTS_GROUPS_EDIT: 'assessments.groups.edit',
|
||||||
|
|
||||||
|
// Board Planning
|
||||||
|
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',
|
||||||
|
|
||||||
|
// Board Reference
|
||||||
|
REFERENCE_VENDORS_VIEW: 'reference.vendors.view',
|
||||||
|
REFERENCE_VENDORS_EDIT: 'reference.vendors.edit',
|
||||||
|
|
||||||
|
// Transactions
|
||||||
|
TRANSACTIONS_VIEW: 'transactions.view',
|
||||||
|
TRANSACTIONS_EDIT: 'transactions.edit',
|
||||||
|
TRANSACTIONS_APPROVE: 'transactions.approve',
|
||||||
|
|
||||||
|
// Reports
|
||||||
|
REPORTS_VIEW: 'reports.view',
|
||||||
|
|
||||||
|
// Settings & Administration
|
||||||
|
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];
|
||||||
|
|
||||||
|
/** Set of all valid capability strings, for validation */
|
||||||
|
export const ALL_CAPABILITIES = new Set<string>(Object.values(CAPABILITIES));
|
||||||
157
backend/src/common/permissions/default-role-capabilities.ts
Normal file
157
backend/src/common/permissions/default-role-capabilities.ts
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import { CAPABILITIES, Capability } from './capabilities';
|
||||||
|
|
||||||
|
const C = CAPABILITIES;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default capability sets per role.
|
||||||
|
*
|
||||||
|
* These represent sensible defaults for a typical HOA. Tenant admins can
|
||||||
|
* customize per-role capabilities via permission overrides in org settings.
|
||||||
|
*
|
||||||
|
* Roles not listed here (e.g. unknown future roles) get zero capabilities.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_ROLE_CAPABILITIES: Record<string, readonly Capability[]> = {
|
||||||
|
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,
|
||||||
|
],
|
||||||
|
};
|
||||||
5
backend/src/common/permissions/index.ts
Normal file
5
backend/src/common/permissions/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export { CAPABILITIES, ALL_CAPABILITIES } from './capabilities';
|
||||||
|
export type { Capability } from './capabilities';
|
||||||
|
export { DEFAULT_ROLE_CAPABILITIES } from './default-role-capabilities';
|
||||||
|
export { resolveCapabilities, resolveCapabilitiesArray } from './resolve-permissions';
|
||||||
|
export type { PermissionOverrides } from './resolve-permissions';
|
||||||
57
backend/src/common/permissions/resolve-permissions.ts
Normal file
57
backend/src/common/permissions/resolve-permissions.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
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.
|
||||||
|
*
|
||||||
|
* 1. Start with default capabilities for the role
|
||||||
|
* 2. Add any granted capabilities from overrides
|
||||||
|
* 3. Remove any revoked capabilities from overrides
|
||||||
|
*
|
||||||
|
* Unknown capabilities in grant/revoke are silently ignored (they may
|
||||||
|
* come from an older version of the overrides).
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience: resolve to a sorted array (for API responses).
|
||||||
|
*/
|
||||||
|
export function resolveCapabilitiesArray(
|
||||||
|
role: string,
|
||||||
|
overrides?: PermissionOverrides | null,
|
||||||
|
): string[] {
|
||||||
|
return Array.from(resolveCapabilities(role, overrides)).sort();
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { RequireCapability } from '../../common/decorators/capability.decorator';
|
||||||
import { AccountsService } from './accounts.service';
|
import { AccountsService } from './accounts.service';
|
||||||
import { CreateAccountDto } from './dto/create-account.dto';
|
import { CreateAccountDto } from './dto/create-account.dto';
|
||||||
import { UpdateAccountDto } from './dto/update-account.dto';
|
import { UpdateAccountDto } from './dto/update-account.dto';
|
||||||
@@ -16,24 +17,28 @@ export class AccountsController {
|
|||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@ApiOperation({ summary: 'List all accounts' })
|
@ApiOperation({ summary: 'List all accounts' })
|
||||||
|
@RequireCapability('financials.accounts.view')
|
||||||
findAll(@Query('fundType') fundType?: string, @Query('includeArchived') includeArchived?: string) {
|
findAll(@Query('fundType') fundType?: string, @Query('includeArchived') includeArchived?: string) {
|
||||||
return this.accountsService.findAll(fundType, includeArchived === 'true');
|
return this.accountsService.findAll(fundType, includeArchived === 'true');
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('trial-balance')
|
@Get('trial-balance')
|
||||||
@ApiOperation({ summary: 'Get trial balance' })
|
@ApiOperation({ summary: 'Get trial balance' })
|
||||||
|
@RequireCapability('financials.accounts.view')
|
||||||
getTrialBalance(@Query('asOfDate') asOfDate?: string) {
|
getTrialBalance(@Query('asOfDate') asOfDate?: string) {
|
||||||
return this.accountsService.getTrialBalance(asOfDate);
|
return this.accountsService.getTrialBalance(asOfDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put(':id/set-primary')
|
@Put(':id/set-primary')
|
||||||
@ApiOperation({ summary: 'Set account as primary for its fund type' })
|
@ApiOperation({ summary: 'Set account as primary for its fund type' })
|
||||||
|
@RequireCapability('financials.accounts.edit')
|
||||||
setPrimary(@Param('id') id: string) {
|
setPrimary(@Param('id') id: string) {
|
||||||
return this.accountsService.setPrimary(id);
|
return this.accountsService.setPrimary(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('bulk-opening-balances')
|
@Post('bulk-opening-balances')
|
||||||
@ApiOperation({ summary: 'Set opening balances for multiple accounts' })
|
@ApiOperation({ summary: 'Set opening balances for multiple accounts' })
|
||||||
|
@RequireCapability('financials.accounts.edit')
|
||||||
bulkSetOpeningBalances(
|
bulkSetOpeningBalances(
|
||||||
@Body() dto: { asOfDate: string; entries: { accountId: string; targetBalance: number }[] },
|
@Body() dto: { asOfDate: string; entries: { accountId: string; targetBalance: number }[] },
|
||||||
) {
|
) {
|
||||||
@@ -42,6 +47,7 @@ export class AccountsController {
|
|||||||
|
|
||||||
@Post(':id/opening-balance')
|
@Post(':id/opening-balance')
|
||||||
@ApiOperation({ summary: 'Set opening balance for an account at a specific date' })
|
@ApiOperation({ summary: 'Set opening balance for an account at a specific date' })
|
||||||
|
@RequireCapability('financials.accounts.edit')
|
||||||
setOpeningBalance(
|
setOpeningBalance(
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@Body() dto: { targetBalance: number; asOfDate: string; memo?: string },
|
@Body() dto: { targetBalance: number; asOfDate: string; memo?: string },
|
||||||
@@ -51,6 +57,7 @@ export class AccountsController {
|
|||||||
|
|
||||||
@Post(':id/adjust-balance')
|
@Post(':id/adjust-balance')
|
||||||
@ApiOperation({ summary: 'Adjust account balance to a target amount' })
|
@ApiOperation({ summary: 'Adjust account balance to a target amount' })
|
||||||
|
@RequireCapability('financials.accounts.edit')
|
||||||
adjustBalance(
|
adjustBalance(
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@Body() dto: { targetBalance: number; asOfDate: string; memo?: string },
|
@Body() dto: { targetBalance: number; asOfDate: string; memo?: string },
|
||||||
@@ -60,6 +67,7 @@ export class AccountsController {
|
|||||||
|
|
||||||
@Post('transfer')
|
@Post('transfer')
|
||||||
@ApiOperation({ summary: 'Transfer funds between asset accounts' })
|
@ApiOperation({ summary: 'Transfer funds between asset accounts' })
|
||||||
|
@RequireCapability('financials.accounts.edit')
|
||||||
transferFunds(
|
transferFunds(
|
||||||
@Body() dto: { fromAccountId: string; toAccountId: string; amount: number; transferDate: string; memo?: string },
|
@Body() dto: { fromAccountId: string; toAccountId: string; amount: number; transferDate: string; memo?: string },
|
||||||
) {
|
) {
|
||||||
@@ -68,18 +76,21 @@ export class AccountsController {
|
|||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@ApiOperation({ summary: 'Get account by ID' })
|
@ApiOperation({ summary: 'Get account by ID' })
|
||||||
|
@RequireCapability('financials.accounts.view')
|
||||||
findOne(@Param('id') id: string) {
|
findOne(@Param('id') id: string) {
|
||||||
return this.accountsService.findOne(id);
|
return this.accountsService.findOne(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@ApiOperation({ summary: 'Create a new account' })
|
@ApiOperation({ summary: 'Create a new account' })
|
||||||
|
@RequireCapability('financials.accounts.edit')
|
||||||
create(@Body() dto: CreateAccountDto) {
|
create(@Body() dto: CreateAccountDto) {
|
||||||
return this.accountsService.create(dto);
|
return this.accountsService.create(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put(':id')
|
@Put(':id')
|
||||||
@ApiOperation({ summary: 'Update an account' })
|
@ApiOperation({ summary: 'Update an account' })
|
||||||
|
@RequireCapability('financials.accounts.edit')
|
||||||
update(@Param('id') id: string, @Body() dto: UpdateAccountDto) {
|
update(@Param('id') id: string, @Body() dto: UpdateAccountDto) {
|
||||||
return this.accountsService.update(id, dto);
|
return this.accountsService.update(id, dto);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Controller, Get, Post, Put, Body, Param, UseGuards } from '@nestjs/common';
|
import { Controller, Get, Post, Put, Body, Param, UseGuards } from '@nestjs/common';
|
||||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { RequireCapability } from '../../common/decorators/capability.decorator';
|
||||||
import { AssessmentGroupsService } from './assessment-groups.service';
|
import { AssessmentGroupsService } from './assessment-groups.service';
|
||||||
|
|
||||||
@ApiTags('assessment-groups')
|
@ApiTags('assessment-groups')
|
||||||
@@ -11,23 +12,30 @@ export class AssessmentGroupsController {
|
|||||||
constructor(private service: AssessmentGroupsService) {}
|
constructor(private service: AssessmentGroupsService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
|
@RequireCapability('assessments.groups.view')
|
||||||
findAll() { return this.service.findAll(); }
|
findAll() { return this.service.findAll(); }
|
||||||
|
|
||||||
@Get('summary')
|
@Get('summary')
|
||||||
|
@RequireCapability('assessments.groups.view')
|
||||||
getSummary() { return this.service.getSummary(); }
|
getSummary() { return this.service.getSummary(); }
|
||||||
|
|
||||||
@Get('default')
|
@Get('default')
|
||||||
|
@RequireCapability('assessments.groups.view')
|
||||||
getDefault() { return this.service.getDefault(); }
|
getDefault() { return this.service.getDefault(); }
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
|
@RequireCapability('assessments.groups.view')
|
||||||
findOne(@Param('id') id: string) { return this.service.findOne(id); }
|
findOne(@Param('id') id: string) { return this.service.findOne(id); }
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
|
@RequireCapability('assessments.groups.edit')
|
||||||
create(@Body() dto: any) { return this.service.create(dto); }
|
create(@Body() dto: any) { return this.service.create(dto); }
|
||||||
|
|
||||||
@Put(':id')
|
@Put(':id')
|
||||||
|
@RequireCapability('assessments.groups.edit')
|
||||||
update(@Param('id') id: string, @Body() dto: any) { return this.service.update(id, dto); }
|
update(@Param('id') id: string, @Body() dto: any) { return this.service.update(id, dto); }
|
||||||
|
|
||||||
@Put(':id/set-default')
|
@Put(':id/set-default')
|
||||||
|
@RequireCapability('assessments.groups.edit')
|
||||||
setDefault(@Param('id') id: string) { return this.service.setDefault(id); }
|
setDefault(@Param('id') id: string) { return this.service.setDefault(id); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { EmailService } from '../email/email.service';
|
|||||||
import { RegisterDto } from './dto/register.dto';
|
import { RegisterDto } from './dto/register.dto';
|
||||||
import { User } from '../users/entities/user.entity';
|
import { User } from '../users/entities/user.entity';
|
||||||
import { RefreshTokenService } from './refresh-token.service';
|
import { RefreshTokenService } from './refresh-token.service';
|
||||||
|
import { resolveCapabilitiesArray } from '../../common/permissions';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
@@ -162,6 +163,12 @@ export class AuthService {
|
|||||||
// Generate new refresh token for org switch
|
// Generate new refresh token for org switch
|
||||||
const refreshToken = await this.refreshTokenService.createRefreshToken(user.id);
|
const refreshToken = await this.refreshTokenService.createRefreshToken(user.id);
|
||||||
|
|
||||||
|
const orgSettings = membership.organization.settings || {};
|
||||||
|
const capabilities = resolveCapabilitiesArray(
|
||||||
|
membership.role,
|
||||||
|
orgSettings.permissionOverrides,
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
accessToken: this.jwtService.sign(payload),
|
accessToken: this.jwtService.sign(payload),
|
||||||
refreshToken,
|
refreshToken,
|
||||||
@@ -169,7 +176,8 @@ export class AuthService {
|
|||||||
id: membership.organization.id,
|
id: membership.organization.id,
|
||||||
name: membership.organization.name,
|
name: membership.organization.name,
|
||||||
role: membership.role,
|
role: membership.role,
|
||||||
settings: membership.organization.settings || {},
|
settings: orgSettings,
|
||||||
|
capabilities,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -468,12 +476,16 @@ export class AuthService {
|
|||||||
hasSeenIntro: user.hasSeenIntro || false,
|
hasSeenIntro: user.hasSeenIntro || false,
|
||||||
mfaEnabled: user.mfaEnabled || false,
|
mfaEnabled: user.mfaEnabled || false,
|
||||||
},
|
},
|
||||||
organizations: orgs.map((uo) => ({
|
organizations: orgs.map((uo) => {
|
||||||
id: uo.organizationId,
|
const settings = uo.organization?.settings || {};
|
||||||
name: uo.organization?.name,
|
return {
|
||||||
status: uo.organization?.status,
|
id: uo.organizationId,
|
||||||
role: uo.role,
|
name: uo.organization?.name,
|
||||||
})),
|
status: uo.organization?.status,
|
||||||
|
role: uo.role,
|
||||||
|
capabilities: resolveCapabilitiesArray(uo.role, settings.permissionOverrides),
|
||||||
|
};
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Response } from 'express';
|
|||||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
import { AllowViewer } from '../../common/decorators/allow-viewer.decorator';
|
import { AllowViewer } from '../../common/decorators/allow-viewer.decorator';
|
||||||
|
import { RequireCapability } from '../../common/decorators/capability.decorator';
|
||||||
import { BoardPlanningService } from './board-planning.service';
|
import { BoardPlanningService } from './board-planning.service';
|
||||||
import { BoardPlanningProjectionService } from './board-planning-projection.service';
|
import { BoardPlanningProjectionService } from './board-planning-projection.service';
|
||||||
import { BudgetPlanningService } from './budget-planning.service';
|
import { BudgetPlanningService } from './budget-planning.service';
|
||||||
@@ -22,27 +23,32 @@ export class BoardPlanningController {
|
|||||||
|
|
||||||
@Get('scenarios')
|
@Get('scenarios')
|
||||||
@AllowViewer()
|
@AllowViewer()
|
||||||
|
@RequireCapability('planning.scenarios.view')
|
||||||
listScenarios(@Query('type') type?: string) {
|
listScenarios(@Query('type') type?: string) {
|
||||||
return this.service.listScenarios(type);
|
return this.service.listScenarios(type);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('scenarios/:id')
|
@Get('scenarios/:id')
|
||||||
@AllowViewer()
|
@AllowViewer()
|
||||||
|
@RequireCapability('planning.scenarios.view')
|
||||||
getScenario(@Param('id') id: string) {
|
getScenario(@Param('id') id: string) {
|
||||||
return this.service.getScenario(id);
|
return this.service.getScenario(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('scenarios')
|
@Post('scenarios')
|
||||||
|
@RequireCapability('planning.scenarios.edit')
|
||||||
createScenario(@Body() dto: any, @Req() req: any) {
|
createScenario(@Body() dto: any, @Req() req: any) {
|
||||||
return this.service.createScenario(dto, req.user.sub);
|
return this.service.createScenario(dto, req.user.sub);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put('scenarios/:id')
|
@Put('scenarios/:id')
|
||||||
|
@RequireCapability('planning.scenarios.edit')
|
||||||
updateScenario(@Param('id') id: string, @Body() dto: any) {
|
updateScenario(@Param('id') id: string, @Body() dto: any) {
|
||||||
return this.service.updateScenario(id, dto);
|
return this.service.updateScenario(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete('scenarios/:id')
|
@Delete('scenarios/:id')
|
||||||
|
@RequireCapability('planning.scenarios.edit')
|
||||||
deleteScenario(@Param('id') id: string) {
|
deleteScenario(@Param('id') id: string) {
|
||||||
return this.service.deleteScenario(id);
|
return this.service.deleteScenario(id);
|
||||||
}
|
}
|
||||||
@@ -51,26 +57,31 @@ export class BoardPlanningController {
|
|||||||
|
|
||||||
@Get('scenarios/:scenarioId/investments')
|
@Get('scenarios/:scenarioId/investments')
|
||||||
@AllowViewer()
|
@AllowViewer()
|
||||||
|
@RequireCapability('planning.scenarios.view')
|
||||||
listInvestments(@Param('scenarioId') scenarioId: string) {
|
listInvestments(@Param('scenarioId') scenarioId: string) {
|
||||||
return this.service.listInvestments(scenarioId);
|
return this.service.listInvestments(scenarioId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('scenarios/:scenarioId/investments')
|
@Post('scenarios/:scenarioId/investments')
|
||||||
|
@RequireCapability('planning.scenarios.edit')
|
||||||
addInvestment(@Param('scenarioId') scenarioId: string, @Body() dto: any) {
|
addInvestment(@Param('scenarioId') scenarioId: string, @Body() dto: any) {
|
||||||
return this.service.addInvestment(scenarioId, dto);
|
return this.service.addInvestment(scenarioId, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('scenarios/:scenarioId/investments/from-recommendation')
|
@Post('scenarios/:scenarioId/investments/from-recommendation')
|
||||||
|
@RequireCapability('planning.scenarios.edit')
|
||||||
addFromRecommendation(@Param('scenarioId') scenarioId: string, @Body() dto: any) {
|
addFromRecommendation(@Param('scenarioId') scenarioId: string, @Body() dto: any) {
|
||||||
return this.service.addInvestmentFromRecommendation(scenarioId, dto);
|
return this.service.addInvestmentFromRecommendation(scenarioId, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put('investments/:id')
|
@Put('investments/:id')
|
||||||
|
@RequireCapability('planning.scenarios.edit')
|
||||||
updateInvestment(@Param('id') id: string, @Body() dto: any) {
|
updateInvestment(@Param('id') id: string, @Body() dto: any) {
|
||||||
return this.service.updateInvestment(id, dto);
|
return this.service.updateInvestment(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete('investments/:id')
|
@Delete('investments/:id')
|
||||||
|
@RequireCapability('planning.scenarios.edit')
|
||||||
removeInvestment(@Param('id') id: string) {
|
removeInvestment(@Param('id') id: string) {
|
||||||
return this.service.removeInvestment(id);
|
return this.service.removeInvestment(id);
|
||||||
}
|
}
|
||||||
@@ -79,21 +90,25 @@ export class BoardPlanningController {
|
|||||||
|
|
||||||
@Get('scenarios/:scenarioId/assessments')
|
@Get('scenarios/:scenarioId/assessments')
|
||||||
@AllowViewer()
|
@AllowViewer()
|
||||||
|
@RequireCapability('planning.scenarios.view')
|
||||||
listAssessments(@Param('scenarioId') scenarioId: string) {
|
listAssessments(@Param('scenarioId') scenarioId: string) {
|
||||||
return this.service.listAssessments(scenarioId);
|
return this.service.listAssessments(scenarioId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('scenarios/:scenarioId/assessments')
|
@Post('scenarios/:scenarioId/assessments')
|
||||||
|
@RequireCapability('planning.scenarios.edit')
|
||||||
addAssessment(@Param('scenarioId') scenarioId: string, @Body() dto: any) {
|
addAssessment(@Param('scenarioId') scenarioId: string, @Body() dto: any) {
|
||||||
return this.service.addAssessment(scenarioId, dto);
|
return this.service.addAssessment(scenarioId, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put('assessments/:id')
|
@Put('assessments/:id')
|
||||||
|
@RequireCapability('planning.scenarios.edit')
|
||||||
updateAssessment(@Param('id') id: string, @Body() dto: any) {
|
updateAssessment(@Param('id') id: string, @Body() dto: any) {
|
||||||
return this.service.updateAssessment(id, dto);
|
return this.service.updateAssessment(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete('assessments/:id')
|
@Delete('assessments/:id')
|
||||||
|
@RequireCapability('planning.scenarios.edit')
|
||||||
removeAssessment(@Param('id') id: string) {
|
removeAssessment(@Param('id') id: string) {
|
||||||
return this.service.removeAssessment(id);
|
return this.service.removeAssessment(id);
|
||||||
}
|
}
|
||||||
@@ -102,11 +117,13 @@ export class BoardPlanningController {
|
|||||||
|
|
||||||
@Get('scenarios/:id/projection')
|
@Get('scenarios/:id/projection')
|
||||||
@AllowViewer()
|
@AllowViewer()
|
||||||
|
@RequireCapability('planning.scenarios.view')
|
||||||
getProjection(@Param('id') id: string) {
|
getProjection(@Param('id') id: string) {
|
||||||
return this.projection.getProjection(id);
|
return this.projection.getProjection(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('scenarios/:id/projection/refresh')
|
@Post('scenarios/:id/projection/refresh')
|
||||||
|
@RequireCapability('planning.scenarios.edit')
|
||||||
refreshProjection(@Param('id') id: string) {
|
refreshProjection(@Param('id') id: string) {
|
||||||
return this.projection.computeProjection(id);
|
return this.projection.computeProjection(id);
|
||||||
}
|
}
|
||||||
@@ -115,6 +132,7 @@ export class BoardPlanningController {
|
|||||||
|
|
||||||
@Get('compare')
|
@Get('compare')
|
||||||
@AllowViewer()
|
@AllowViewer()
|
||||||
|
@RequireCapability('planning.scenarios.view')
|
||||||
compareScenarios(@Query('ids') ids: string) {
|
compareScenarios(@Query('ids') ids: string) {
|
||||||
const scenarioIds = ids.split(',').map((s) => s.trim()).filter(Boolean);
|
const scenarioIds = ids.split(',').map((s) => s.trim()).filter(Boolean);
|
||||||
return this.projection.compareScenarios(scenarioIds);
|
return this.projection.compareScenarios(scenarioIds);
|
||||||
@@ -123,6 +141,7 @@ export class BoardPlanningController {
|
|||||||
// ── Execute Investment ──
|
// ── Execute Investment ──
|
||||||
|
|
||||||
@Post('investments/:id/execute')
|
@Post('investments/:id/execute')
|
||||||
|
@RequireCapability('planning.scenarios.edit')
|
||||||
executeInvestment(
|
executeInvestment(
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@Body() dto: { executionDate: string },
|
@Body() dto: { executionDate: string },
|
||||||
@@ -135,43 +154,51 @@ export class BoardPlanningController {
|
|||||||
|
|
||||||
@Get('budget-plans')
|
@Get('budget-plans')
|
||||||
@AllowViewer()
|
@AllowViewer()
|
||||||
|
@RequireCapability('planning.scenarios.view')
|
||||||
listBudgetPlans() {
|
listBudgetPlans() {
|
||||||
return this.budgetPlanning.listPlans();
|
return this.budgetPlanning.listPlans();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('budget-plans/available-years')
|
@Get('budget-plans/available-years')
|
||||||
@AllowViewer()
|
@AllowViewer()
|
||||||
|
@RequireCapability('planning.scenarios.view')
|
||||||
getAvailableYears() {
|
getAvailableYears() {
|
||||||
return this.budgetPlanning.getAvailableYears();
|
return this.budgetPlanning.getAvailableYears();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('budget-plans/:year')
|
@Get('budget-plans/:year')
|
||||||
@AllowViewer()
|
@AllowViewer()
|
||||||
|
@RequireCapability('planning.scenarios.view')
|
||||||
getBudgetPlan(@Param('year') year: string) {
|
getBudgetPlan(@Param('year') year: string) {
|
||||||
return this.budgetPlanning.getPlan(parseInt(year, 10));
|
return this.budgetPlanning.getPlan(parseInt(year, 10));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('budget-plans')
|
@Post('budget-plans')
|
||||||
|
@RequireCapability('planning.scenarios.edit')
|
||||||
createBudgetPlan(@Body() dto: { fiscalYear: number; baseYear: number; inflationRate?: number }, @Req() req: any) {
|
createBudgetPlan(@Body() dto: { fiscalYear: number; baseYear: number; inflationRate?: number }, @Req() req: any) {
|
||||||
return this.budgetPlanning.createPlan(dto.fiscalYear, dto.baseYear, dto.inflationRate ?? 2.5, req.user.sub);
|
return this.budgetPlanning.createPlan(dto.fiscalYear, dto.baseYear, dto.inflationRate ?? 2.5, req.user.sub);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put('budget-plans/:year/lines')
|
@Put('budget-plans/:year/lines')
|
||||||
|
@RequireCapability('planning.scenarios.edit')
|
||||||
updateBudgetPlanLines(@Param('year') year: string, @Body() dto: { planId: string; lines: any[] }) {
|
updateBudgetPlanLines(@Param('year') year: string, @Body() dto: { planId: string; lines: any[] }) {
|
||||||
return this.budgetPlanning.updateLines(dto.planId, dto.lines);
|
return this.budgetPlanning.updateLines(dto.planId, dto.lines);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put('budget-plans/:year/inflation')
|
@Put('budget-plans/:year/inflation')
|
||||||
|
@RequireCapability('planning.scenarios.edit')
|
||||||
updateBudgetPlanInflation(@Param('year') year: string, @Body() dto: { inflationRate: number }) {
|
updateBudgetPlanInflation(@Param('year') year: string, @Body() dto: { inflationRate: number }) {
|
||||||
return this.budgetPlanning.updateInflation(parseInt(year, 10), dto.inflationRate);
|
return this.budgetPlanning.updateInflation(parseInt(year, 10), dto.inflationRate);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put('budget-plans/:year/status')
|
@Put('budget-plans/:year/status')
|
||||||
|
@RequireCapability('planning.scenarios.edit')
|
||||||
advanceBudgetPlanStatus(@Param('year') year: string, @Body() dto: { status: string }, @Req() req: any) {
|
advanceBudgetPlanStatus(@Param('year') year: string, @Body() dto: { status: string }, @Req() req: any) {
|
||||||
return this.budgetPlanning.advanceStatus(parseInt(year, 10), dto.status, req.user.sub);
|
return this.budgetPlanning.advanceStatus(parseInt(year, 10), dto.status, req.user.sub);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('budget-plans/:year/import')
|
@Post('budget-plans/:year/import')
|
||||||
|
@RequireCapability('planning.scenarios.edit')
|
||||||
importBudgetPlanLines(
|
importBudgetPlanLines(
|
||||||
@Param('year') year: string,
|
@Param('year') year: string,
|
||||||
@Body() lines: any[],
|
@Body() lines: any[],
|
||||||
@@ -181,6 +208,7 @@ export class BoardPlanningController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('budget-plans/:year/template')
|
@Get('budget-plans/:year/template')
|
||||||
|
@RequireCapability('planning.scenarios.view')
|
||||||
async getBudgetPlanTemplate(
|
async getBudgetPlanTemplate(
|
||||||
@Param('year') year: string,
|
@Param('year') year: string,
|
||||||
@Res() res: Response,
|
@Res() res: Response,
|
||||||
@@ -194,6 +222,7 @@ export class BoardPlanningController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Delete('budget-plans/:year')
|
@Delete('budget-plans/:year')
|
||||||
|
@RequireCapability('planning.scenarios.edit')
|
||||||
deleteBudgetPlan(@Param('year') year: string) {
|
deleteBudgetPlan(@Param('year') year: string) {
|
||||||
return this.budgetPlanning.deletePlan(parseInt(year, 10));
|
return this.budgetPlanning.deletePlan(parseInt(year, 10));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Controller, Get, Put, Post, Body, Param, Query, Res, UseGuards, ParseIn
|
|||||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { RequireCapability } from '../../common/decorators/capability.decorator';
|
||||||
import { BudgetsService } from './budgets.service';
|
import { BudgetsService } from './budgets.service';
|
||||||
import { UpsertBudgetDto } from './dto/upsert-budget.dto';
|
import { UpsertBudgetDto } from './dto/upsert-budget.dto';
|
||||||
|
|
||||||
@@ -14,6 +15,7 @@ export class BudgetsController {
|
|||||||
|
|
||||||
@Post(':year/import')
|
@Post(':year/import')
|
||||||
@ApiOperation({ summary: 'Import budget data from parsed CSV/XLSX lines' })
|
@ApiOperation({ summary: 'Import budget data from parsed CSV/XLSX lines' })
|
||||||
|
@RequireCapability('financials.budgets.edit')
|
||||||
importBudget(
|
importBudget(
|
||||||
@Param('year', ParseIntPipe) year: number,
|
@Param('year', ParseIntPipe) year: number,
|
||||||
@Body() lines: any[],
|
@Body() lines: any[],
|
||||||
@@ -23,6 +25,7 @@ export class BudgetsController {
|
|||||||
|
|
||||||
@Get(':year/template')
|
@Get(':year/template')
|
||||||
@ApiOperation({ summary: 'Download budget CSV template for a fiscal year' })
|
@ApiOperation({ summary: 'Download budget CSV template for a fiscal year' })
|
||||||
|
@RequireCapability('financials.budgets.view')
|
||||||
async getTemplate(
|
async getTemplate(
|
||||||
@Param('year', ParseIntPipe) year: number,
|
@Param('year', ParseIntPipe) year: number,
|
||||||
@Res() res: Response,
|
@Res() res: Response,
|
||||||
@@ -37,6 +40,7 @@ export class BudgetsController {
|
|||||||
|
|
||||||
@Get(':year/vs-actual')
|
@Get(':year/vs-actual')
|
||||||
@ApiOperation({ summary: 'Budget vs actual comparison' })
|
@ApiOperation({ summary: 'Budget vs actual comparison' })
|
||||||
|
@RequireCapability('financials.budgets.view')
|
||||||
budgetVsActual(
|
budgetVsActual(
|
||||||
@Param('year', ParseIntPipe) year: number,
|
@Param('year', ParseIntPipe) year: number,
|
||||||
@Query('month') month?: string,
|
@Query('month') month?: string,
|
||||||
@@ -46,12 +50,14 @@ export class BudgetsController {
|
|||||||
|
|
||||||
@Get(':year')
|
@Get(':year')
|
||||||
@ApiOperation({ summary: 'Get budgets for a fiscal year' })
|
@ApiOperation({ summary: 'Get budgets for a fiscal year' })
|
||||||
|
@RequireCapability('financials.budgets.view')
|
||||||
findByYear(@Param('year', ParseIntPipe) year: number) {
|
findByYear(@Param('year', ParseIntPipe) year: number) {
|
||||||
return this.budgetsService.findByYear(year);
|
return this.budgetsService.findByYear(year);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put(':year')
|
@Put(':year')
|
||||||
@ApiOperation({ summary: 'Upsert budgets for a fiscal year' })
|
@ApiOperation({ summary: 'Upsert budgets for a fiscal year' })
|
||||||
|
@RequireCapability('financials.budgets.edit')
|
||||||
upsert(
|
upsert(
|
||||||
@Param('year', ParseIntPipe) year: number,
|
@Param('year', ParseIntPipe) year: number,
|
||||||
@Body() budgets: UpsertBudgetDto[],
|
@Body() budgets: UpsertBudgetDto[],
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Controller, Get, Post, Put, Body, Param, UseGuards } from '@nestjs/common';
|
import { Controller, Get, Post, Put, Body, Param, UseGuards } from '@nestjs/common';
|
||||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { RequireCapability } from '../../common/decorators/capability.decorator';
|
||||||
import { CapitalProjectsService } from './capital-projects.service';
|
import { CapitalProjectsService } from './capital-projects.service';
|
||||||
|
|
||||||
@ApiTags('capital-projects')
|
@ApiTags('capital-projects')
|
||||||
@@ -11,14 +12,18 @@ export class CapitalProjectsController {
|
|||||||
constructor(private service: CapitalProjectsService) {}
|
constructor(private service: CapitalProjectsService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
|
@RequireCapability('planning.projects.view')
|
||||||
findAll() { return this.service.findAll(); }
|
findAll() { return this.service.findAll(); }
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
|
@RequireCapability('planning.projects.view')
|
||||||
findOne(@Param('id') id: string) { return this.service.findOne(id); }
|
findOne(@Param('id') id: string) { return this.service.findOne(id); }
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
|
@RequireCapability('planning.projects.edit')
|
||||||
create(@Body() dto: any) { return this.service.create(dto); }
|
create(@Body() dto: any) { return this.service.create(dto); }
|
||||||
|
|
||||||
@Put(':id')
|
@Put(':id')
|
||||||
|
@RequireCapability('planning.projects.edit')
|
||||||
update(@Param('id') id: string, @Body() dto: any) { return this.service.update(id, dto); }
|
update(@Param('id') id: string, @Body() dto: any) { return this.service.update(id, dto); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Controller, Get, Post, UseGuards, Req } from '@nestjs/common';
|
|||||||
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
|
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
import { AllowViewer } from '../../common/decorators/allow-viewer.decorator';
|
import { AllowViewer } from '../../common/decorators/allow-viewer.decorator';
|
||||||
|
import { RequireCapability } from '../../common/decorators/capability.decorator';
|
||||||
import { InvestmentPlanningService } from './investment-planning.service';
|
import { InvestmentPlanningService } from './investment-planning.service';
|
||||||
|
|
||||||
@ApiTags('investment-planning')
|
@ApiTags('investment-planning')
|
||||||
@@ -13,24 +14,28 @@ export class InvestmentPlanningController {
|
|||||||
|
|
||||||
@Get('snapshot')
|
@Get('snapshot')
|
||||||
@ApiOperation({ summary: 'Get financial snapshot for investment planning' })
|
@ApiOperation({ summary: 'Get financial snapshot for investment planning' })
|
||||||
|
@RequireCapability('planning.investments.view')
|
||||||
getSnapshot() {
|
getSnapshot() {
|
||||||
return this.service.getFinancialSnapshot();
|
return this.service.getFinancialSnapshot();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('cd-rates')
|
@Get('cd-rates')
|
||||||
@ApiOperation({ summary: 'Get latest CD rates from market data (backward compat)' })
|
@ApiOperation({ summary: 'Get latest CD rates from market data (backward compat)' })
|
||||||
|
@RequireCapability('planning.investments.view')
|
||||||
getCdRates() {
|
getCdRates() {
|
||||||
return this.service.getCdRates();
|
return this.service.getCdRates();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('market-rates')
|
@Get('market-rates')
|
||||||
@ApiOperation({ summary: 'Get all market rates grouped by type (CD, Money Market, High Yield Savings)' })
|
@ApiOperation({ summary: 'Get all market rates grouped by type (CD, Money Market, High Yield Savings)' })
|
||||||
|
@RequireCapability('planning.investments.view')
|
||||||
getMarketRates() {
|
getMarketRates() {
|
||||||
return this.service.getMarketRates();
|
return this.service.getMarketRates();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('saved-recommendation')
|
@Get('saved-recommendation')
|
||||||
@ApiOperation({ summary: 'Get the latest saved AI recommendation for this tenant' })
|
@ApiOperation({ summary: 'Get the latest saved AI recommendation for this tenant' })
|
||||||
|
@RequireCapability('planning.investments.view')
|
||||||
getSavedRecommendation() {
|
getSavedRecommendation() {
|
||||||
return this.service.getSavedRecommendation();
|
return this.service.getSavedRecommendation();
|
||||||
}
|
}
|
||||||
@@ -38,6 +43,7 @@ export class InvestmentPlanningController {
|
|||||||
@Post('recommendations')
|
@Post('recommendations')
|
||||||
@ApiOperation({ summary: 'Trigger AI-powered investment recommendations (async — returns immediately)' })
|
@ApiOperation({ summary: 'Trigger AI-powered investment recommendations (async — returns immediately)' })
|
||||||
@AllowViewer()
|
@AllowViewer()
|
||||||
|
@RequireCapability('planning.investments.edit')
|
||||||
triggerRecommendations(@Req() req: any) {
|
triggerRecommendations(@Req() req: any) {
|
||||||
return this.service.triggerAIRecommendations(req.user?.sub, req.user?.orgId);
|
return this.service.triggerAIRecommendations(req.user?.sub, req.user?.orgId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Controller, Get, Post, Put, Body, Param, UseGuards } from '@nestjs/common';
|
import { Controller, Get, Post, Put, Body, Param, UseGuards } from '@nestjs/common';
|
||||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { RequireCapability } from '../../common/decorators/capability.decorator';
|
||||||
import { InvestmentsService } from './investments.service';
|
import { InvestmentsService } from './investments.service';
|
||||||
|
|
||||||
@ApiTags('investments')
|
@ApiTags('investments')
|
||||||
@@ -11,14 +12,18 @@ export class InvestmentsController {
|
|||||||
constructor(private service: InvestmentsService) {}
|
constructor(private service: InvestmentsService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
|
@RequireCapability('planning.investments.view')
|
||||||
findAll() { return this.service.findAll(); }
|
findAll() { return this.service.findAll(); }
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
|
@RequireCapability('planning.investments.view')
|
||||||
findOne(@Param('id') id: string) { return this.service.findOne(id); }
|
findOne(@Param('id') id: string) { return this.service.findOne(id); }
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
|
@RequireCapability('planning.investments.edit')
|
||||||
create(@Body() dto: any) { return this.service.create(dto); }
|
create(@Body() dto: any) { return this.service.create(dto); }
|
||||||
|
|
||||||
@Put(':id')
|
@Put(':id')
|
||||||
|
@RequireCapability('planning.investments.edit')
|
||||||
update(@Param('id') id: string, @Body() dto: any) { return this.service.update(id, dto); }
|
update(@Param('id') id: string, @Body() dto: any) { return this.service.update(id, dto); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Controller, Get, Post, Body, Param, UseGuards, Request } from '@nestjs/common';
|
import { Controller, Get, Post, Body, Param, UseGuards, Request } from '@nestjs/common';
|
||||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { RequireCapability } from '../../common/decorators/capability.decorator';
|
||||||
import { InvoicesService } from './invoices.service';
|
import { InvoicesService } from './invoices.service';
|
||||||
|
|
||||||
@ApiTags('invoices')
|
@ApiTags('invoices')
|
||||||
@@ -11,22 +12,27 @@ export class InvoicesController {
|
|||||||
constructor(private invoicesService: InvoicesService) {}
|
constructor(private invoicesService: InvoicesService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
|
@RequireCapability('transactions.view')
|
||||||
findAll() { return this.invoicesService.findAll(); }
|
findAll() { return this.invoicesService.findAll(); }
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
|
@RequireCapability('transactions.view')
|
||||||
findOne(@Param('id') id: string) { return this.invoicesService.findOne(id); }
|
findOne(@Param('id') id: string) { return this.invoicesService.findOne(id); }
|
||||||
|
|
||||||
@Post('generate-preview')
|
@Post('generate-preview')
|
||||||
|
@RequireCapability('transactions.edit')
|
||||||
generatePreview(@Body() dto: { month: number; year: number }) {
|
generatePreview(@Body() dto: { month: number; year: number }) {
|
||||||
return this.invoicesService.generatePreview(dto);
|
return this.invoicesService.generatePreview(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('generate-bulk')
|
@Post('generate-bulk')
|
||||||
|
@RequireCapability('transactions.edit')
|
||||||
generateBulk(@Body() dto: { month: number; year: number }, @Request() req: any) {
|
generateBulk(@Body() dto: { month: number; year: number }, @Request() req: any) {
|
||||||
return this.invoicesService.generateBulk(dto, req.user.sub);
|
return this.invoicesService.generateBulk(dto, req.user.sub);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('apply-late-fees')
|
@Post('apply-late-fees')
|
||||||
|
@RequireCapability('transactions.edit')
|
||||||
applyLateFees(@Body() dto: { grace_period_days: number; late_fee_amount: number }, @Request() req: any) {
|
applyLateFees(@Body() dto: { grace_period_days: number; late_fee_amount: number }, @Request() req: any) {
|
||||||
return this.invoicesService.applyLateFees(dto, req.user.sub);
|
return this.invoicesService.applyLateFees(dto, req.user.sub);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { RequireCapability } from '../../common/decorators/capability.decorator';
|
||||||
import { JournalEntriesService } from './journal-entries.service';
|
import { JournalEntriesService } from './journal-entries.service';
|
||||||
import { CreateJournalEntryDto } from './dto/create-journal-entry.dto';
|
import { CreateJournalEntryDto } from './dto/create-journal-entry.dto';
|
||||||
import { VoidJournalEntryDto } from './dto/void-journal-entry.dto';
|
import { VoidJournalEntryDto } from './dto/void-journal-entry.dto';
|
||||||
@@ -16,6 +17,7 @@ export class JournalEntriesController {
|
|||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@ApiOperation({ summary: 'List journal entries' })
|
@ApiOperation({ summary: 'List journal entries' })
|
||||||
|
@RequireCapability('transactions.view')
|
||||||
findAll(
|
findAll(
|
||||||
@Query('from') from?: string,
|
@Query('from') from?: string,
|
||||||
@Query('to') to?: string,
|
@Query('to') to?: string,
|
||||||
@@ -27,24 +29,28 @@ export class JournalEntriesController {
|
|||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@ApiOperation({ summary: 'Get journal entry by ID' })
|
@ApiOperation({ summary: 'Get journal entry by ID' })
|
||||||
|
@RequireCapability('transactions.view')
|
||||||
findOne(@Param('id') id: string) {
|
findOne(@Param('id') id: string) {
|
||||||
return this.jeService.findOne(id);
|
return this.jeService.findOne(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@ApiOperation({ summary: 'Create a journal entry' })
|
@ApiOperation({ summary: 'Create a journal entry' })
|
||||||
|
@RequireCapability('transactions.edit')
|
||||||
create(@Body() dto: CreateJournalEntryDto, @Request() req: any) {
|
create(@Body() dto: CreateJournalEntryDto, @Request() req: any) {
|
||||||
return this.jeService.create(dto, req.user.sub);
|
return this.jeService.create(dto, req.user.sub);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/post')
|
@Post(':id/post')
|
||||||
@ApiOperation({ summary: 'Post (finalize) a journal entry' })
|
@ApiOperation({ summary: 'Post (finalize) a journal entry' })
|
||||||
|
@RequireCapability('transactions.edit')
|
||||||
post(@Param('id') id: string, @Request() req: any) {
|
post(@Param('id') id: string, @Request() req: any) {
|
||||||
return this.jeService.post(id, req.user.sub);
|
return this.jeService.post(id, req.user.sub);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/void')
|
@Post(':id/void')
|
||||||
@ApiOperation({ summary: 'Void a journal entry' })
|
@ApiOperation({ summary: 'Void a journal entry' })
|
||||||
|
@RequireCapability('transactions.edit')
|
||||||
void(@Param('id') id: string, @Body() dto: VoidJournalEntryDto, @Request() req: any) {
|
void(@Param('id') id: string, @Body() dto: VoidJournalEntryDto, @Request() req: any) {
|
||||||
return this.jeService.void(id, req.user.sub, dto.reason);
|
return this.jeService.void(id, req.user.sub, dto.reason);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Controller, Get, Post, Param, Body, UseGuards, Request } from '@nestjs/common';
|
import { Controller, Get, Post, Param, Body, UseGuards, Request } from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { RequireCapability } from '../../common/decorators/capability.decorator';
|
||||||
import { MonthlyActualsService } from './monthly-actuals.service';
|
import { MonthlyActualsService } from './monthly-actuals.service';
|
||||||
|
|
||||||
@ApiTags('monthly-actuals')
|
@ApiTags('monthly-actuals')
|
||||||
@@ -12,12 +13,14 @@ export class MonthlyActualsController {
|
|||||||
|
|
||||||
@Get(':year/:month')
|
@Get(':year/:month')
|
||||||
@ApiOperation({ summary: 'Get monthly actuals grid for a specific month' })
|
@ApiOperation({ summary: 'Get monthly actuals grid for a specific month' })
|
||||||
|
@RequireCapability('financials.actuals.view')
|
||||||
async getGrid(@Param('year') year: string, @Param('month') month: string) {
|
async getGrid(@Param('year') year: string, @Param('month') month: string) {
|
||||||
return this.monthlyActualsService.getActualsGrid(parseInt(year), parseInt(month));
|
return this.monthlyActualsService.getActualsGrid(parseInt(year), parseInt(month));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':year/:month')
|
@Post(':year/:month')
|
||||||
@ApiOperation({ summary: 'Save monthly actuals (creates reconciled journal entry)' })
|
@ApiOperation({ summary: 'Save monthly actuals (creates reconciled journal entry)' })
|
||||||
|
@RequireCapability('financials.actuals.edit')
|
||||||
async save(
|
async save(
|
||||||
@Param('year') year: string,
|
@Param('year') year: string,
|
||||||
@Param('month') month: string,
|
@Param('month') month: string,
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
|||||||
import { OrganizationsService } from './organizations.service';
|
import { OrganizationsService } from './organizations.service';
|
||||||
import { CreateOrganizationDto } from './dto/create-organization.dto';
|
import { CreateOrganizationDto } from './dto/create-organization.dto';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { RequireCapability } from '../../common/decorators/capability.decorator';
|
||||||
|
import { resolveCapabilitiesArray, ALL_CAPABILITIES } from '../../common/permissions';
|
||||||
|
|
||||||
@ApiTags('organizations')
|
@ApiTags('organizations')
|
||||||
@Controller('organizations')
|
@Controller('organizations')
|
||||||
@@ -23,54 +25,87 @@ export class OrganizationsController {
|
|||||||
return this.orgService.findByUser(req.user.sub);
|
return this.orgService.findByUser(req.user.sub);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('my-capabilities')
|
||||||
|
@ApiOperation({ summary: 'Get resolved capabilities for current user in current org' })
|
||||||
|
async getMyCapabilities(@Request() req: any) {
|
||||||
|
const org = await this.orgService.findById(req.user.orgId);
|
||||||
|
const settings = org?.settings || {};
|
||||||
|
const capabilities = resolveCapabilitiesArray(req.user.role, settings.permissionOverrides);
|
||||||
|
return { role: req.user.role, capabilities };
|
||||||
|
}
|
||||||
|
|
||||||
@Patch('settings')
|
@Patch('settings')
|
||||||
@ApiOperation({ summary: 'Update settings for the current organization' })
|
@ApiOperation({ summary: 'Update settings for the current organization' })
|
||||||
|
@RequireCapability('settings.org.edit')
|
||||||
async updateSettings(@Request() req: any, @Body() body: Record<string, any>) {
|
async updateSettings(@Request() req: any, @Body() body: Record<string, any>) {
|
||||||
this.requireTenantAdmin(req);
|
// Validate permissionOverrides if present
|
||||||
|
if (body.permissionOverrides) {
|
||||||
|
this.validatePermissionOverrides(body.permissionOverrides);
|
||||||
|
}
|
||||||
return this.orgService.updateSettings(req.user.orgId, body);
|
return this.orgService.updateSettings(req.user.orgId, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Org Member Management ──
|
// ── Org Member Management ──
|
||||||
|
|
||||||
private requireTenantAdmin(req: any) {
|
|
||||||
const role = req.user.role;
|
|
||||||
if (!['president', 'admin', 'treasurer'].includes(role) && !req.user.isSuperadmin) {
|
|
||||||
throw new ForbiddenException('Only organization administrators can manage members');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('members')
|
@Get('members')
|
||||||
@ApiOperation({ summary: 'List members of current organization' })
|
@ApiOperation({ summary: 'List members of current organization' })
|
||||||
|
@RequireCapability('settings.members.view')
|
||||||
async getMembers(@Request() req: any) {
|
async getMembers(@Request() req: any) {
|
||||||
this.requireTenantAdmin(req);
|
|
||||||
return this.orgService.getMembers(req.user.orgId);
|
return this.orgService.getMembers(req.user.orgId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('members')
|
@Post('members')
|
||||||
@ApiOperation({ summary: 'Add a member to the current organization' })
|
@ApiOperation({ summary: 'Add a member to the current organization' })
|
||||||
|
@RequireCapability('settings.members.manage')
|
||||||
async addMember(
|
async addMember(
|
||||||
@Request() req: any,
|
@Request() req: any,
|
||||||
@Body() body: { email: string; firstName: string; lastName: string; password: string; role: string },
|
@Body() body: { email: string; firstName: string; lastName: string; password: string; role: string },
|
||||||
) {
|
) {
|
||||||
this.requireTenantAdmin(req);
|
|
||||||
return this.orgService.addMember(req.user.orgId, body);
|
return this.orgService.addMember(req.user.orgId, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put('members/:id/role')
|
@Put('members/:id/role')
|
||||||
@ApiOperation({ summary: 'Update a member role' })
|
@ApiOperation({ summary: 'Update a member role' })
|
||||||
|
@RequireCapability('settings.members.manage')
|
||||||
async updateMemberRole(
|
async updateMemberRole(
|
||||||
@Request() req: any,
|
@Request() req: any,
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@Body() body: { role: string },
|
@Body() body: { role: string },
|
||||||
) {
|
) {
|
||||||
this.requireTenantAdmin(req);
|
|
||||||
return this.orgService.updateMemberRole(req.user.orgId, id, body.role);
|
return this.orgService.updateMemberRole(req.user.orgId, id, body.role);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete('members/:id')
|
@Delete('members/:id')
|
||||||
@ApiOperation({ summary: 'Remove a member from the organization' })
|
@ApiOperation({ summary: 'Remove a member from the organization' })
|
||||||
|
@RequireCapability('settings.members.manage')
|
||||||
async removeMember(@Request() req: any, @Param('id') id: string) {
|
async removeMember(@Request() req: any, @Param('id') id: string) {
|
||||||
this.requireTenantAdmin(req);
|
|
||||||
return this.orgService.removeMember(req.user.orgId, id, req.user.sub);
|
return this.orgService.removeMember(req.user.orgId, id, req.user.sub);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private validatePermissionOverrides(overrides: any) {
|
||||||
|
if (typeof overrides !== 'object' || overrides === null) {
|
||||||
|
throw new ForbiddenException('permissionOverrides must be an object');
|
||||||
|
}
|
||||||
|
const validRoles = ['president', 'vice_president', 'treasurer', 'secretary', 'member_at_large', 'manager', 'homeowner', 'admin', 'viewer'];
|
||||||
|
for (const role of Object.keys(overrides)) {
|
||||||
|
if (!validRoles.includes(role)) {
|
||||||
|
throw new ForbiddenException(`Invalid role in permissionOverrides: ${role}`);
|
||||||
|
}
|
||||||
|
const entry = overrides[role];
|
||||||
|
if (entry.grant) {
|
||||||
|
for (const cap of entry.grant) {
|
||||||
|
if (!ALL_CAPABILITIES.has(cap)) {
|
||||||
|
throw new ForbiddenException(`Unknown capability in grant: ${cap}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (entry.revoke) {
|
||||||
|
for (const cap of entry.revoke) {
|
||||||
|
if (!ALL_CAPABILITIES.has(cap)) {
|
||||||
|
throw new ForbiddenException(`Unknown capability in revoke: ${cap}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Controller, Get, Post, Put, Delete, Body, Param, UseGuards, Request } from '@nestjs/common';
|
import { Controller, Get, Post, Put, Delete, Body, Param, UseGuards, Request } from '@nestjs/common';
|
||||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { RequireCapability } from '../../common/decorators/capability.decorator';
|
||||||
import { PaymentsService } from './payments.service';
|
import { PaymentsService } from './payments.service';
|
||||||
|
|
||||||
@ApiTags('payments')
|
@ApiTags('payments')
|
||||||
@@ -11,19 +12,24 @@ export class PaymentsController {
|
|||||||
constructor(private paymentsService: PaymentsService) {}
|
constructor(private paymentsService: PaymentsService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
|
@RequireCapability('transactions.view')
|
||||||
findAll() { return this.paymentsService.findAll(); }
|
findAll() { return this.paymentsService.findAll(); }
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
|
@RequireCapability('transactions.view')
|
||||||
findOne(@Param('id') id: string) { return this.paymentsService.findOne(id); }
|
findOne(@Param('id') id: string) { return this.paymentsService.findOne(id); }
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
|
@RequireCapability('transactions.edit')
|
||||||
create(@Body() dto: any, @Request() req: any) { return this.paymentsService.create(dto, req.user.sub); }
|
create(@Body() dto: any, @Request() req: any) { return this.paymentsService.create(dto, req.user.sub); }
|
||||||
|
|
||||||
@Put(':id')
|
@Put(':id')
|
||||||
|
@RequireCapability('transactions.edit')
|
||||||
update(@Param('id') id: string, @Body() dto: any, @Request() req: any) {
|
update(@Param('id') id: string, @Body() dto: any, @Request() req: any) {
|
||||||
return this.paymentsService.update(id, dto, req.user.sub);
|
return this.paymentsService.update(id, dto, req.user.sub);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
|
@RequireCapability('transactions.edit')
|
||||||
delete(@Param('id') id: string) { return this.paymentsService.delete(id); }
|
delete(@Param('id') id: string) { return this.paymentsService.delete(id); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Controller, Get, Post, Put, Body, Param, Res, UseGuards } from '@nestjs
|
|||||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { RequireCapability } from '../../common/decorators/capability.decorator';
|
||||||
import { ProjectsService } from './projects.service';
|
import { ProjectsService } from './projects.service';
|
||||||
|
|
||||||
@ApiTags('projects')
|
@ApiTags('projects')
|
||||||
@@ -12,9 +13,11 @@ export class ProjectsController {
|
|||||||
constructor(private service: ProjectsService) {}
|
constructor(private service: ProjectsService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
|
@RequireCapability('planning.projects.view')
|
||||||
findAll() { return this.service.findAll(); }
|
findAll() { return this.service.findAll(); }
|
||||||
|
|
||||||
@Get('export')
|
@Get('export')
|
||||||
|
@RequireCapability('planning.projects.view')
|
||||||
async exportCSV(@Res() res: Response) {
|
async exportCSV(@Res() res: Response) {
|
||||||
const csv = await this.service.exportCSV();
|
const csv = await this.service.exportCSV();
|
||||||
res.set({ 'Content-Type': 'text/csv', 'Content-Disposition': 'attachment; filename="projects.csv"' });
|
res.set({ 'Content-Type': 'text/csv', 'Content-Disposition': 'attachment; filename="projects.csv"' });
|
||||||
@@ -22,21 +25,27 @@ export class ProjectsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('planning')
|
@Get('planning')
|
||||||
|
@RequireCapability('planning.projects.view')
|
||||||
findForPlanning() { return this.service.findForPlanning(); }
|
findForPlanning() { return this.service.findForPlanning(); }
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
|
@RequireCapability('planning.projects.view')
|
||||||
findOne(@Param('id') id: string) { return this.service.findOne(id); }
|
findOne(@Param('id') id: string) { return this.service.findOne(id); }
|
||||||
|
|
||||||
@Post('import')
|
@Post('import')
|
||||||
|
@RequireCapability('planning.projects.edit')
|
||||||
importCSV(@Body() rows: any[]) { return this.service.importCSV(rows); }
|
importCSV(@Body() rows: any[]) { return this.service.importCSV(rows); }
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
|
@RequireCapability('planning.projects.edit')
|
||||||
create(@Body() dto: any) { return this.service.create(dto); }
|
create(@Body() dto: any) { return this.service.create(dto); }
|
||||||
|
|
||||||
@Put(':id')
|
@Put(':id')
|
||||||
|
@RequireCapability('planning.projects.edit')
|
||||||
update(@Param('id') id: string, @Body() dto: any) { return this.service.update(id, dto); }
|
update(@Param('id') id: string, @Body() dto: any) { return this.service.update(id, dto); }
|
||||||
|
|
||||||
@Put(':id/planned-date')
|
@Put(':id/planned-date')
|
||||||
|
@RequireCapability('planning.projects.edit')
|
||||||
updatePlannedDate(@Param('id') id: string, @Body() dto: { planned_date: string }) {
|
updatePlannedDate(@Param('id') id: string, @Body() dto: { planned_date: string }) {
|
||||||
return this.service.updatePlannedDate(id, dto.planned_date);
|
return this.service.updatePlannedDate(id, dto.planned_date);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { RequireCapability } from '../../common/decorators/capability.decorator';
|
||||||
import { ReportsService } from './reports.service';
|
import { ReportsService } from './reports.service';
|
||||||
|
|
||||||
@ApiTags('reports')
|
@ApiTags('reports')
|
||||||
@@ -11,11 +12,13 @@ export class ReportsController {
|
|||||||
constructor(private reportsService: ReportsService) {}
|
constructor(private reportsService: ReportsService) {}
|
||||||
|
|
||||||
@Get('balance-sheet')
|
@Get('balance-sheet')
|
||||||
|
@RequireCapability('reports.view')
|
||||||
getBalanceSheet(@Query('as_of') asOf?: string) {
|
getBalanceSheet(@Query('as_of') asOf?: string) {
|
||||||
return this.reportsService.getBalanceSheet(asOf || new Date().toISOString().split('T')[0]);
|
return this.reportsService.getBalanceSheet(asOf || new Date().toISOString().split('T')[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('income-statement')
|
@Get('income-statement')
|
||||||
|
@RequireCapability('reports.view')
|
||||||
getIncomeStatement(@Query('from') from?: string, @Query('to') to?: string) {
|
getIncomeStatement(@Query('from') from?: string, @Query('to') to?: string) {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const defaultFrom = `${now.getFullYear()}-01-01`;
|
const defaultFrom = `${now.getFullYear()}-01-01`;
|
||||||
@@ -24,6 +27,7 @@ export class ReportsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('cash-flow-sankey')
|
@Get('cash-flow-sankey')
|
||||||
|
@RequireCapability('reports.view')
|
||||||
getCashFlowSankey(
|
getCashFlowSankey(
|
||||||
@Query('year') year?: string,
|
@Query('year') year?: string,
|
||||||
@Query('source') source?: string,
|
@Query('source') source?: string,
|
||||||
@@ -37,6 +41,7 @@ export class ReportsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('cash-flow')
|
@Get('cash-flow')
|
||||||
|
@RequireCapability('reports.view')
|
||||||
getCashFlowStatement(
|
getCashFlowStatement(
|
||||||
@Query('from') from?: string,
|
@Query('from') from?: string,
|
||||||
@Query('to') to?: string,
|
@Query('to') to?: string,
|
||||||
@@ -51,26 +56,31 @@ export class ReportsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('aging')
|
@Get('aging')
|
||||||
|
@RequireCapability('reports.view')
|
||||||
getAgingReport() {
|
getAgingReport() {
|
||||||
return this.reportsService.getAgingReport();
|
return this.reportsService.getAgingReport();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('year-end')
|
@Get('year-end')
|
||||||
|
@RequireCapability('reports.view')
|
||||||
getYearEndSummary(@Query('year') year?: string) {
|
getYearEndSummary(@Query('year') year?: string) {
|
||||||
return this.reportsService.getYearEndSummary(parseInt(year || '') || new Date().getFullYear());
|
return this.reportsService.getYearEndSummary(parseInt(year || '') || new Date().getFullYear());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('dashboard')
|
@Get('dashboard')
|
||||||
|
@RequireCapability('reports.view')
|
||||||
getDashboardKPIs() {
|
getDashboardKPIs() {
|
||||||
return this.reportsService.getDashboardKPIs();
|
return this.reportsService.getDashboardKPIs();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('upcoming-investment-activities')
|
@Get('upcoming-investment-activities')
|
||||||
|
@RequireCapability('reports.view')
|
||||||
getUpcomingInvestmentActivities() {
|
getUpcomingInvestmentActivities() {
|
||||||
return this.reportsService.getUpcomingInvestmentActivities();
|
return this.reportsService.getUpcomingInvestmentActivities();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('cash-flow-forecast')
|
@Get('cash-flow-forecast')
|
||||||
|
@RequireCapability('reports.view')
|
||||||
getCashFlowForecast(
|
getCashFlowForecast(
|
||||||
@Query('startYear') startYear?: string,
|
@Query('startYear') startYear?: string,
|
||||||
@Query('months') months?: string,
|
@Query('months') months?: string,
|
||||||
@@ -81,6 +91,7 @@ export class ReportsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('capital-planning')
|
@Get('capital-planning')
|
||||||
|
@RequireCapability('reports.view')
|
||||||
getCapitalPlanningReport(@Query('startYear') startYear?: string) {
|
getCapitalPlanningReport(@Query('startYear') startYear?: string) {
|
||||||
return this.reportsService.getCapitalPlanningReport(
|
return this.reportsService.getCapitalPlanningReport(
|
||||||
parseInt(startYear || '') || undefined,
|
parseInt(startYear || '') || undefined,
|
||||||
@@ -88,6 +99,7 @@ export class ReportsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('quarterly')
|
@Get('quarterly')
|
||||||
|
@RequireCapability('reports.view')
|
||||||
getQuarterlyFinancial(
|
getQuarterlyFinancial(
|
||||||
@Query('year') year?: string,
|
@Query('year') year?: string,
|
||||||
@Query('quarter') quarter?: string,
|
@Query('quarter') quarter?: string,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Controller, Get, Post, Put, Body, Param, UseGuards } from '@nestjs/common';
|
import { Controller, Get, Post, Put, Body, Param, UseGuards } from '@nestjs/common';
|
||||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { RequireCapability } from '../../common/decorators/capability.decorator';
|
||||||
import { ReserveComponentsService } from './reserve-components.service';
|
import { ReserveComponentsService } from './reserve-components.service';
|
||||||
|
|
||||||
@ApiTags('reserve-components')
|
@ApiTags('reserve-components')
|
||||||
@@ -11,14 +12,18 @@ export class ReserveComponentsController {
|
|||||||
constructor(private service: ReserveComponentsService) {}
|
constructor(private service: ReserveComponentsService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
|
@RequireCapability('planning.projects.view')
|
||||||
findAll() { return this.service.findAll(); }
|
findAll() { return this.service.findAll(); }
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
|
@RequireCapability('planning.projects.view')
|
||||||
findOne(@Param('id') id: string) { return this.service.findOne(id); }
|
findOne(@Param('id') id: string) { return this.service.findOne(id); }
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
|
@RequireCapability('planning.projects.edit')
|
||||||
create(@Body() dto: any) { return this.service.create(dto); }
|
create(@Body() dto: any) { return this.service.create(dto); }
|
||||||
|
|
||||||
@Put(':id')
|
@Put(':id')
|
||||||
|
@RequireCapability('planning.projects.edit')
|
||||||
update(@Param('id') id: string, @Body() dto: any) { return this.service.update(id, dto); }
|
update(@Param('id') id: string, @Body() dto: any) { return this.service.update(id, dto); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Controller, Get, Post, Put, Delete, Body, Param, Res, UseGuards } from
|
|||||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { RequireCapability } from '../../common/decorators/capability.decorator';
|
||||||
import { UnitsService } from './units.service';
|
import { UnitsService } from './units.service';
|
||||||
|
|
||||||
@ApiTags('units')
|
@ApiTags('units')
|
||||||
@@ -12,9 +13,11 @@ export class UnitsController {
|
|||||||
constructor(private unitsService: UnitsService) {}
|
constructor(private unitsService: UnitsService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
|
@RequireCapability('assessments.units.view')
|
||||||
findAll() { return this.unitsService.findAll(); }
|
findAll() { return this.unitsService.findAll(); }
|
||||||
|
|
||||||
@Get('export')
|
@Get('export')
|
||||||
|
@RequireCapability('assessments.units.view')
|
||||||
async exportCSV(@Res() res: Response) {
|
async exportCSV(@Res() res: Response) {
|
||||||
const csv = await this.unitsService.exportCSV();
|
const csv = await this.unitsService.exportCSV();
|
||||||
res.set({ 'Content-Type': 'text/csv', 'Content-Disposition': 'attachment; filename="units.csv"' });
|
res.set({ 'Content-Type': 'text/csv', 'Content-Disposition': 'attachment; filename="units.csv"' });
|
||||||
@@ -22,17 +25,22 @@ export class UnitsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
|
@RequireCapability('assessments.units.view')
|
||||||
findOne(@Param('id') id: string) { return this.unitsService.findOne(id); }
|
findOne(@Param('id') id: string) { return this.unitsService.findOne(id); }
|
||||||
|
|
||||||
@Post('import')
|
@Post('import')
|
||||||
|
@RequireCapability('assessments.units.edit')
|
||||||
importCSV(@Body() rows: any[]) { return this.unitsService.importCSV(rows); }
|
importCSV(@Body() rows: any[]) { return this.unitsService.importCSV(rows); }
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
|
@RequireCapability('assessments.units.edit')
|
||||||
create(@Body() dto: any) { return this.unitsService.create(dto); }
|
create(@Body() dto: any) { return this.unitsService.create(dto); }
|
||||||
|
|
||||||
@Put(':id')
|
@Put(':id')
|
||||||
|
@RequireCapability('assessments.units.edit')
|
||||||
update(@Param('id') id: string, @Body() dto: any) { return this.unitsService.update(id, dto); }
|
update(@Param('id') id: string, @Body() dto: any) { return this.unitsService.update(id, dto); }
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
|
@RequireCapability('assessments.units.edit')
|
||||||
delete(@Param('id') id: string) { return this.unitsService.delete(id); }
|
delete(@Param('id') id: string) { return this.unitsService.delete(id); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Controller, Get, Post, Put, Body, Param, Query, Res, UseGuards } from '
|
|||||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { RequireCapability } from '../../common/decorators/capability.decorator';
|
||||||
import { VendorsService } from './vendors.service';
|
import { VendorsService } from './vendors.service';
|
||||||
|
|
||||||
@ApiTags('vendors')
|
@ApiTags('vendors')
|
||||||
@@ -12,9 +13,11 @@ export class VendorsController {
|
|||||||
constructor(private vendorsService: VendorsService) {}
|
constructor(private vendorsService: VendorsService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
|
@RequireCapability('reference.vendors.view')
|
||||||
findAll() { return this.vendorsService.findAll(); }
|
findAll() { return this.vendorsService.findAll(); }
|
||||||
|
|
||||||
@Get('export')
|
@Get('export')
|
||||||
|
@RequireCapability('reference.vendors.view')
|
||||||
async exportCSV(@Res() res: Response) {
|
async exportCSV(@Res() res: Response) {
|
||||||
const csv = await this.vendorsService.exportCSV();
|
const csv = await this.vendorsService.exportCSV();
|
||||||
res.set({ 'Content-Type': 'text/csv', 'Content-Disposition': 'attachment; filename="vendors.csv"' });
|
res.set({ 'Content-Type': 'text/csv', 'Content-Disposition': 'attachment; filename="vendors.csv"' });
|
||||||
@@ -22,19 +25,24 @@ export class VendorsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('1099-data')
|
@Get('1099-data')
|
||||||
|
@RequireCapability('reference.vendors.view')
|
||||||
get1099Data(@Query('year') year: string) {
|
get1099Data(@Query('year') year: string) {
|
||||||
return this.vendorsService.get1099Data(parseInt(year) || new Date().getFullYear());
|
return this.vendorsService.get1099Data(parseInt(year) || new Date().getFullYear());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
|
@RequireCapability('reference.vendors.view')
|
||||||
findOne(@Param('id') id: string) { return this.vendorsService.findOne(id); }
|
findOne(@Param('id') id: string) { return this.vendorsService.findOne(id); }
|
||||||
|
|
||||||
@Post('import')
|
@Post('import')
|
||||||
|
@RequireCapability('reference.vendors.edit')
|
||||||
importCSV(@Body() rows: any[]) { return this.vendorsService.importCSV(rows); }
|
importCSV(@Body() rows: any[]) { return this.vendorsService.importCSV(rows); }
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
|
@RequireCapability('reference.vendors.edit')
|
||||||
create(@Body() dto: any) { return this.vendorsService.create(dto); }
|
create(@Body() dto: any) { return this.vendorsService.create(dto); }
|
||||||
|
|
||||||
@Put(':id')
|
@Put(':id')
|
||||||
|
@RequireCapability('reference.vendors.edit')
|
||||||
update(@Param('id') id: string, @Body() dto: any) { return this.vendorsService.update(id, dto); }
|
update(@Param('id') id: string, @Body() dto: any) { return this.vendorsService.update(id, dto); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ CREATE TABLE shared.user_organizations (
|
|||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
user_id UUID NOT NULL REFERENCES shared.users(id) ON DELETE CASCADE,
|
user_id UUID NOT NULL REFERENCES shared.users(id) ON DELETE CASCADE,
|
||||||
organization_id UUID NOT NULL REFERENCES shared.organizations(id) ON DELETE CASCADE,
|
organization_id UUID NOT NULL REFERENCES shared.organizations(id) ON DELETE CASCADE,
|
||||||
role VARCHAR(50) NOT NULL CHECK (role IN ('president', 'treasurer', 'secretary', 'member_at_large', 'manager', 'homeowner', 'admin', 'viewer')),
|
role VARCHAR(50) NOT NULL CHECK (role IN ('president', 'vice_president', 'treasurer', 'secretary', 'member_at_large', 'manager', 'homeowner', 'admin', 'viewer')),
|
||||||
is_active BOOLEAN DEFAULT TRUE,
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
joined_at TIMESTAMPTZ DEFAULT NOW(),
|
joined_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
UNIQUE(user_id, organization_id)
|
UNIQUE(user_id, organization_id)
|
||||||
|
|||||||
9
db/migrations/020-add-vice-president-role.sql
Normal file
9
db/migrations/020-add-vice-president-role.sql
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
-- Migration 020: Add vice_president role to user_organizations
|
||||||
|
-- This adds the vice_president role to the CHECK constraint on the role column.
|
||||||
|
|
||||||
|
ALTER TABLE shared.user_organizations
|
||||||
|
DROP CONSTRAINT IF EXISTS user_organizations_role_check;
|
||||||
|
|
||||||
|
ALTER TABLE shared.user_organizations
|
||||||
|
ADD CONSTRAINT user_organizations_role_check
|
||||||
|
CHECK (role IN ('president', 'vice_president', 'treasurer', 'secretary', 'member_at_large', 'manager', 'homeowner', 'admin', 'viewer'));
|
||||||
@@ -42,6 +42,7 @@ import { AssessmentScenarioDetailPage } from './pages/board-planning/AssessmentS
|
|||||||
import { ScenarioComparisonPage } from './pages/board-planning/ScenarioComparisonPage';
|
import { ScenarioComparisonPage } from './pages/board-planning/ScenarioComparisonPage';
|
||||||
import { BudgetPlanningPage } from './pages/board-planning/BudgetPlanningPage';
|
import { BudgetPlanningPage } from './pages/board-planning/BudgetPlanningPage';
|
||||||
import { PricingPage } from './pages/pricing/PricingPage';
|
import { PricingPage } from './pages/pricing/PricingPage';
|
||||||
|
import { PermissionSettingsPage } from './pages/settings/PermissionSettingsPage';
|
||||||
import { OnboardingPage } from './pages/onboarding/OnboardingPage';
|
import { OnboardingPage } from './pages/onboarding/OnboardingPage';
|
||||||
import { OnboardingPendingPage } from './pages/onboarding/OnboardingPendingPage';
|
import { OnboardingPendingPage } from './pages/onboarding/OnboardingPendingPage';
|
||||||
|
|
||||||
@@ -182,6 +183,7 @@ export function App() {
|
|||||||
<Route path="settings" element={<SettingsPage />} />
|
<Route path="settings" element={<SettingsPage />} />
|
||||||
<Route path="preferences" element={<UserPreferencesPage />} />
|
<Route path="preferences" element={<UserPreferencesPage />} />
|
||||||
<Route path="org-members" element={<OrgMembersPage />} />
|
<Route path="org-members" element={<OrgMembersPage />} />
|
||||||
|
<Route path="settings/permissions" element={<PermissionSettingsPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -77,8 +77,9 @@ export function AppLayout() {
|
|||||||
navigate('/admin');
|
navigate('/admin');
|
||||||
};
|
};
|
||||||
|
|
||||||
// Tenant admins (president role) can manage org members
|
// Capability-based check: can this user manage members?
|
||||||
const isTenantAdmin = currentOrg?.role === 'president' || currentOrg?.role === 'admin';
|
const capabilities = currentOrg?.capabilities || [];
|
||||||
|
const isTenantAdmin = user?.isSuperadmin || capabilities.includes('settings.members.manage');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell
|
<AppShell
|
||||||
|
|||||||
@@ -23,57 +23,60 @@ import {
|
|||||||
IconBulb,
|
IconBulb,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useAuthStore } from '../../stores/authStore';
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
|
import { CAPABILITIES } from '../../permissions/capabilities';
|
||||||
|
|
||||||
|
const C = CAPABILITIES;
|
||||||
|
|
||||||
const navSections = [
|
const navSections = [
|
||||||
{
|
{
|
||||||
items: [
|
items: [
|
||||||
{ label: 'Dashboard', icon: IconDashboard, path: '/dashboard' },
|
{ label: 'Dashboard', icon: IconDashboard, path: '/dashboard', capability: C.DASHBOARD_VIEW },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Financials',
|
label: 'Financials',
|
||||||
items: [
|
items: [
|
||||||
{ label: 'Accounts', icon: IconListDetails, path: '/accounts', tourId: 'nav-accounts' },
|
{ label: 'Accounts', icon: IconListDetails, path: '/accounts', tourId: 'nav-accounts', capability: C.FINANCIALS_ACCOUNTS_VIEW },
|
||||||
{ label: 'Cash Flow', icon: IconChartAreaLine, path: '/cash-flow' },
|
{ label: 'Cash Flow', icon: IconChartAreaLine, path: '/cash-flow', capability: C.FINANCIALS_CASHFLOW_VIEW },
|
||||||
{ label: 'Monthly Actuals', icon: IconClipboardCheck, path: '/monthly-actuals' },
|
{ label: 'Monthly Actuals', icon: IconClipboardCheck, path: '/monthly-actuals', capability: C.FINANCIALS_ACTUALS_VIEW },
|
||||||
{ label: 'Budgets', icon: IconReportAnalytics, path: '/budgets/2026', tourId: 'nav-budgets' },
|
{ label: 'Budgets', icon: IconReportAnalytics, path: '/budgets/2026', tourId: 'nav-budgets', capability: C.FINANCIALS_BUDGETS_VIEW },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Assessments',
|
label: 'Assessments',
|
||||||
items: [
|
items: [
|
||||||
{ label: 'Units / Homeowners', icon: IconHome, path: '/units' },
|
{ label: 'Units / Homeowners', icon: IconHome, path: '/units', capability: C.ASSESSMENTS_UNITS_VIEW },
|
||||||
{ label: 'Assessment Groups', icon: IconCategory, path: '/assessment-groups', tourId: 'nav-assessment-groups' },
|
{ label: 'Assessment Groups', icon: IconCategory, path: '/assessment-groups', tourId: 'nav-assessment-groups', capability: C.ASSESSMENTS_GROUPS_VIEW },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Board Planning',
|
label: 'Board Planning',
|
||||||
items: [
|
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: [
|
children: [
|
||||||
{ label: 'Capital Planning', path: '/capital-projects' },
|
{ 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 Planning', icon: IconSparkles, path: '/investment-planning', tourId: 'nav-investment-planning', capability: C.PLANNING_INVESTMENTS_VIEW },
|
||||||
{ label: 'Investment Scenarios', icon: IconScale, path: '/board-planning/investments' },
|
{ label: 'Investment Scenarios', icon: IconScale, path: '/board-planning/investments', capability: C.PLANNING_SCENARIOS_VIEW },
|
||||||
{ label: 'Compare Scenarios', icon: IconGitCompare, path: '/board-planning/compare' },
|
{ label: 'Compare Scenarios', icon: IconGitCompare, path: '/board-planning/compare', capability: C.PLANNING_SCENARIOS_VIEW },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Board Reference',
|
label: 'Board Reference',
|
||||||
items: [
|
items: [
|
||||||
{ label: 'Vendors', icon: IconUsers, path: '/vendors' },
|
{ label: 'Vendors', icon: IconUsers, path: '/vendors', capability: C.REFERENCE_VENDORS_VIEW },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Transactions',
|
label: 'Transactions',
|
||||||
items: [
|
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
|
// Invoices and Payments hidden — see PARKING-LOT.md for future re-enablement
|
||||||
// { label: 'Invoices', icon: IconFileInvoice, path: '/invoices' },
|
// { label: 'Invoices', icon: IconFileInvoice, path: '/invoices' },
|
||||||
// { label: 'Payments', icon: IconCash, path: '/payments' },
|
// { label: 'Payments', icon: IconCash, path: '/payments' },
|
||||||
@@ -86,6 +89,7 @@ const navSections = [
|
|||||||
label: 'Reports',
|
label: 'Reports',
|
||||||
icon: IconChartSankey,
|
icon: IconChartSankey,
|
||||||
tourId: 'nav-reports',
|
tourId: 'nav-reports',
|
||||||
|
capability: C.REPORTS_VIEW,
|
||||||
children: [
|
children: [
|
||||||
{ label: 'Balance Sheet', path: '/reports/balance-sheet' },
|
{ label: 'Balance Sheet', path: '/reports/balance-sheet' },
|
||||||
{ label: 'Income Statement', path: '/reports/income-statement' },
|
{ label: 'Income Statement', path: '/reports/income-statement' },
|
||||||
@@ -114,6 +118,15 @@ export function Sidebar({ onNavigate }: SidebarProps) {
|
|||||||
const organizations = useAuthStore((s) => s.organizations);
|
const organizations = useAuthStore((s) => s.organizations);
|
||||||
const isAdminOnly = location.pathname.startsWith('/admin') && !currentOrg;
|
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) => {
|
const go = (path: string) => {
|
||||||
navigate(path);
|
navigate(path);
|
||||||
onNavigate?.();
|
onNavigate?.();
|
||||||
@@ -164,7 +177,10 @@ export function Sidebar({ onNavigate }: SidebarProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollArea p="sm" data-tour="sidebar-nav">
|
<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}>
|
<div key={sIdx}>
|
||||||
{section.label && (
|
{section.label && (
|
||||||
<>
|
<>
|
||||||
@@ -174,7 +190,7 @@ export function Sidebar({ onNavigate }: SidebarProps) {
|
|||||||
</Text>
|
</Text>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{section.items.map((item: any) =>
|
{visibleItems.map((item: any) =>
|
||||||
item.children && !item.path ? (
|
item.children && !item.path ? (
|
||||||
// Collapsible group without a parent route (e.g. Reports)
|
// Collapsible group without a parent route (e.g. Reports)
|
||||||
<NavLink
|
<NavLink
|
||||||
@@ -230,7 +246,8 @@ export function Sidebar({ onNavigate }: SidebarProps) {
|
|||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
{user?.isSuperadmin && (
|
{user?.isSuperadmin && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ import {
|
|||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '../../services/api';
|
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'];
|
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 [showArchived, setShowArchived] = useState(false);
|
||||||
const [transferOpened, { open: openTransfer, close: closeTransfer }] = useDisclosure(false);
|
const [transferOpened, { open: openTransfer, close: closeTransfer }] = useDisclosure(false);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const isReadOnly = useIsReadOnly();
|
const isReadOnly = !useCanEdit(CAPABILITIES.FINANCIALS_ACCOUNTS_EDIT);
|
||||||
|
|
||||||
// ── Accounts query ──
|
// ── Accounts query ──
|
||||||
const { data: accounts = [], isLoading } = useQuery<Account[]>({
|
const { data: accounts = [], isLoading } = useQuery<Account[]>({
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
import { useIsReadOnly } from '../../stores/authStore';
|
import { useCanEdit, CAPABILITIES } from '../../permissions';
|
||||||
|
|
||||||
interface AssessmentGroup {
|
interface AssessmentGroup {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -79,7 +79,7 @@ export function AssessmentGroupsPage() {
|
|||||||
const [opened, { open, close }] = useDisclosure(false);
|
const [opened, { open, close }] = useDisclosure(false);
|
||||||
const [editing, setEditing] = useState<AssessmentGroup | null>(null);
|
const [editing, setEditing] = useState<AssessmentGroup | null>(null);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const isReadOnly = useIsReadOnly();
|
const isReadOnly = !useCanEdit(CAPABILITIES.ASSESSMENTS_GROUPS_EDIT);
|
||||||
|
|
||||||
const { data: groups = [], isLoading } = useQuery<AssessmentGroup[]>({
|
const { data: groups = [], isLoading } = useQuery<AssessmentGroup[]>({
|
||||||
queryKey: ['assessment-groups'],
|
queryKey: ['assessment-groups'],
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
import { useIsReadOnly } from '../../stores/authStore';
|
import { useCanEdit, CAPABILITIES } from '../../permissions';
|
||||||
import { usePreferencesStore } from '../../stores/preferencesStore';
|
import { usePreferencesStore } from '../../stores/preferencesStore';
|
||||||
|
|
||||||
interface PlanLine {
|
interface PlanLine {
|
||||||
@@ -87,7 +87,7 @@ const statusColors: Record<string, string> = {
|
|||||||
|
|
||||||
export function BudgetPlanningPage() {
|
export function BudgetPlanningPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const isReadOnly = useIsReadOnly();
|
const isReadOnly = !useCanEdit(CAPABILITIES.PLANNING_BUDGETS_EDIT);
|
||||||
const isDark = usePreferencesStore((s) => s.colorScheme) === 'dark';
|
const isDark = usePreferencesStore((s) => s.colorScheme) === 'dark';
|
||||||
const stickyBg = isDark ? 'var(--mantine-color-dark-7)' : 'white';
|
const stickyBg = isDark ? 'var(--mantine-color-dark-7)' : 'white';
|
||||||
const stickyBorder = isDark ? 'var(--mantine-color-dark-4)' : '#e9ecef';
|
const stickyBorder = isDark ? 'var(--mantine-color-dark-4)' : '#e9ecef';
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { IconDeviceFloppy, IconInfoCircle, IconPencil, IconX, IconArrowRight } f
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
import { useIsReadOnly } from '../../stores/authStore';
|
import { useCanEdit, CAPABILITIES } from '../../permissions';
|
||||||
import { usePreferencesStore } from '../../stores/preferencesStore';
|
import { usePreferencesStore } from '../../stores/preferencesStore';
|
||||||
|
|
||||||
interface BudgetLine {
|
interface BudgetLine {
|
||||||
@@ -40,7 +40,7 @@ export function BudgetsPage() {
|
|||||||
const [editData, setEditData] = useState<BudgetLine[] | null>(null); // null = not editing
|
const [editData, setEditData] = useState<BudgetLine[] | null>(null); // null = not editing
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const isReadOnly = useIsReadOnly();
|
const isReadOnly = !useCanEdit(CAPABILITIES.FINANCIALS_BUDGETS_EDIT);
|
||||||
const isDark = usePreferencesStore((s) => s.colorScheme) === 'dark';
|
const isDark = usePreferencesStore((s) => s.colorScheme) === 'dark';
|
||||||
const stickyBg = isDark ? 'var(--mantine-color-dark-7)' : 'white';
|
const stickyBg = isDark ? 'var(--mantine-color-dark-7)' : 'white';
|
||||||
const stickyBorder = isDark ? 'var(--mantine-color-dark-4)' : '#e9ecef';
|
const stickyBorder = isDark ? 'var(--mantine-color-dark-4)' : '#e9ecef';
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
import { useIsReadOnly } from '../../stores/authStore';
|
import { useCanEdit, CAPABILITIES } from '../../permissions';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Types & constants
|
// Types & constants
|
||||||
@@ -252,7 +252,7 @@ export function CapitalProjectsPage() {
|
|||||||
const [dragOverYear, setDragOverYear] = useState<number | null>(null);
|
const [dragOverYear, setDragOverYear] = useState<number | null>(null);
|
||||||
const printModeRef = useRef(false);
|
const printModeRef = useRef(false);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const isReadOnly = useIsReadOnly();
|
const isReadOnly = !useCanEdit(CAPABILITIES.PLANNING_PROJECTS_EDIT);
|
||||||
|
|
||||||
// ---- Data fetching ----
|
// ---- Data fetching ----
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ import {
|
|||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useNavigate } from 'react-router-dom';
|
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';
|
import api from '../../services/api';
|
||||||
|
|
||||||
interface HealthScore {
|
interface HealthScore {
|
||||||
@@ -350,7 +351,11 @@ interface DashboardData {
|
|||||||
|
|
||||||
export function DashboardPage() {
|
export function DashboardPage() {
|
||||||
const currentOrg = useAuthStore((s) => s.currentOrg);
|
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 queryClient = useQueryClient();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|||||||
import { notifications } from '@mantine/notifications';
|
import { notifications } from '@mantine/notifications';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
import { useIsReadOnly } from '../../stores/authStore';
|
import { useCanEdit, CAPABILITIES } from '../../permissions';
|
||||||
|
|
||||||
// ── Types ──
|
// ── Types ──
|
||||||
|
|
||||||
@@ -385,7 +385,7 @@ export function InvestmentPlanningPage() {
|
|||||||
const [targetScenarioId, setTargetScenarioId] = useState<string | null>(null);
|
const [targetScenarioId, setTargetScenarioId] = useState<string | null>(null);
|
||||||
const [newScenarioName, setNewScenarioName] = useState('');
|
const [newScenarioName, setNewScenarioName] = useState('');
|
||||||
const [investmentStartDate, setInvestmentStartDate] = useState<Date | null>(new Date());
|
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
|
// Load investment scenarios for the "Add to Plan" modal
|
||||||
const { data: investmentScenarios } = useQuery<any[]>({
|
const { data: investmentScenarios } = useQuery<any[]>({
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { notifications } from '@mantine/notifications';
|
|||||||
import { IconPlus, IconEdit } from '@tabler/icons-react';
|
import { IconPlus, IconEdit } from '@tabler/icons-react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
import { useIsReadOnly } from '../../stores/authStore';
|
import { useCanEdit, CAPABILITIES } from '../../permissions';
|
||||||
|
|
||||||
interface Investment {
|
interface Investment {
|
||||||
id: string; name: string; institution: string; account_number_last4: string;
|
id: string; name: string; institution: string; account_number_last4: string;
|
||||||
@@ -26,7 +26,7 @@ export function InvestmentsPage() {
|
|||||||
const [opened, { open, close }] = useDisclosure(false);
|
const [opened, { open, close }] = useDisclosure(false);
|
||||||
const [editing, setEditing] = useState<Investment | null>(null);
|
const [editing, setEditing] = useState<Investment | null>(null);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const isReadOnly = useIsReadOnly();
|
const isReadOnly = !useCanEdit(CAPABILITIES.PLANNING_INVESTMENTS_EDIT);
|
||||||
|
|
||||||
const { data: investments = [], isLoading } = useQuery<Investment[]>({
|
const { data: investments = [], isLoading } = useQuery<Investment[]>({
|
||||||
queryKey: ['investments'],
|
queryKey: ['investments'],
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { notifications } from '@mantine/notifications';
|
|||||||
import { IconSend, IconInfoCircle, IconCheck, IconX } from '@tabler/icons-react';
|
import { IconSend, IconInfoCircle, IconCheck, IconX } from '@tabler/icons-react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
import { useIsReadOnly } from '../../stores/authStore';
|
import { useCanEdit, CAPABILITIES } from '../../permissions';
|
||||||
|
|
||||||
interface Invoice {
|
interface Invoice {
|
||||||
id: string; invoice_number: string; unit_number: string; unit_id: string;
|
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 [preview, setPreview] = useState<Preview | null>(null);
|
||||||
const [previewLoading, setPreviewLoading] = useState(false);
|
const [previewLoading, setPreviewLoading] = useState(false);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const isReadOnly = useIsReadOnly();
|
const isReadOnly = !useCanEdit(CAPABILITIES.TRANSACTIONS_EDIT);
|
||||||
|
|
||||||
const { data: invoices = [], isLoading } = useQuery<Invoice[]>({
|
const { data: invoices = [], isLoading } = useQuery<Invoice[]>({
|
||||||
queryKey: ['invoices'],
|
queryKey: ['invoices'],
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
import { useIsReadOnly } from '../../stores/authStore';
|
import { useCanEdit, CAPABILITIES } from '../../permissions';
|
||||||
import { usePreferencesStore } from '../../stores/preferencesStore';
|
import { usePreferencesStore } from '../../stores/preferencesStore';
|
||||||
import { AttachmentPanel } from '../../components/attachments/AttachmentPanel';
|
import { AttachmentPanel } from '../../components/attachments/AttachmentPanel';
|
||||||
|
|
||||||
@@ -69,7 +69,7 @@ export function MonthlyActualsPage() {
|
|||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [confirmOpened, { open: openConfirm, close: closeConfirm }] = useDisclosure(false);
|
const [confirmOpened, { open: openConfirm, close: closeConfirm }] = useDisclosure(false);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const isReadOnly = useIsReadOnly();
|
const isReadOnly = !useCanEdit(CAPABILITIES.FINANCIALS_ACTUALS_EDIT);
|
||||||
const isDark = usePreferencesStore((s) => s.colorScheme) === 'dark';
|
const isDark = usePreferencesStore((s) => s.colorScheme) === 'dark';
|
||||||
const stickyBg = isDark ? 'var(--mantine-color-dark-7)' : 'white';
|
const stickyBg = isDark ? 'var(--mantine-color-dark-7)' : 'white';
|
||||||
const stickyBorder = isDark ? 'var(--mantine-color-dark-4)' : '#e9ecef';
|
const stickyBorder = isDark ? 'var(--mantine-color-dark-4)' : '#e9ecef';
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ import {
|
|||||||
IconShieldCheck, IconInfoCircle,
|
IconShieldCheck, IconInfoCircle,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
import { useAuthStore, useIsReadOnly } from '../../stores/authStore';
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
|
import { useCanEdit, useHasCapability, CAPABILITIES } from '../../permissions';
|
||||||
|
|
||||||
interface OrgMember {
|
interface OrgMember {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -29,19 +31,21 @@ interface OrgMember {
|
|||||||
|
|
||||||
const ROLE_OPTIONS = [
|
const ROLE_OPTIONS = [
|
||||||
{ value: 'president', label: 'President' },
|
{ value: 'president', label: 'President' },
|
||||||
|
{ value: 'vice_president', label: 'Vice President' },
|
||||||
{ value: 'treasurer', label: 'Treasurer' },
|
{ value: 'treasurer', label: 'Treasurer' },
|
||||||
{ value: 'secretary', label: 'Secretary' },
|
{ value: 'secretary', label: 'Secretary' },
|
||||||
{ value: 'board_member', label: 'Board Member' },
|
{ value: 'member_at_large', label: 'Member at Large' },
|
||||||
{ value: 'property_manager', label: 'Property Manager' },
|
{ value: 'manager', label: 'Property Manager' },
|
||||||
{ value: 'viewer', label: 'Viewer (Read-Only)' },
|
{ value: 'viewer', label: 'Viewer (Read-Only)' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const roleColors: Record<string, string> = {
|
const roleColors: Record<string, string> = {
|
||||||
president: 'red',
|
president: 'red',
|
||||||
|
vice_president: 'grape',
|
||||||
treasurer: 'blue',
|
treasurer: 'blue',
|
||||||
secretary: 'green',
|
secretary: 'green',
|
||||||
board_member: 'violet',
|
member_at_large: 'violet',
|
||||||
property_manager: 'orange',
|
manager: 'orange',
|
||||||
viewer: 'gray',
|
viewer: 'gray',
|
||||||
admin: 'red',
|
admin: 'red',
|
||||||
};
|
};
|
||||||
@@ -52,7 +56,9 @@ export function OrgMembersPage() {
|
|||||||
const [editingMember, setEditingMember] = useState<OrgMember | null>(null);
|
const [editingMember, setEditingMember] = useState<OrgMember | null>(null);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { user, currentOrg } = useAuthStore();
|
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[]>({
|
const { data: members = [], isLoading } = useQuery<OrgMember[]>({
|
||||||
queryKey: ['org-members'],
|
queryKey: ['org-members'],
|
||||||
@@ -68,7 +74,7 @@ export function OrgMembersPage() {
|
|||||||
firstName: '',
|
firstName: '',
|
||||||
lastName: '',
|
lastName: '',
|
||||||
password: '',
|
password: '',
|
||||||
role: 'board_member',
|
role: 'member_at_large',
|
||||||
},
|
},
|
||||||
validate: {
|
validate: {
|
||||||
email: (v) => (/^\S+@\S+\.\S+$/.test(v) ? null : 'Valid email required'),
|
email: (v) => (/^\S+@\S+\.\S+$/.test(v) ? null : 'Valid email required'),
|
||||||
@@ -80,7 +86,7 @@ export function OrgMembersPage() {
|
|||||||
|
|
||||||
const editForm = useForm({
|
const editForm = useForm({
|
||||||
initialValues: {
|
initialValues: {
|
||||||
role: 'board_member',
|
role: 'member_at_large',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -163,11 +169,18 @@ export function OrgMembersPage() {
|
|||||||
<Title order={2}>Organization Members</Title>
|
<Title order={2}>Organization Members</Title>
|
||||||
<Text c="dimmed" size="sm">Manage who has access to {currentOrg?.name}</Text>
|
<Text c="dimmed" size="sm">Manage who has access to {currentOrg?.name}</Text>
|
||||||
</div>
|
</div>
|
||||||
{!isReadOnly && (
|
<Group>
|
||||||
<Button leftSection={<IconUserPlus size={16} />} onClick={openAdd}>
|
{canManagePermissions && (
|
||||||
Add Member
|
<Button variant="light" leftSection={<IconShieldCheck size={16} />} onClick={() => navigate('/settings/permissions')}>
|
||||||
</Button>
|
Role Permissions
|
||||||
)}
|
</Button>
|
||||||
|
)}
|
||||||
|
{!isReadOnly && (
|
||||||
|
<Button leftSection={<IconUserPlus size={16} />} onClick={openAdd}>
|
||||||
|
Add Member
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<SimpleGrid cols={{ base: 1, sm: 3 }}>
|
<SimpleGrid cols={{ base: 1, sm: 3 }}>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { notifications } from '@mantine/notifications';
|
|||||||
import { IconPlus, IconEdit, IconTrash } from '@tabler/icons-react';
|
import { IconPlus, IconEdit, IconTrash } from '@tabler/icons-react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
import { useIsReadOnly } from '../../stores/authStore';
|
import { useCanEdit, CAPABILITIES } from '../../permissions';
|
||||||
|
|
||||||
interface Payment {
|
interface Payment {
|
||||||
id: string; unit_id: string; unit_number: string; invoice_id: string;
|
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 [editing, setEditing] = useState<Payment | null>(null);
|
||||||
const [deleteConfirm, setDeleteConfirm] = useState<Payment | null>(null);
|
const [deleteConfirm, setDeleteConfirm] = useState<Payment | null>(null);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const isReadOnly = useIsReadOnly();
|
const isReadOnly = !useCanEdit(CAPABILITIES.TRANSACTIONS_EDIT);
|
||||||
|
|
||||||
const { data: payments = [], isLoading } = useQuery<Payment[]>({
|
const { data: payments = [], isLoading } = useQuery<Payment[]>({
|
||||||
queryKey: ['payments'],
|
queryKey: ['payments'],
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { IconPlus, IconEdit, IconUpload, IconDownload, IconLock, IconLockOpen, I
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
import { parseCSV, downloadBlob } from '../../utils/csv';
|
import { parseCSV, downloadBlob } from '../../utils/csv';
|
||||||
import { useIsReadOnly } from '../../stores/authStore';
|
import { useCanEdit, CAPABILITIES } from '../../permissions';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Types & constants
|
// Types & constants
|
||||||
@@ -79,7 +79,7 @@ export function ProjectsPage() {
|
|||||||
const [editing, setEditing] = useState<Project | null>(null);
|
const [editing, setEditing] = useState<Project | null>(null);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const isReadOnly = useIsReadOnly();
|
const isReadOnly = !useCanEdit(CAPABILITIES.PLANNING_PROJECTS_EDIT);
|
||||||
|
|
||||||
// ---- Data fetching ----
|
// ---- Data fetching ----
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { notifications } from '@mantine/notifications';
|
|||||||
import { IconPlus, IconEdit } from '@tabler/icons-react';
|
import { IconPlus, IconEdit } from '@tabler/icons-react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
import { useIsReadOnly } from '../../stores/authStore';
|
import { useCanEdit, CAPABILITIES } from '../../permissions';
|
||||||
|
|
||||||
interface ReserveComponent {
|
interface ReserveComponent {
|
||||||
id: string; name: string; category: string; description: string;
|
id: string; name: string; category: string; description: string;
|
||||||
@@ -27,7 +27,7 @@ export function ReservesPage() {
|
|||||||
const [opened, { open, close }] = useDisclosure(false);
|
const [opened, { open, close }] = useDisclosure(false);
|
||||||
const [editing, setEditing] = useState<ReserveComponent | null>(null);
|
const [editing, setEditing] = useState<ReserveComponent | null>(null);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const isReadOnly = useIsReadOnly();
|
const isReadOnly = !useCanEdit(CAPABILITIES.PLANNING_PROJECTS_EDIT);
|
||||||
|
|
||||||
const { data: components = [], isLoading } = useQuery<ReserveComponent[]>({
|
const { data: components = [], isLoading } = useQuery<ReserveComponent[]>({
|
||||||
queryKey: ['reserve-components'],
|
queryKey: ['reserve-components'],
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ import { IconPlus, IconEye, IconCheck, IconX, IconTrash, IconShieldCheck } from
|
|||||||
import { AttachmentPanel } from '../../components/attachments/AttachmentPanel';
|
import { AttachmentPanel } from '../../components/attachments/AttachmentPanel';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
import { useIsReadOnly } from '../../stores/authStore';
|
import { useCanEdit, CAPABILITIES } from '../../permissions';
|
||||||
|
|
||||||
interface JournalEntryLine {
|
interface JournalEntryLine {
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -49,7 +49,7 @@ export function TransactionsPage() {
|
|||||||
const [opened, { open, close }] = useDisclosure(false);
|
const [opened, { open, close }] = useDisclosure(false);
|
||||||
const [viewId, setViewId] = useState<string | null>(null);
|
const [viewId, setViewId] = useState<string | null>(null);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const isReadOnly = useIsReadOnly();
|
const isReadOnly = !useCanEdit(CAPABILITIES.TRANSACTIONS_EDIT);
|
||||||
|
|
||||||
const { data: entries = [], isLoading } = useQuery<JournalEntry[]>({
|
const { data: entries = [], isLoading } = useQuery<JournalEntry[]>({
|
||||||
queryKey: ['journal-entries'],
|
queryKey: ['journal-entries'],
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { IconPlus, IconEdit, IconSearch, IconTrash, IconInfoCircle, IconUpload,
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
import { parseCSV, downloadBlob } from '../../utils/csv';
|
import { parseCSV, downloadBlob } from '../../utils/csv';
|
||||||
import { useIsReadOnly } from '../../stores/authStore';
|
import { useCanEdit, CAPABILITIES } from '../../permissions';
|
||||||
|
|
||||||
interface Unit {
|
interface Unit {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -43,7 +43,7 @@ export function UnitsPage() {
|
|||||||
const [deleteConfirm, setDeleteConfirm] = useState<Unit | null>(null);
|
const [deleteConfirm, setDeleteConfirm] = useState<Unit | null>(null);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const isReadOnly = useIsReadOnly();
|
const isReadOnly = !useCanEdit(CAPABILITIES.ASSESSMENTS_UNITS_EDIT);
|
||||||
|
|
||||||
const { data: units = [], isLoading } = useQuery<Unit[]>({
|
const { data: units = [], isLoading } = useQuery<Unit[]>({
|
||||||
queryKey: ['units'],
|
queryKey: ['units'],
|
||||||
|
|||||||
4
frontend/src/pages/vendors/VendorsPage.tsx
vendored
4
frontend/src/pages/vendors/VendorsPage.tsx
vendored
@@ -10,7 +10,7 @@ import { notifications } from '@mantine/notifications';
|
|||||||
import { IconPlus, IconEdit, IconSearch, IconUpload, IconDownload, IconUsers, IconBulb, IconRocket } from '@tabler/icons-react';
|
import { IconPlus, IconEdit, IconSearch, IconUpload, IconDownload, IconUsers, IconBulb, IconRocket } from '@tabler/icons-react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
import { useIsReadOnly } from '../../stores/authStore';
|
import { useCanEdit, CAPABILITIES } from '../../permissions';
|
||||||
import { parseCSV, downloadBlob } from '../../utils/csv';
|
import { parseCSV, downloadBlob } from '../../utils/csv';
|
||||||
|
|
||||||
interface Vendor {
|
interface Vendor {
|
||||||
@@ -26,7 +26,7 @@ export function VendorsPage() {
|
|||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const isReadOnly = useIsReadOnly();
|
const isReadOnly = !useCanEdit(CAPABILITIES.REFERENCE_VENDORS_EDIT);
|
||||||
|
|
||||||
const { data: vendors = [], isLoading } = useQuery<Vendor[]>({
|
const { data: vendors = [], isLoading } = useQuery<Vendor[]>({
|
||||||
queryKey: ['vendors'],
|
queryKey: ['vendors'],
|
||||||
|
|||||||
22
frontend/src/permissions/CapabilityGate.tsx
Normal file
22
frontend/src/permissions/CapabilityGate.tsx
Normal 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}</>;
|
||||||
|
}
|
||||||
131
frontend/src/permissions/capabilities.ts
Normal file
131
frontend/src/permissions/capabilities.ts
Normal 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' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
155
frontend/src/permissions/default-role-capabilities.ts
Normal file
155
frontend/src/permissions/default-role-capabilities.ts
Normal 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,
|
||||||
|
],
|
||||||
|
};
|
||||||
7
frontend/src/permissions/index.ts
Normal file
7
frontend/src/permissions/index.ts
Normal 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';
|
||||||
42
frontend/src/permissions/resolve-permissions.ts
Normal file
42
frontend/src/permissions/resolve-permissions.ts
Normal 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;
|
||||||
|
}
|
||||||
44
frontend/src/permissions/useCapability.ts
Normal file
44
frontend/src/permissions/useCapability.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ interface Organization {
|
|||||||
status?: string;
|
status?: string;
|
||||||
planLevel?: string;
|
planLevel?: string;
|
||||||
settings?: Record<string, any>;
|
settings?: Record<string, any>;
|
||||||
|
capabilities?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
@@ -119,7 +120,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: 'ledgeriq-auth',
|
name: 'ledgeriq-auth',
|
||||||
version: 5,
|
version: 6,
|
||||||
migrate: () => ({
|
migrate: () => ({
|
||||||
token: null,
|
token: null,
|
||||||
user: null,
|
user: null,
|
||||||
|
|||||||
Reference in New Issue
Block a user