18 Commits

Author SHA1 Message Date
4df796e977 Merge pull request 'feature-deploy-script' (#18) from feature-deploy-script into main
Reviewed-on: #18
2026-04-09 09:36:16 -04:00
a7e3f80eda Merge branch 'main' into feature-deploy-script 2026-04-09 09:36:06 -04:00
19bd19b0c4 docs: add Gitea Actions runner setup guide for production server
Step-by-step guide covering act_runner installation, registration,
host execution mode configuration, systemd service setup, and
troubleshooting for the HOALedgerIQ production deployment workflow.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 09:35:49 -04:00
3e7463cf46 fix: replace curl with Docker health status and wget for health check
The health check used curl which is not installed on the prod server.
Replace with a dual approach:
1. Primary: check Docker's own container health status (already running
   via docker-compose.prod.yml healthcheck with wget inside container)
2. Secondary: wget from host as fallback signal

Also add diagnostic logging (container status + recent backend logs)
before triggering rollback on health check failure.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 09:22:28 -04:00
cefcc296fb Merge pull request 'fix: resolve unbound variable error in deploy script migration check' (#17) from feature-deploy-script into main
Reviewed-on: #17
2026-04-09 09:16:34 -04:00
2aad137bd7 fix: resolve unbound variable error in deploy script migration check
The APPLIED_MIGRATIONS associative array triggered "unbound variable"
under set -u when empty (first run / seed-existing). Fix by initializing
with =() and using a safe helper function with ${:-} default syntax.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 09:15:54 -04:00
f5bea7cdc2 Merge pull request 'fix: remove bc dependency from db-backup.sh format_size function' (#16) from feature-deploy-script into main
Reviewed-on: #16
2026-04-09 09:10:00 -04:00
e06ca74d1d fix: remove bc dependency from db-backup.sh format_size function
Replace bc-based floating point division with pure bash integer
arithmetic so the script works on minimal Ubuntu servers without
bc installed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 09:09:24 -04:00
5144da4680 Merge pull request 'feat: add production deploy script with auto-rollback and Gitea Actions workflow' (#15) from feature-deploy-script into main
Reviewed-on: #15
2026-04-09 09:06:54 -04:00
95c83a57b6 feat: add production deploy script with auto-rollback and Gitea Actions workflow
Add automated production deployment pipeline:
- scripts/deploy-prod.sh: Full deployment script with pre/post DB backups,
  migration tracking via shared.schema_migrations table, health checks,
  and automatic rollback on failure (restores DB, reverts code, rebuilds)
- .gitea/workflows/deploy.yml: Manual-trigger Gitea Actions workflow for
  intentional production deployments with optional --seed-existing flag
- scripts/db-backup.sh: Add --yes/-y flag to skip interactive confirmation
  prompts, enabling automated restore during rollback

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 09:05:45 -04:00
83115c9b5c Merge pull request 'feat: add flexible capability-based RBAC with per-tenant customization' (#14) from feature-rbac into main
Reviewed-on: #14
2026-04-06 16:13:26 -04:00
c57dd3e155 Merge branch 'main' into feature-rbac 2026-04-06 16:13:17 -04:00
afe5633b0a Updating Version 2026-04-06 16:13:00 -04:00
43b10869f0 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>
2026-04-06 15:28:14 -04:00
f76c67f51a Update Version 2026-04-05 09:16:51 -04:00
5fec296569 Merge pull request 'fix: normalize API URL to prevent duplicate /chat/completions path' (#13) from feature-shadowAI into main
Reviewed-on: #13
2026-04-05 08:19:26 -04:00
c981676bc7 Merge branch 'main' into feature-shadowAI 2026-04-05 08:19:15 -04:00
JoeBot
bd174fc22b fix: normalize API URL to prevent duplicate /chat/completions path
Users entering the full endpoint URL (e.g. https://openrouter.ai/api/v1/chat/completions)
caused a 404 because the code appended /chat/completions again. Now strips any trailing
/chat/completions before re-appending, and adds a hint in the UI.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 08:14:37 -04:00
62 changed files with 2091 additions and 94 deletions

View File

@@ -0,0 +1,65 @@
# ---------------------------------------------------------------------------
# Production Deployment Workflow for HOA LedgerIQ
#
# Trigger: Manual only (workflow_dispatch) — production deploys are intentional.
# Runner: Self-hosted on the production server at /opt/hoa-ledgeriq.
#
# This workflow does NOT use actions/checkout. The runner operates directly
# on the production directory. The deploy script itself handles git pull.
# ---------------------------------------------------------------------------
name: Deploy to Production
on:
workflow_dispatch:
inputs:
seed_existing:
description: "Mark existing migrations as applied without running them (first deployment only)"
required: false
default: "false"
type: boolean
jobs:
deploy:
name: Deploy
runs-on: ubuntu-latest
defaults:
run:
working-directory: /opt/hoa-ledgeriq
steps:
- name: Pre-deploy info
run: |
echo "## Pre-Deploy Info" >> $GITHUB_STEP_SUMMARY
echo "- **Server:** $(hostname)" >> $GITHUB_STEP_SUMMARY
echo "- **Directory:** $(pwd)" >> $GITHUB_STEP_SUMMARY
echo "- **Current commit:** $(git rev-parse --short HEAD)" >> $GITHUB_STEP_SUMMARY
echo "- **Branch:** $(git branch --show-current || echo 'detached')" >> $GITHUB_STEP_SUMMARY
echo "- **Triggered by:** ${{ github.actor }}" >> $GITHUB_STEP_SUMMARY
echo "- **Seed existing:** ${{ inputs.seed_existing }}" >> $GITHUB_STEP_SUMMARY
echo "- **Started at:** $(date -Iseconds)" >> $GITHUB_STEP_SUMMARY
- name: Run deployment
run: |
DEPLOY_FLAGS=""
if [ "${{ inputs.seed_existing }}" = "true" ]; then
DEPLOY_FLAGS="--seed-existing"
fi
bash scripts/deploy-prod.sh $DEPLOY_FLAGS
env:
TERM: xterm
- name: Deployment result
if: always()
run: |
echo "" >> $GITHUB_STEP_SUMMARY
echo "## Deployment Result" >> $GITHUB_STEP_SUMMARY
if [ "${{ job.status }}" = "success" ]; then
echo "- **Status:** Successful" >> $GITHUB_STEP_SUMMARY
echo "- **Commit:** $(git rev-parse --short HEAD)" >> $GITHUB_STEP_SUMMARY
else
echo "- **Status:** FAILED (auto-rollback triggered)" >> $GITHUB_STEP_SUMMARY
echo "- **Commit (after rollback):** $(git rev-parse --short HEAD)" >> $GITHUB_STEP_SUMMARY
echo "- Check the deploy log on the server for details" >> $GITHUB_STEP_SUMMARY
fi
echo "- **Completed at:** $(date -Iseconds)" >> $GITHUB_STEP_SUMMARY

View File

@@ -7,6 +7,7 @@ import { AppController } from './app.controller';
import { DatabaseModule } from './database/database.module';
import { TenantMiddleware } from './database/tenant.middleware';
import { WriteAccessGuard } from './common/guards/write-access.guard';
import { CapabilityGuard } from './common/guards/capability.guard';
import { NoCacheInterceptor } from './common/interceptors/no-cache.interceptor';
import { AuthModule } from './modules/auth/auth.module';
import { OrganizationsModule } from './modules/organizations/organizations.module';
@@ -100,6 +101,10 @@ import { ScheduleModule } from '@nestjs/schedule';
provide: APP_GUARD,
useClass: WriteAccessGuard,
},
{
provide: APP_GUARD,
useClass: CapabilityGuard,
},
{
provide: APP_INTERCEPTOR,
useClass: NoCacheInterceptor,

View 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);

View 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);
}
}

View 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));

View 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,
],
};

View 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';

View 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();
}

View File

@@ -37,7 +37,12 @@ export async function callOpenAICompatible(params: AICallerParams): Promise<AICa
const https = await import('https');
const aiResult = await new Promise<{ status: number; body: string }>((resolve, reject) => {
const url = new URL(`${apiUrl}/chat/completions`);
// Normalize: strip trailing slash and /chat/completions if user included it
let baseUrl = apiUrl.replace(/\/+$/, '');
if (baseUrl.endsWith('/chat/completions')) {
baseUrl = baseUrl.slice(0, -'/chat/completions'.length);
}
const url = new URL(`${baseUrl}/chat/completions`);
const options = {
hostname: url.hostname,

View File

@@ -3,6 +3,7 @@ import {
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RequireCapability } from '../../common/decorators/capability.decorator';
import { AccountsService } from './accounts.service';
import { CreateAccountDto } from './dto/create-account.dto';
import { UpdateAccountDto } from './dto/update-account.dto';
@@ -16,24 +17,28 @@ export class AccountsController {
@Get()
@ApiOperation({ summary: 'List all accounts' })
@RequireCapability('financials.accounts.view')
findAll(@Query('fundType') fundType?: string, @Query('includeArchived') includeArchived?: string) {
return this.accountsService.findAll(fundType, includeArchived === 'true');
}
@Get('trial-balance')
@ApiOperation({ summary: 'Get trial balance' })
@RequireCapability('financials.accounts.view')
getTrialBalance(@Query('asOfDate') asOfDate?: string) {
return this.accountsService.getTrialBalance(asOfDate);
}
@Put(':id/set-primary')
@ApiOperation({ summary: 'Set account as primary for its fund type' })
@RequireCapability('financials.accounts.edit')
setPrimary(@Param('id') id: string) {
return this.accountsService.setPrimary(id);
}
@Post('bulk-opening-balances')
@ApiOperation({ summary: 'Set opening balances for multiple accounts' })
@RequireCapability('financials.accounts.edit')
bulkSetOpeningBalances(
@Body() dto: { asOfDate: string; entries: { accountId: string; targetBalance: number }[] },
) {
@@ -42,6 +47,7 @@ export class AccountsController {
@Post(':id/opening-balance')
@ApiOperation({ summary: 'Set opening balance for an account at a specific date' })
@RequireCapability('financials.accounts.edit')
setOpeningBalance(
@Param('id') id: string,
@Body() dto: { targetBalance: number; asOfDate: string; memo?: string },
@@ -51,6 +57,7 @@ export class AccountsController {
@Post(':id/adjust-balance')
@ApiOperation({ summary: 'Adjust account balance to a target amount' })
@RequireCapability('financials.accounts.edit')
adjustBalance(
@Param('id') id: string,
@Body() dto: { targetBalance: number; asOfDate: string; memo?: string },
@@ -60,6 +67,7 @@ export class AccountsController {
@Post('transfer')
@ApiOperation({ summary: 'Transfer funds between asset accounts' })
@RequireCapability('financials.accounts.edit')
transferFunds(
@Body() dto: { fromAccountId: string; toAccountId: string; amount: number; transferDate: string; memo?: string },
) {
@@ -68,18 +76,21 @@ export class AccountsController {
@Get(':id')
@ApiOperation({ summary: 'Get account by ID' })
@RequireCapability('financials.accounts.view')
findOne(@Param('id') id: string) {
return this.accountsService.findOne(id);
}
@Post()
@ApiOperation({ summary: 'Create a new account' })
@RequireCapability('financials.accounts.edit')
create(@Body() dto: CreateAccountDto) {
return this.accountsService.create(dto);
}
@Put(':id')
@ApiOperation({ summary: 'Update an account' })
@RequireCapability('financials.accounts.edit')
update(@Param('id') id: string, @Body() dto: UpdateAccountDto) {
return this.accountsService.update(id, dto);
}

View File

@@ -1,6 +1,7 @@
import { Controller, Get, Post, Put, Body, Param, UseGuards } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RequireCapability } from '../../common/decorators/capability.decorator';
import { AssessmentGroupsService } from './assessment-groups.service';
@ApiTags('assessment-groups')
@@ -11,23 +12,30 @@ export class AssessmentGroupsController {
constructor(private service: AssessmentGroupsService) {}
@Get()
@RequireCapability('assessments.groups.view')
findAll() { return this.service.findAll(); }
@Get('summary')
@RequireCapability('assessments.groups.view')
getSummary() { return this.service.getSummary(); }
@Get('default')
@RequireCapability('assessments.groups.view')
getDefault() { return this.service.getDefault(); }
@Get(':id')
@RequireCapability('assessments.groups.view')
findOne(@Param('id') id: string) { return this.service.findOne(id); }
@Post()
@RequireCapability('assessments.groups.edit')
create(@Body() dto: any) { return this.service.create(dto); }
@Put(':id')
@RequireCapability('assessments.groups.edit')
update(@Param('id') id: string, @Body() dto: any) { return this.service.update(id, dto); }
@Put(':id/set-default')
@RequireCapability('assessments.groups.edit')
setDefault(@Param('id') id: string) { return this.service.setDefault(id); }
}

View File

@@ -17,6 +17,7 @@ import { EmailService } from '../email/email.service';
import { RegisterDto } from './dto/register.dto';
import { User } from '../users/entities/user.entity';
import { RefreshTokenService } from './refresh-token.service';
import { resolveCapabilitiesArray } from '../../common/permissions';
@Injectable()
export class AuthService {
@@ -162,6 +163,12 @@ export class AuthService {
// Generate new refresh token for org switch
const refreshToken = await this.refreshTokenService.createRefreshToken(user.id);
const orgSettings = membership.organization.settings || {};
const capabilities = resolveCapabilitiesArray(
membership.role,
orgSettings.permissionOverrides,
);
return {
accessToken: this.jwtService.sign(payload),
refreshToken,
@@ -169,7 +176,8 @@ export class AuthService {
id: membership.organization.id,
name: membership.organization.name,
role: membership.role,
settings: membership.organization.settings || {},
settings: orgSettings,
capabilities,
},
};
}
@@ -468,12 +476,16 @@ export class AuthService {
hasSeenIntro: user.hasSeenIntro || false,
mfaEnabled: user.mfaEnabled || false,
},
organizations: orgs.map((uo) => ({
id: uo.organizationId,
name: uo.organization?.name,
status: uo.organization?.status,
role: uo.role,
})),
organizations: orgs.map((uo) => {
const settings = uo.organization?.settings || {};
return {
id: uo.organizationId,
name: uo.organization?.name,
status: uo.organization?.status,
role: uo.role,
capabilities: resolveCapabilitiesArray(uo.role, settings.permissionOverrides),
};
}),
};
}

View File

@@ -3,6 +3,7 @@ import { Response } from 'express';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { AllowViewer } from '../../common/decorators/allow-viewer.decorator';
import { RequireCapability } from '../../common/decorators/capability.decorator';
import { BoardPlanningService } from './board-planning.service';
import { BoardPlanningProjectionService } from './board-planning-projection.service';
import { BudgetPlanningService } from './budget-planning.service';
@@ -22,27 +23,32 @@ export class BoardPlanningController {
@Get('scenarios')
@AllowViewer()
@RequireCapability('planning.scenarios.view')
listScenarios(@Query('type') type?: string) {
return this.service.listScenarios(type);
}
@Get('scenarios/:id')
@AllowViewer()
@RequireCapability('planning.scenarios.view')
getScenario(@Param('id') id: string) {
return this.service.getScenario(id);
}
@Post('scenarios')
@RequireCapability('planning.scenarios.edit')
createScenario(@Body() dto: any, @Req() req: any) {
return this.service.createScenario(dto, req.user.sub);
}
@Put('scenarios/:id')
@RequireCapability('planning.scenarios.edit')
updateScenario(@Param('id') id: string, @Body() dto: any) {
return this.service.updateScenario(id, dto);
}
@Delete('scenarios/:id')
@RequireCapability('planning.scenarios.edit')
deleteScenario(@Param('id') id: string) {
return this.service.deleteScenario(id);
}
@@ -51,26 +57,31 @@ export class BoardPlanningController {
@Get('scenarios/:scenarioId/investments')
@AllowViewer()
@RequireCapability('planning.scenarios.view')
listInvestments(@Param('scenarioId') scenarioId: string) {
return this.service.listInvestments(scenarioId);
}
@Post('scenarios/:scenarioId/investments')
@RequireCapability('planning.scenarios.edit')
addInvestment(@Param('scenarioId') scenarioId: string, @Body() dto: any) {
return this.service.addInvestment(scenarioId, dto);
}
@Post('scenarios/:scenarioId/investments/from-recommendation')
@RequireCapability('planning.scenarios.edit')
addFromRecommendation(@Param('scenarioId') scenarioId: string, @Body() dto: any) {
return this.service.addInvestmentFromRecommendation(scenarioId, dto);
}
@Put('investments/:id')
@RequireCapability('planning.scenarios.edit')
updateInvestment(@Param('id') id: string, @Body() dto: any) {
return this.service.updateInvestment(id, dto);
}
@Delete('investments/:id')
@RequireCapability('planning.scenarios.edit')
removeInvestment(@Param('id') id: string) {
return this.service.removeInvestment(id);
}
@@ -79,21 +90,25 @@ export class BoardPlanningController {
@Get('scenarios/:scenarioId/assessments')
@AllowViewer()
@RequireCapability('planning.scenarios.view')
listAssessments(@Param('scenarioId') scenarioId: string) {
return this.service.listAssessments(scenarioId);
}
@Post('scenarios/:scenarioId/assessments')
@RequireCapability('planning.scenarios.edit')
addAssessment(@Param('scenarioId') scenarioId: string, @Body() dto: any) {
return this.service.addAssessment(scenarioId, dto);
}
@Put('assessments/:id')
@RequireCapability('planning.scenarios.edit')
updateAssessment(@Param('id') id: string, @Body() dto: any) {
return this.service.updateAssessment(id, dto);
}
@Delete('assessments/:id')
@RequireCapability('planning.scenarios.edit')
removeAssessment(@Param('id') id: string) {
return this.service.removeAssessment(id);
}
@@ -102,11 +117,13 @@ export class BoardPlanningController {
@Get('scenarios/:id/projection')
@AllowViewer()
@RequireCapability('planning.scenarios.view')
getProjection(@Param('id') id: string) {
return this.projection.getProjection(id);
}
@Post('scenarios/:id/projection/refresh')
@RequireCapability('planning.scenarios.edit')
refreshProjection(@Param('id') id: string) {
return this.projection.computeProjection(id);
}
@@ -115,6 +132,7 @@ export class BoardPlanningController {
@Get('compare')
@AllowViewer()
@RequireCapability('planning.scenarios.view')
compareScenarios(@Query('ids') ids: string) {
const scenarioIds = ids.split(',').map((s) => s.trim()).filter(Boolean);
return this.projection.compareScenarios(scenarioIds);
@@ -123,6 +141,7 @@ export class BoardPlanningController {
// ── Execute Investment ──
@Post('investments/:id/execute')
@RequireCapability('planning.scenarios.edit')
executeInvestment(
@Param('id') id: string,
@Body() dto: { executionDate: string },
@@ -135,43 +154,51 @@ export class BoardPlanningController {
@Get('budget-plans')
@AllowViewer()
@RequireCapability('planning.scenarios.view')
listBudgetPlans() {
return this.budgetPlanning.listPlans();
}
@Get('budget-plans/available-years')
@AllowViewer()
@RequireCapability('planning.scenarios.view')
getAvailableYears() {
return this.budgetPlanning.getAvailableYears();
}
@Get('budget-plans/:year')
@AllowViewer()
@RequireCapability('planning.scenarios.view')
getBudgetPlan(@Param('year') year: string) {
return this.budgetPlanning.getPlan(parseInt(year, 10));
}
@Post('budget-plans')
@RequireCapability('planning.scenarios.edit')
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);
}
@Put('budget-plans/:year/lines')
@RequireCapability('planning.scenarios.edit')
updateBudgetPlanLines(@Param('year') year: string, @Body() dto: { planId: string; lines: any[] }) {
return this.budgetPlanning.updateLines(dto.planId, dto.lines);
}
@Put('budget-plans/:year/inflation')
@RequireCapability('planning.scenarios.edit')
updateBudgetPlanInflation(@Param('year') year: string, @Body() dto: { inflationRate: number }) {
return this.budgetPlanning.updateInflation(parseInt(year, 10), dto.inflationRate);
}
@Put('budget-plans/:year/status')
@RequireCapability('planning.scenarios.edit')
advanceBudgetPlanStatus(@Param('year') year: string, @Body() dto: { status: string }, @Req() req: any) {
return this.budgetPlanning.advanceStatus(parseInt(year, 10), dto.status, req.user.sub);
}
@Post('budget-plans/:year/import')
@RequireCapability('planning.scenarios.edit')
importBudgetPlanLines(
@Param('year') year: string,
@Body() lines: any[],
@@ -181,6 +208,7 @@ export class BoardPlanningController {
}
@Get('budget-plans/:year/template')
@RequireCapability('planning.scenarios.view')
async getBudgetPlanTemplate(
@Param('year') year: string,
@Res() res: Response,
@@ -194,6 +222,7 @@ export class BoardPlanningController {
}
@Delete('budget-plans/:year')
@RequireCapability('planning.scenarios.edit')
deleteBudgetPlan(@Param('year') year: string) {
return this.budgetPlanning.deletePlan(parseInt(year, 10));
}

View File

@@ -2,6 +2,7 @@ import { Controller, Get, Put, Post, Body, Param, Query, Res, UseGuards, ParseIn
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { Response } from 'express';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RequireCapability } from '../../common/decorators/capability.decorator';
import { BudgetsService } from './budgets.service';
import { UpsertBudgetDto } from './dto/upsert-budget.dto';
@@ -14,6 +15,7 @@ export class BudgetsController {
@Post(':year/import')
@ApiOperation({ summary: 'Import budget data from parsed CSV/XLSX lines' })
@RequireCapability('financials.budgets.edit')
importBudget(
@Param('year', ParseIntPipe) year: number,
@Body() lines: any[],
@@ -23,6 +25,7 @@ export class BudgetsController {
@Get(':year/template')
@ApiOperation({ summary: 'Download budget CSV template for a fiscal year' })
@RequireCapability('financials.budgets.view')
async getTemplate(
@Param('year', ParseIntPipe) year: number,
@Res() res: Response,
@@ -37,6 +40,7 @@ export class BudgetsController {
@Get(':year/vs-actual')
@ApiOperation({ summary: 'Budget vs actual comparison' })
@RequireCapability('financials.budgets.view')
budgetVsActual(
@Param('year', ParseIntPipe) year: number,
@Query('month') month?: string,
@@ -46,12 +50,14 @@ export class BudgetsController {
@Get(':year')
@ApiOperation({ summary: 'Get budgets for a fiscal year' })
@RequireCapability('financials.budgets.view')
findByYear(@Param('year', ParseIntPipe) year: number) {
return this.budgetsService.findByYear(year);
}
@Put(':year')
@ApiOperation({ summary: 'Upsert budgets for a fiscal year' })
@RequireCapability('financials.budgets.edit')
upsert(
@Param('year', ParseIntPipe) year: number,
@Body() budgets: UpsertBudgetDto[],

View File

@@ -1,6 +1,7 @@
import { Controller, Get, Post, Put, Body, Param, UseGuards } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RequireCapability } from '../../common/decorators/capability.decorator';
import { CapitalProjectsService } from './capital-projects.service';
@ApiTags('capital-projects')
@@ -11,14 +12,18 @@ export class CapitalProjectsController {
constructor(private service: CapitalProjectsService) {}
@Get()
@RequireCapability('planning.projects.view')
findAll() { return this.service.findAll(); }
@Get(':id')
@RequireCapability('planning.projects.view')
findOne(@Param('id') id: string) { return this.service.findOne(id); }
@Post()
@RequireCapability('planning.projects.edit')
create(@Body() dto: any) { return this.service.create(dto); }
@Put(':id')
@RequireCapability('planning.projects.edit')
update(@Param('id') id: string, @Body() dto: any) { return this.service.update(id, dto); }
}

View File

@@ -2,6 +2,7 @@ import { Controller, Get, Post, UseGuards, Req } from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { AllowViewer } from '../../common/decorators/allow-viewer.decorator';
import { RequireCapability } from '../../common/decorators/capability.decorator';
import { InvestmentPlanningService } from './investment-planning.service';
@ApiTags('investment-planning')
@@ -13,24 +14,28 @@ export class InvestmentPlanningController {
@Get('snapshot')
@ApiOperation({ summary: 'Get financial snapshot for investment planning' })
@RequireCapability('planning.investments.view')
getSnapshot() {
return this.service.getFinancialSnapshot();
}
@Get('cd-rates')
@ApiOperation({ summary: 'Get latest CD rates from market data (backward compat)' })
@RequireCapability('planning.investments.view')
getCdRates() {
return this.service.getCdRates();
}
@Get('market-rates')
@ApiOperation({ summary: 'Get all market rates grouped by type (CD, Money Market, High Yield Savings)' })
@RequireCapability('planning.investments.view')
getMarketRates() {
return this.service.getMarketRates();
}
@Get('saved-recommendation')
@ApiOperation({ summary: 'Get the latest saved AI recommendation for this tenant' })
@RequireCapability('planning.investments.view')
getSavedRecommendation() {
return this.service.getSavedRecommendation();
}
@@ -38,6 +43,7 @@ export class InvestmentPlanningController {
@Post('recommendations')
@ApiOperation({ summary: 'Trigger AI-powered investment recommendations (async — returns immediately)' })
@AllowViewer()
@RequireCapability('planning.investments.edit')
triggerRecommendations(@Req() req: any) {
return this.service.triggerAIRecommendations(req.user?.sub, req.user?.orgId);
}

View File

@@ -1,6 +1,7 @@
import { Controller, Get, Post, Put, Body, Param, UseGuards } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RequireCapability } from '../../common/decorators/capability.decorator';
import { InvestmentsService } from './investments.service';
@ApiTags('investments')
@@ -11,14 +12,18 @@ export class InvestmentsController {
constructor(private service: InvestmentsService) {}
@Get()
@RequireCapability('planning.investments.view')
findAll() { return this.service.findAll(); }
@Get(':id')
@RequireCapability('planning.investments.view')
findOne(@Param('id') id: string) { return this.service.findOne(id); }
@Post()
@RequireCapability('planning.investments.edit')
create(@Body() dto: any) { return this.service.create(dto); }
@Put(':id')
@RequireCapability('planning.investments.edit')
update(@Param('id') id: string, @Body() dto: any) { return this.service.update(id, dto); }
}

View File

@@ -1,6 +1,7 @@
import { Controller, Get, Post, Body, Param, UseGuards, Request } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RequireCapability } from '../../common/decorators/capability.decorator';
import { InvoicesService } from './invoices.service';
@ApiTags('invoices')
@@ -11,22 +12,27 @@ export class InvoicesController {
constructor(private invoicesService: InvoicesService) {}
@Get()
@RequireCapability('transactions.view')
findAll() { return this.invoicesService.findAll(); }
@Get(':id')
@RequireCapability('transactions.view')
findOne(@Param('id') id: string) { return this.invoicesService.findOne(id); }
@Post('generate-preview')
@RequireCapability('transactions.edit')
generatePreview(@Body() dto: { month: number; year: number }) {
return this.invoicesService.generatePreview(dto);
}
@Post('generate-bulk')
@RequireCapability('transactions.edit')
generateBulk(@Body() dto: { month: number; year: number }, @Request() req: any) {
return this.invoicesService.generateBulk(dto, req.user.sub);
}
@Post('apply-late-fees')
@RequireCapability('transactions.edit')
applyLateFees(@Body() dto: { grace_period_days: number; late_fee_amount: number }, @Request() req: any) {
return this.invoicesService.applyLateFees(dto, req.user.sub);
}

View File

@@ -3,6 +3,7 @@ import {
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RequireCapability } from '../../common/decorators/capability.decorator';
import { JournalEntriesService } from './journal-entries.service';
import { CreateJournalEntryDto } from './dto/create-journal-entry.dto';
import { VoidJournalEntryDto } from './dto/void-journal-entry.dto';
@@ -16,6 +17,7 @@ export class JournalEntriesController {
@Get()
@ApiOperation({ summary: 'List journal entries' })
@RequireCapability('transactions.view')
findAll(
@Query('from') from?: string,
@Query('to') to?: string,
@@ -27,24 +29,28 @@ export class JournalEntriesController {
@Get(':id')
@ApiOperation({ summary: 'Get journal entry by ID' })
@RequireCapability('transactions.view')
findOne(@Param('id') id: string) {
return this.jeService.findOne(id);
}
@Post()
@ApiOperation({ summary: 'Create a journal entry' })
@RequireCapability('transactions.edit')
create(@Body() dto: CreateJournalEntryDto, @Request() req: any) {
return this.jeService.create(dto, req.user.sub);
}
@Post(':id/post')
@ApiOperation({ summary: 'Post (finalize) a journal entry' })
@RequireCapability('transactions.edit')
post(@Param('id') id: string, @Request() req: any) {
return this.jeService.post(id, req.user.sub);
}
@Post(':id/void')
@ApiOperation({ summary: 'Void a journal entry' })
@RequireCapability('transactions.edit')
void(@Param('id') id: string, @Body() dto: VoidJournalEntryDto, @Request() req: any) {
return this.jeService.void(id, req.user.sub, dto.reason);
}

View File

@@ -1,6 +1,7 @@
import { Controller, Get, Post, Param, Body, UseGuards, Request } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RequireCapability } from '../../common/decorators/capability.decorator';
import { MonthlyActualsService } from './monthly-actuals.service';
@ApiTags('monthly-actuals')
@@ -12,12 +13,14 @@ export class MonthlyActualsController {
@Get(':year/: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) {
return this.monthlyActualsService.getActualsGrid(parseInt(year), parseInt(month));
}
@Post(':year/:month')
@ApiOperation({ summary: 'Save monthly actuals (creates reconciled journal entry)' })
@RequireCapability('financials.actuals.edit')
async save(
@Param('year') year: string,
@Param('month') month: string,

View File

@@ -3,6 +3,8 @@ import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { OrganizationsService } from './organizations.service';
import { CreateOrganizationDto } from './dto/create-organization.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RequireCapability } from '../../common/decorators/capability.decorator';
import { resolveCapabilitiesArray, ALL_CAPABILITIES } from '../../common/permissions';
@ApiTags('organizations')
@Controller('organizations')
@@ -23,54 +25,87 @@ export class OrganizationsController {
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')
@ApiOperation({ summary: 'Update settings for the current organization' })
@RequireCapability('settings.org.edit')
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);
}
// ── 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')
@ApiOperation({ summary: 'List members of current organization' })
@RequireCapability('settings.members.view')
async getMembers(@Request() req: any) {
this.requireTenantAdmin(req);
return this.orgService.getMembers(req.user.orgId);
}
@Post('members')
@ApiOperation({ summary: 'Add a member to the current organization' })
@RequireCapability('settings.members.manage')
async addMember(
@Request() req: any,
@Body() body: { email: string; firstName: string; lastName: string; password: string; role: string },
) {
this.requireTenantAdmin(req);
return this.orgService.addMember(req.user.orgId, body);
}
@Put('members/:id/role')
@ApiOperation({ summary: 'Update a member role' })
@RequireCapability('settings.members.manage')
async updateMemberRole(
@Request() req: any,
@Param('id') id: string,
@Body() body: { role: string },
) {
this.requireTenantAdmin(req);
return this.orgService.updateMemberRole(req.user.orgId, id, body.role);
}
@Delete('members/:id')
@ApiOperation({ summary: 'Remove a member from the organization' })
@RequireCapability('settings.members.manage')
async removeMember(@Request() req: any, @Param('id') id: string) {
this.requireTenantAdmin(req);
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}`);
}
}
}
}
}
}

View File

@@ -1,6 +1,7 @@
import { Controller, Get, Post, Put, Delete, Body, Param, UseGuards, Request } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RequireCapability } from '../../common/decorators/capability.decorator';
import { PaymentsService } from './payments.service';
@ApiTags('payments')
@@ -11,19 +12,24 @@ export class PaymentsController {
constructor(private paymentsService: PaymentsService) {}
@Get()
@RequireCapability('transactions.view')
findAll() { return this.paymentsService.findAll(); }
@Get(':id')
@RequireCapability('transactions.view')
findOne(@Param('id') id: string) { return this.paymentsService.findOne(id); }
@Post()
@RequireCapability('transactions.edit')
create(@Body() dto: any, @Request() req: any) { return this.paymentsService.create(dto, req.user.sub); }
@Put(':id')
@RequireCapability('transactions.edit')
update(@Param('id') id: string, @Body() dto: any, @Request() req: any) {
return this.paymentsService.update(id, dto, req.user.sub);
}
@Delete(':id')
@RequireCapability('transactions.edit')
delete(@Param('id') id: string) { return this.paymentsService.delete(id); }
}

View File

@@ -2,6 +2,7 @@ import { Controller, Get, Post, Put, Body, Param, Res, UseGuards } from '@nestjs
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { Response } from 'express';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RequireCapability } from '../../common/decorators/capability.decorator';
import { ProjectsService } from './projects.service';
@ApiTags('projects')
@@ -12,9 +13,11 @@ export class ProjectsController {
constructor(private service: ProjectsService) {}
@Get()
@RequireCapability('planning.projects.view')
findAll() { return this.service.findAll(); }
@Get('export')
@RequireCapability('planning.projects.view')
async exportCSV(@Res() res: Response) {
const csv = await this.service.exportCSV();
res.set({ 'Content-Type': 'text/csv', 'Content-Disposition': 'attachment; filename="projects.csv"' });
@@ -22,21 +25,27 @@ export class ProjectsController {
}
@Get('planning')
@RequireCapability('planning.projects.view')
findForPlanning() { return this.service.findForPlanning(); }
@Get(':id')
@RequireCapability('planning.projects.view')
findOne(@Param('id') id: string) { return this.service.findOne(id); }
@Post('import')
@RequireCapability('planning.projects.edit')
importCSV(@Body() rows: any[]) { return this.service.importCSV(rows); }
@Post()
@RequireCapability('planning.projects.edit')
create(@Body() dto: any) { return this.service.create(dto); }
@Put(':id')
@RequireCapability('planning.projects.edit')
update(@Param('id') id: string, @Body() dto: any) { return this.service.update(id, dto); }
@Put(':id/planned-date')
@RequireCapability('planning.projects.edit')
updatePlannedDate(@Param('id') id: string, @Body() dto: { planned_date: string }) {
return this.service.updatePlannedDate(id, dto.planned_date);
}

View File

@@ -1,6 +1,7 @@
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RequireCapability } from '../../common/decorators/capability.decorator';
import { ReportsService } from './reports.service';
@ApiTags('reports')
@@ -11,11 +12,13 @@ export class ReportsController {
constructor(private reportsService: ReportsService) {}
@Get('balance-sheet')
@RequireCapability('reports.view')
getBalanceSheet(@Query('as_of') asOf?: string) {
return this.reportsService.getBalanceSheet(asOf || new Date().toISOString().split('T')[0]);
}
@Get('income-statement')
@RequireCapability('reports.view')
getIncomeStatement(@Query('from') from?: string, @Query('to') to?: string) {
const now = new Date();
const defaultFrom = `${now.getFullYear()}-01-01`;
@@ -24,6 +27,7 @@ export class ReportsController {
}
@Get('cash-flow-sankey')
@RequireCapability('reports.view')
getCashFlowSankey(
@Query('year') year?: string,
@Query('source') source?: string,
@@ -37,6 +41,7 @@ export class ReportsController {
}
@Get('cash-flow')
@RequireCapability('reports.view')
getCashFlowStatement(
@Query('from') from?: string,
@Query('to') to?: string,
@@ -51,26 +56,31 @@ export class ReportsController {
}
@Get('aging')
@RequireCapability('reports.view')
getAgingReport() {
return this.reportsService.getAgingReport();
}
@Get('year-end')
@RequireCapability('reports.view')
getYearEndSummary(@Query('year') year?: string) {
return this.reportsService.getYearEndSummary(parseInt(year || '') || new Date().getFullYear());
}
@Get('dashboard')
@RequireCapability('reports.view')
getDashboardKPIs() {
return this.reportsService.getDashboardKPIs();
}
@Get('upcoming-investment-activities')
@RequireCapability('reports.view')
getUpcomingInvestmentActivities() {
return this.reportsService.getUpcomingInvestmentActivities();
}
@Get('cash-flow-forecast')
@RequireCapability('reports.view')
getCashFlowForecast(
@Query('startYear') startYear?: string,
@Query('months') months?: string,
@@ -81,6 +91,7 @@ export class ReportsController {
}
@Get('capital-planning')
@RequireCapability('reports.view')
getCapitalPlanningReport(@Query('startYear') startYear?: string) {
return this.reportsService.getCapitalPlanningReport(
parseInt(startYear || '') || undefined,
@@ -88,6 +99,7 @@ export class ReportsController {
}
@Get('quarterly')
@RequireCapability('reports.view')
getQuarterlyFinancial(
@Query('year') year?: string,
@Query('quarter') quarter?: string,

View File

@@ -1,6 +1,7 @@
import { Controller, Get, Post, Put, Body, Param, UseGuards } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RequireCapability } from '../../common/decorators/capability.decorator';
import { ReserveComponentsService } from './reserve-components.service';
@ApiTags('reserve-components')
@@ -11,14 +12,18 @@ export class ReserveComponentsController {
constructor(private service: ReserveComponentsService) {}
@Get()
@RequireCapability('planning.projects.view')
findAll() { return this.service.findAll(); }
@Get(':id')
@RequireCapability('planning.projects.view')
findOne(@Param('id') id: string) { return this.service.findOne(id); }
@Post()
@RequireCapability('planning.projects.edit')
create(@Body() dto: any) { return this.service.create(dto); }
@Put(':id')
@RequireCapability('planning.projects.edit')
update(@Param('id') id: string, @Body() dto: any) { return this.service.update(id, dto); }
}

View File

@@ -2,6 +2,7 @@ import { Controller, Get, Post, Put, Delete, Body, Param, Res, UseGuards } from
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { Response } from 'express';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RequireCapability } from '../../common/decorators/capability.decorator';
import { UnitsService } from './units.service';
@ApiTags('units')
@@ -12,9 +13,11 @@ export class UnitsController {
constructor(private unitsService: UnitsService) {}
@Get()
@RequireCapability('assessments.units.view')
findAll() { return this.unitsService.findAll(); }
@Get('export')
@RequireCapability('assessments.units.view')
async exportCSV(@Res() res: Response) {
const csv = await this.unitsService.exportCSV();
res.set({ 'Content-Type': 'text/csv', 'Content-Disposition': 'attachment; filename="units.csv"' });
@@ -22,17 +25,22 @@ export class UnitsController {
}
@Get(':id')
@RequireCapability('assessments.units.view')
findOne(@Param('id') id: string) { return this.unitsService.findOne(id); }
@Post('import')
@RequireCapability('assessments.units.edit')
importCSV(@Body() rows: any[]) { return this.unitsService.importCSV(rows); }
@Post()
@RequireCapability('assessments.units.edit')
create(@Body() dto: any) { return this.unitsService.create(dto); }
@Put(':id')
@RequireCapability('assessments.units.edit')
update(@Param('id') id: string, @Body() dto: any) { return this.unitsService.update(id, dto); }
@Delete(':id')
@RequireCapability('assessments.units.edit')
delete(@Param('id') id: string) { return this.unitsService.delete(id); }
}

View File

@@ -2,6 +2,7 @@ import { Controller, Get, Post, Put, Body, Param, Query, Res, UseGuards } from '
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { Response } from 'express';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RequireCapability } from '../../common/decorators/capability.decorator';
import { VendorsService } from './vendors.service';
@ApiTags('vendors')
@@ -12,9 +13,11 @@ export class VendorsController {
constructor(private vendorsService: VendorsService) {}
@Get()
@RequireCapability('reference.vendors.view')
findAll() { return this.vendorsService.findAll(); }
@Get('export')
@RequireCapability('reference.vendors.view')
async exportCSV(@Res() res: Response) {
const csv = await this.vendorsService.exportCSV();
res.set({ 'Content-Type': 'text/csv', 'Content-Disposition': 'attachment; filename="vendors.csv"' });
@@ -22,19 +25,24 @@ export class VendorsController {
}
@Get('1099-data')
@RequireCapability('reference.vendors.view')
get1099Data(@Query('year') year: string) {
return this.vendorsService.get1099Data(parseInt(year) || new Date().getFullYear());
}
@Get(':id')
@RequireCapability('reference.vendors.view')
findOne(@Param('id') id: string) { return this.vendorsService.findOne(id); }
@Post('import')
@RequireCapability('reference.vendors.edit')
importCSV(@Body() rows: any[]) { return this.vendorsService.importCSV(rows); }
@Post()
@RequireCapability('reference.vendors.edit')
create(@Body() dto: any) { return this.vendorsService.create(dto); }
@Put(':id')
@RequireCapability('reference.vendors.edit')
update(@Param('id') id: string, @Body() dto: any) { return this.vendorsService.update(id, dto); }
}

View File

@@ -58,7 +58,7 @@ CREATE TABLE shared.user_organizations (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES shared.users(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,
joined_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(user_id, organization_id)

View 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'));

230
docs/gitea-runner-setup.md Normal file
View File

@@ -0,0 +1,230 @@
# Gitea Actions Runner Setup — HOALedgerIQ Production Server
This guide walks through setting up a self-hosted Gitea Actions runner on the production server so the deployment workflow (`.gitea/workflows/deploy.yml`) can execute automatically.
The runner uses **host execution mode** — jobs run directly on the server (not inside Docker containers) so the deploy script has access to Docker, the git repo, and the local filesystem.
---
## Prerequisites
- Ubuntu Linux production server
- Gitea instance (e.g., `https://git.sensetostyle.com`)
- Docker and Docker Compose installed on the server
- The HOALedgerIQ repo cloned at `/opt/hoa-ledgeriq`
---
## Step 1: Enable Actions in Gitea
Ensure Actions are enabled in your Gitea configuration (`/etc/gitea/app.ini`):
```ini
[actions]
ENABLED = true
```
Restart Gitea after making changes:
```bash
sudo systemctl restart gitea
```
---
## Step 2: Get a Registration Token
1. Log into your Gitea instance
2. Navigate to **Site Administration****Actions****Runners**
3. Copy the **Registration Token**
> **Tip:** For tighter security, you can get a repo-scoped token instead:
> Repo → **Settings** → **Actions** → **Runners** → copy the token shown there.
> This limits the runner to only execute workflows from that specific repository.
---
## Step 3: Install the Act Runner Binary
```bash
# Download the latest act_runner for x86_64 Linux
wget https://dl.gitea.com/act_runner/latest/act_runner-linux-amd64
# Make executable and install to system path
chmod +x act_runner-linux-amd64
sudo mv act_runner-linux-amd64 /usr/local/bin/act_runner
# Verify installation
act_runner --version
```
> For ARM64 servers, use `act_runner-linux-arm64` instead.
---
## Step 4: Generate and Edit the Configuration
```bash
sudo mkdir -p /etc/act_runner
act_runner generate-config > /tmp/config.yaml
```
Edit `/tmp/config.yaml` and set the **labels to use host execution mode**:
```yaml
runner:
labels:
- "ubuntu-latest:host"
- "ubuntu-22.04:host"
```
The `:host` suffix tells the runner to execute jobs directly on the server rather than spinning up Docker containers. This is required because the deploy script needs access to:
- The Docker socket (to run `docker compose`)
- The git repository at `/opt/hoa-ledgeriq`
- The backup scripts and database
Move the config into place and lock down permissions:
```bash
sudo mv /tmp/config.yaml /etc/act_runner/config.yaml
sudo chmod 600 /etc/act_runner/config.yaml
```
---
## Step 5: Register the Runner
```bash
act_runner register \
--no-interactive \
--instance "https://git.sensetostyle.com" \
--token "YOUR_REGISTRATION_TOKEN_HERE" \
--name "hoaledgeriq-prod" \
--labels "ubuntu-latest:host,ubuntu-22.04:host" \
--config /etc/act_runner/config.yaml
```
This creates a `.runner` file in the current directory containing the registration state.
> **Interactive alternative:** Run `act_runner register --config /etc/act_runner/config.yaml` and follow the prompts.
---
## Step 6: Set Up as a Systemd Service
Create the service file at `/etc/systemd/system/act_runner.service`:
```ini
[Unit]
Description=Gitea Actions Runner (HOALedgerIQ Prod)
Documentation=https://docs.gitea.com/usage/actions/act-runner
After=docker.service network-online.target
[Service]
Type=simple
User=root
WorkingDirectory=/opt/hoa-ledgeriq
ExecStart=/usr/local/bin/act_runner daemon --config /etc/act_runner/config.yaml
Restart=always
RestartSec=10
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
```
> **Security note on `User=root`:** The deploy script needs to run `docker compose`, `git reset --hard`, etc. If you have a dedicated deploy user in the `docker` group with write access to `/opt/hoa-ledgeriq`, use that instead. Running as root is the simplest option but grants maximum privileges.
Enable and start the service:
```bash
sudo systemctl daemon-reload
sudo systemctl enable act_runner
sudo systemctl start act_runner
```
---
## Step 7: Verify the Runner Is Online
Check the service is running:
```bash
sudo systemctl status act_runner
```
View logs:
```bash
sudo journalctl -u act_runner -f
```
Then confirm in Gitea:
1. Go to **Site Administration****Actions****Runners**
2. You should see **"hoaledgeriq-prod"** listed with status **Online**
---
## Step 8: Test the Workflow
1. Go to your repo on Gitea → **Actions** tab
2. Select the **"Deploy to Production"** workflow
3. Click **Run Workflow**
4. If this is the first deployment against an existing database, check the **"Mark existing migrations as applied"** box
5. Monitor the run in the Actions tab
---
## Troubleshooting
### Runner shows as Offline
```bash
# Check service status and logs
sudo systemctl status act_runner
sudo journalctl -u act_runner -n 50
# Verify the instance URL is reachable from the server
wget -qO- https://git.sensetostyle.com/api/v1/version
```
### Workflow stuck on "Waiting for runner"
- Verify the runner labels match what the workflow expects. The workflow uses `runs-on: ubuntu-latest` which must match the `ubuntu-latest:host` label.
- Check the runner is registered at the correct scope (instance-wide, org-level, or repo-level).
### Permission denied errors during deploy
- Ensure the systemd service `User` has Docker access (`usermod -aG docker <user>`)
- Ensure the user has write access to `/opt/hoa-ledgeriq`
### Re-registering after token expiry
```bash
sudo systemctl stop act_runner
# Get a new token from Gitea admin panel, then:
act_runner register \
--no-interactive \
--instance "https://git.sensetostyle.com" \
--token "NEW_TOKEN_HERE" \
--name "hoaledgeriq-prod" \
--labels "ubuntu-latest:host,ubuntu-22.04:host" \
--config /etc/act_runner/config.yaml
sudo systemctl start act_runner
```
---
## Security Best Practices
| Concern | Recommendation |
|---------|----------------|
| Runner user | Use a dedicated user with `docker` group access rather than `root` when possible |
| Registration token | Rotate periodically in the Gitea admin panel |
| Config file | Keep `/etc/act_runner/config.yaml` at mode `600` (owner-read only) |
| Runner scope | Register at the **repo level** instead of instance-wide so only this repo can trigger deployments |
| Workflow triggers | The deploy workflow uses `workflow_dispatch` (manual only) — no automatic triggers on push |
| Network | Ensure Gitea is accessed over HTTPS with valid SSL certificates |

View File

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

View File

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

View File

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

View File

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

View File

@@ -194,7 +194,7 @@ function ModelSlotCard({ slot, model, isLoading }: { slot: string; model?: Shado
</Group>
<Divider />
<TextInput label="Display Name" placeholder="e.g. GPT-4o" value={name} onChange={(e) => setName(e.target.value)} size="sm" />
<TextInput label="API URL" placeholder="https://api.openai.com/v1" value={apiUrl} onChange={(e) => setApiUrl(e.target.value)} size="sm" />
<TextInput label="API URL" description="Base URL only — /chat/completions is added automatically" placeholder="https://openrouter.ai/api/v1" value={apiUrl} onChange={(e) => setApiUrl(e.target.value)} size="sm" />
<PasswordInput label="API Key" placeholder="sk-..." value={apiKey} onChange={(e) => setApiKey(e.target.value)} size="sm" />
<TextInput label="Model Name" placeholder="gpt-4o" value={modelName} onChange={(e) => setModelName(e.target.value)} size="sm" />
<Switch label="Active" checked={isActive} onChange={(e) => setIsActive(e.currentTarget.checked)} />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -237,7 +237,7 @@ export function SettingsPage() {
</Group>
<Group justify="space-between">
<Text size="sm" c="dimmed">Version</Text>
<Badge variant="light">2026.4.2</Badge>
<Badge variant="light">2026.4.6</Badge>
</Group>
<Group justify="space-between">
<Text size="sm" c="dimmed">API</Text>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -21,6 +21,7 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
BACKUP_DIR="$PROJECT_DIR/backups"
KEEP_DAYS=0 # 0 = keep forever
FORCE_YES=false # skip interactive confirmations (for automation)
DB_USER="${POSTGRES_USER:-hoafinance}"
DB_NAME="${POSTGRES_DB:-hoafinance}"
COMPOSE_CMD="docker compose"
@@ -51,9 +52,9 @@ ensure_postgres_running() {
format_size() {
local bytes=$1
if (( bytes >= 1073741824 )); then printf "%.1f GB" "$(echo "$bytes / 1073741824" | bc -l)"
elif (( bytes >= 1048576 )); then printf "%.1f MB" "$(echo "$bytes / 1048576" | bc -l)"
elif (( bytes >= 1024 )); then printf "%.1f KB" "$(echo "$bytes / 1024" | bc -l)"
if (( bytes >= 1073741824 )); then printf "%d.%d GB" $((bytes / 1073741824)) $(( (bytes % 1073741824) * 10 / 1073741824 ))
elif (( bytes >= 1048576 )); then printf "%d.%d MB" $((bytes / 1048576)) $(( (bytes % 1048576) * 10 / 1048576 ))
elif (( bytes >= 1024 )); then printf "%d.%d KB" $((bytes / 1024)) $(( (bytes % 1024) * 10 / 1024 ))
else printf "%d B" "$bytes"
fi
}
@@ -121,8 +122,12 @@ do_restore() {
warn "This will DESTROY the current '${DB_NAME}' database and replace it"
warn "with the contents of: $(basename "$file")"
echo ""
read -rp "Type 'yes' to continue: " confirm
[ "$confirm" = "yes" ] || { info "Aborted."; exit 0; }
if [ "$FORCE_YES" = true ]; then
info "Skipping confirmation (--yes flag set)"
else
read -rp "Type 'yes' to continue: " confirm
[ "$confirm" = "yes" ] || { info "Aborted."; exit 0; }
fi
echo ""
info "Step 1/4 — Terminating active connections ..."
@@ -229,6 +234,7 @@ Usage:
Options:
--dir DIR Backup directory (default: ./backups)
--keep DAYS Auto-delete backups older than DAYS (default: keep all)
--yes, -y Skip interactive confirmation prompts (for automation)
Supported restore formats:
.dump.gz Custom-format pg_dump, gzipped (default backup format)
@@ -255,6 +261,7 @@ while [ $# -gt 0 ]; do
case "$1" in
--dir) BACKUP_DIR="$2"; shift 2 ;;
--keep) KEEP_DAYS="$2"; shift 2 ;;
--yes|-y) FORCE_YES=true; shift ;;
--help) usage ;;
*)
if [ "$COMMAND" = "restore" ] && [ -z "$RESTORE_FILE" ]; then

425
scripts/deploy-prod.sh Executable file
View File

@@ -0,0 +1,425 @@
#!/usr/bin/env bash
# ---------------------------------------------------------------------------
# deploy-prod.sh — Production deployment script for HOA LedgerIQ
#
# Usage:
# ./scripts/deploy-prod.sh [--seed-existing]
#
# This script performs a full production deployment:
# 1. Takes a pre-upgrade database backup
# 2. Pulls the latest code from the main branch
# 3. Rebuilds and restarts Docker containers
# 4. Runs any pending database migrations (tracked in shared.schema_migrations)
# 5. Verifies the application is healthy
# 6. Takes a post-upgrade database backup
#
# On failure (migration error or health check), the script automatically:
# - Restores the pre-upgrade database backup
# - Reverts the code to the previous commit
# - Rebuilds containers from the reverted code
#
# Flags:
# --seed-existing Mark all existing migration files as applied without
# executing them. Use this ONLY on the first deployment
# against an existing database where migrations were
# previously applied manually.
#
# Environment:
# PROJECT_DIR Override the project directory (default: /opt/hoa-ledgeriq)
# POSTGRES_USER Database user (default: hoafinance)
# POSTGRES_DB Database name (default: hoafinance)
# ---------------------------------------------------------------------------
set -euo pipefail
# ---- Defaults ----
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="${PROJECT_DIR:-/opt/hoa-ledgeriq}"
COMPOSE_CMD="docker compose -f $PROJECT_DIR/docker-compose.yml -f $PROJECT_DIR/docker-compose.prod.yml"
DB_USER="${POSTGRES_USER:-hoafinance}"
DB_NAME="${POSTGRES_DB:-hoafinance}"
MIGRATION_DIR="$PROJECT_DIR/db/migrations"
HEALTH_URL="http://localhost:3000/api"
HEALTH_RETRIES=20
HEALTH_INTERVAL=5
HEALTH_START_WAIT=30
LOG_DIR="$PROJECT_DIR/logs"
LOG_FILE="$LOG_DIR/deploy-$(date +%Y%m%d_%H%M%S).log"
# State tracking
SEED_EXISTING=false
PREV_COMMIT=""
BACKUP_FILE=""
ROLLBACK_NEEDED=false
DEPLOY_SUCCESS=false
DEPLOY_START_TIME=""
# ---- Colors ----
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m'
# ---- Logging ----
log() { echo -e "$(date -Iseconds) ${CYAN}[DEPLOY]${NC} $*"; }
ok() { echo -e "$(date -Iseconds) ${GREEN}[OK]${NC} $*"; }
warn() { echo -e "$(date -Iseconds) ${YELLOW}[WARN]${NC} $*"; }
err() { echo -e "$(date -Iseconds) ${RED}[ERROR]${NC} $*" >&2; }
die() { err "$@"; exit 1; }
# ---- Parse flags ----
while [ $# -gt 0 ]; do
case "$1" in
--seed-existing) SEED_EXISTING=true; shift ;;
--help|-h)
head -35 "$0" | tail -33
exit 0
;;
*) die "Unknown argument: $1" ;;
esac
done
# ---- Setup logging ----
mkdir -p "$LOG_DIR"
exec > >(tee -a "$LOG_FILE") 2>&1
# ---- Cleanup / Rollback trap ----
cleanup() {
if [ "$DEPLOY_SUCCESS" = true ]; then
return 0
fi
if [ "$ROLLBACK_NEEDED" = true ] && [ -n "$BACKUP_FILE" ]; then
echo ""
err "=========================================="
err " DEPLOYMENT FAILED — STARTING ROLLBACK"
err "=========================================="
echo ""
# Step 1: Restore the pre-upgrade database backup
log "Restoring database from pre-upgrade backup: $(basename "$BACKUP_FILE")"
if "$SCRIPT_DIR/db-backup.sh" restore --yes "$BACKUP_FILE"; then
ok "Database restored successfully"
else
err "DATABASE RESTORE FAILED — manual intervention required!"
err "Backup file: $BACKUP_FILE"
exit 1
fi
# Step 2: Revert code to previous commit
if [ -n "$PREV_COMMIT" ]; then
log "Reverting code to previous commit: $PREV_COMMIT"
cd "$PROJECT_DIR"
git reset --hard "$PREV_COMMIT"
ok "Code reverted to $PREV_COMMIT"
fi
# Step 3: Rebuild containers from old code
log "Rebuilding containers from reverted code ..."
cd "$PROJECT_DIR"
$COMPOSE_CMD up -d --build
ok "Containers rebuilt from previous version"
echo ""
err "Rollback complete. The system is restored to the pre-deployment state."
err "Review the deploy log for details: $LOG_FILE"
exit 1
elif [ "$ROLLBACK_NEEDED" = true ]; then
err "Rollback needed but no backup file available — manual intervention required!"
exit 1
fi
}
trap cleanup EXIT
# ====================================================================
# STEP 1: Pre-flight checks
# ====================================================================
log "============================================"
log " HOA LedgerIQ — Production Deployment"
log "============================================"
log "Project directory: $PROJECT_DIR"
log "Timestamp: $(date -Iseconds)"
DEPLOY_START_TIME=$(date +%s)
echo ""
cd "$PROJECT_DIR"
# Verify prerequisites
command -v git >/dev/null 2>&1 || die "git is not installed"
command -v docker >/dev/null 2>&1 || die "docker is not installed"
docker compose version >/dev/null 2>&1 || die "docker compose is not available"
# Verify we're in a git repo
[ -d ".git" ] || die "$PROJECT_DIR is not a git repository"
# Verify postgres is running
if ! $COMPOSE_CMD ps postgres 2>/dev/null | grep -q "running\|Up"; then
die "PostgreSQL container is not running. Start it with: $COMPOSE_CMD up -d postgres"
fi
# Store current commit for rollback
PREV_COMMIT=$(git rev-parse HEAD)
log "Current commit: $PREV_COMMIT"
# ====================================================================
# STEP 2: Pre-upgrade database backup
# ====================================================================
echo ""
log "--- Step 1/6: Pre-upgrade database backup ---"
BACKUP_OUTPUT=$("$SCRIPT_DIR/db-backup.sh" backup 2>&1)
echo "$BACKUP_OUTPUT"
# Extract the backup file path from the output (strip ANSI color codes first)
BACKUP_FILE=$(echo "$BACKUP_OUTPUT" | sed 's/\x1b\[[0-9;]*m//g' | grep -oP 'Backup complete: \K\S+' || true)
if [ -z "$BACKUP_FILE" ]; then
die "Failed to capture backup file path from db-backup.sh output"
fi
if [ ! -f "$BACKUP_FILE" ]; then
die "Backup file does not exist: $BACKUP_FILE"
fi
ok "Pre-upgrade backup saved: $(basename "$BACKUP_FILE")"
# From this point forward, rollback is possible
ROLLBACK_NEEDED=true
# ====================================================================
# STEP 3: Pull latest code
# ====================================================================
echo ""
log "--- Step 2/6: Pulling latest code from main ---"
git fetch origin main
git reset --hard origin/main
NEW_COMMIT=$(git rev-parse HEAD)
log "Updated to commit: $NEW_COMMIT"
if [ "$PREV_COMMIT" = "$NEW_COMMIT" ]; then
warn "No new commits — continuing anyway (migrations or rebuilds may still be needed)"
fi
# ====================================================================
# STEP 4: Rebuild and restart containers
# ====================================================================
echo ""
log "--- Step 3/6: Rebuilding and restarting containers ---"
$COMPOSE_CMD up -d --build
# Wait for postgres to be healthy before running migrations
log "Waiting for PostgreSQL to be healthy ..."
PG_RETRIES=30
PG_COUNT=0
while [ $PG_COUNT -lt $PG_RETRIES ]; do
if $COMPOSE_CMD exec -T postgres pg_isready -U "$DB_USER" -d "$DB_NAME" >/dev/null 2>&1; then
ok "PostgreSQL is ready"
break
fi
((PG_COUNT++))
if [ $PG_COUNT -eq $PG_RETRIES ]; then
die "PostgreSQL did not become healthy after $((PG_RETRIES * 2))s"
fi
sleep 2
done
# ====================================================================
# STEP 5: Run database migrations
# ====================================================================
echo ""
log "--- Step 4/6: Running database migrations ---"
# Helper: run SQL via psql in the postgres container
run_sql() {
$COMPOSE_CMD exec -T postgres psql -U "$DB_USER" -d "$DB_NAME" -v ON_ERROR_STOP=1 --quiet "$@"
}
# Step 5a: Ensure the migration tracking table exists
log "Ensuring shared.schema_migrations table exists ..."
run_sql <<'SQL'
CREATE SCHEMA IF NOT EXISTS shared;
CREATE TABLE IF NOT EXISTS shared.schema_migrations (
id SERIAL PRIMARY KEY,
filename TEXT NOT NULL UNIQUE,
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
checksum TEXT
);
SQL
ok "Migration tracking table ready"
# Helper: check if a migration has been applied (safe with set -u)
is_applied() {
local key="$1"
# Use a subshell test to avoid unbound variable with set -u on empty associative arrays
[[ -n "${APPLIED_MIGRATIONS[$key]:-}" ]]
}
# Step 5b: Get list of already-applied migrations
declare -A APPLIED_MIGRATIONS=()
while IFS= read -r fname; do
fname=$(echo "$fname" | xargs) # trim whitespace
[ -n "$fname" ] && APPLIED_MIGRATIONS["$fname"]=1
done < <(run_sql -t -c "SELECT filename FROM shared.schema_migrations ORDER BY filename;" 2>/dev/null || true)
APPLIED_COUNT=${#APPLIED_MIGRATIONS[@]}
log "Previously applied migrations: $APPLIED_COUNT"
# Step 5c: Scan migration directory for .sql files
MIGRATION_FILES=()
if [ -d "$MIGRATION_DIR" ]; then
while IFS= read -r f; do
MIGRATION_FILES+=("$(basename "$f")")
done < <(find "$MIGRATION_DIR" -name "*.sql" -type f | sort)
fi
TOTAL_MIGRATIONS=${#MIGRATION_FILES[@]}
log "Total migration files found: $TOTAL_MIGRATIONS"
# Step 5d: Handle --seed-existing (first deployment only)
if [ "$SEED_EXISTING" = true ]; then
if [ "$APPLIED_COUNT" -gt 0 ]; then
warn "--seed-existing flag set but $APPLIED_COUNT migrations are already tracked. Skipping seed."
else
log "Seeding migration tracking table with ${TOTAL_MIGRATIONS} existing migration files ..."
for filename in "${MIGRATION_FILES[@]}"; do
checksum=$(md5sum "$MIGRATION_DIR/$filename" | awk '{print $1}')
run_sql -c "INSERT INTO shared.schema_migrations (filename, checksum) VALUES ('$filename', '$checksum') ON CONFLICT (filename) DO NOTHING;"
log " Seeded: $filename"
done
ok "All existing migrations marked as applied (not executed)"
# Refresh the applied list
APPLIED_COUNT=$TOTAL_MIGRATIONS
for filename in "${MIGRATION_FILES[@]}"; do
APPLIED_MIGRATIONS["$filename"]=1
done
fi
fi
# Step 5e: Detect first-run without --seed-existing
if [ "$APPLIED_COUNT" -eq 0 ] && [ "$TOTAL_MIGRATIONS" -gt 0 ] && [ "$SEED_EXISTING" = false ]; then
warn "The migration tracking table is empty but $TOTAL_MIGRATIONS migration files exist."
warn "If these migrations were previously applied manually, re-run with --seed-existing"
warn "to register them without re-executing. Otherwise, all migrations will be applied."
warn ""
warn "Continuing in 10 seconds ... (Ctrl+C to abort)"
sleep 10
fi
# Step 5f: Apply pending migrations
PENDING_COUNT=0
APPLIED_THIS_RUN=0
for filename in "${MIGRATION_FILES[@]}"; do
if is_applied "$filename"; then
continue
fi
((PENDING_COUNT++))
done
if [ "$PENDING_COUNT" -eq 0 ]; then
ok "No pending migrations to apply"
else
log "$PENDING_COUNT pending migration(s) to apply"
echo ""
for filename in "${MIGRATION_FILES[@]}"; do
if is_applied "$filename"; then
continue
fi
checksum=$(md5sum "$MIGRATION_DIR/$filename" | awk '{print $1}')
log " Applying: $filename ..."
# Run the migration in a single transaction with error stopping
if cat "$MIGRATION_DIR/$filename" | $COMPOSE_CMD exec -T postgres psql \
-U "$DB_USER" \
-d "$DB_NAME" \
-v ON_ERROR_STOP=1 \
--single-transaction \
--quiet 2>&1; then
# Record successful migration
run_sql -c "INSERT INTO shared.schema_migrations (filename, checksum) VALUES ('$filename', '$checksum');"
ok " Applied: $filename"
((APPLIED_THIS_RUN++))
else
err "Migration FAILED: $filename"
err "Triggering automatic rollback ..."
exit 1 # trap will handle rollback
fi
done
echo ""
ok "Successfully applied $APPLIED_THIS_RUN migration(s)"
fi
# ====================================================================
# STEP 6: Health check
# ====================================================================
echo ""
log "--- Step 5/6: Verifying application health ---"
log "Waiting ${HEALTH_START_WAIT}s for backend to initialize (matches Docker start_period) ..."
sleep "$HEALTH_START_WAIT"
# Primary check: Docker's own container health status
# (docker-compose.prod.yml already defines a healthcheck using wget inside the container)
HEALTHY=false
for ((i=1; i<=HEALTH_RETRIES; i++)); do
CONTAINER_HEALTH=$($COMPOSE_CMD ps backend --format '{{.Health}}' 2>/dev/null || echo "unknown")
if [ "$CONTAINER_HEALTH" = "healthy" ]; then
HEALTHY=true
break
fi
# Also try a direct HTTP check from the host as a secondary signal
# Use wget (available on Ubuntu) since curl may not be installed
if wget -qO- --timeout=5 "$HEALTH_URL" >/dev/null 2>&1; then
HEALTHY=true
break
fi
log " Health check attempt $i/$HEALTH_RETRIES — container status: ${CONTAINER_HEALTH}, retrying in ${HEALTH_INTERVAL}s ..."
sleep "$HEALTH_INTERVAL"
done
if [ "$HEALTHY" = true ]; then
ok "Backend is healthy and responding"
else
# Log diagnostics before triggering rollback
err "Backend failed to respond after $((HEALTH_START_WAIT + HEALTH_RETRIES * HEALTH_INTERVAL))s"
warn "Container status: $($COMPOSE_CMD ps backend 2>/dev/null || echo 'unknown')"
warn "Recent backend logs:"
$COMPOSE_CMD logs --tail=20 backend 2>/dev/null || true
err "Triggering automatic rollback ..."
exit 1 # trap will handle rollback
fi
# ====================================================================
# STEP 7: Post-upgrade database backup
# ====================================================================
echo ""
log "--- Step 6/6: Post-upgrade database backup ---"
"$SCRIPT_DIR/db-backup.sh" backup
# ====================================================================
# Deployment complete
# ====================================================================
DEPLOY_SUCCESS=true
ROLLBACK_NEEDED=false
DEPLOY_END_TIME=$(date +%s)
DEPLOY_DURATION=$((DEPLOY_END_TIME - DEPLOY_START_TIME))
echo ""
log "============================================"
ok " DEPLOYMENT COMPLETE"
log "============================================"
log " Previous commit : $PREV_COMMIT"
log " Current commit : $NEW_COMMIT"
log " Migrations run : $APPLIED_THIS_RUN"
log " Duration : ${DEPLOY_DURATION}s"
log " Log file : $LOG_FILE"
log "============================================"
echo ""