10 Commits

Author SHA1 Message Date
fb20c917e1 feat(security): address findings from v2 security assessment
- L2: Add server_tokens off to nginx configs to hide version
- M1: Add X-Frame-Options, X-Content-Type-Options, Referrer-Policy,
  Permissions-Policy headers to all nginx routes
- L3: Add global NoCacheInterceptor (Cache-Control: no-store) on all
  API responses to prevent caching of sensitive financial data
- C1: Disable open registration by default (ALLOW_OPEN_REGISTRATION env)
- H3: Add logout endpoint with correct HTTP 200 status code
- M2: Implement full password reset flow (forgot-password, reset-password,
  change-password) with hashed tokens, 15-min expiry, single-use
- Reduce JWT access token expiry from 24h to 1h
- Add EmailService stub (logs to shared.email_log)
- Add DB migration 016 for password_reset_tokens table

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 07:38:48 -04:00
9cd20a1867 Merge branch 'ai-improvements' 2026-03-16 16:34:11 -04:00
420227d70c Merge branch 'feature/invoice-billing-frequency'
# Conflicts:
#	frontend/src/pages/invoices/InvoicesPage.tsx
2026-03-16 16:34:04 -04:00
e893319cfe Merge branch 'fix/viewer-readonly-audit'
# Conflicts:
#	frontend/src/pages/investment-planning/InvestmentPlanningPage.tsx
2026-03-16 16:33:24 -04:00
93eeacfe8f Merge branch 'claude/reverent-moore' into feature/board-planning 2026-03-16 16:28:52 -04:00
267d92933e chore: reorganize sidebar navigation and bump version to 2026.03.16
Remove the Planning section. Move Projects and Capital Planning (as
sub-item) into Board Planning. Move Investment Planning with Investment
Scenarios as sub-item into Board Planning. Move Vendors into new Board
Reference section. Board Planning order: Budget Planning, Projects >
Capital Planning, Assessment Scenarios, Investment Planning > Investment
Scenarios, Compare Scenarios. Sidebar now supports parent items with
their own route plus nested children.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 16:21:58 -04:00
9d137a40d3 fix: enforce read-only restrictions for viewer role across 5 pages
Audit and fix viewer (read-only) user permissions:
- Dashboard: hide health score refresh buttons
- Accounts: hide investment edit icons
- Invoices: hide Apply Late Fees and Generate Invoices buttons
- Capital Planning: disable drag-and-drop, hide grip handles and edit buttons
- Investment Planning: hide AI Recommendations refresh button

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 09:59:20 -04:00
2b83defbc3 fix: resolve 5 invoice/payment issues from user feedback
- Replace misleading 'sent' status with 'pending' (no email capability)
- Show assessment group name instead of raw 'regular_assessment' type
- Add owner last name to invoice table
- Fix payment creation Internal Server Error (PostgreSQL $2 type cast)
- Add edit/delete capability for payment records with invoice recalc

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 11:53:54 -05:00
a59dac7fe1 Merge remote-tracking branch 'origin/feature/invoice-billing-frequency' into ai-improvements 2026-03-06 19:18:11 -05:00
1e31595d7f feat: add flexible billing frequency support for invoices
Assessment groups can now define billing frequency (monthly, quarterly,
annual) with configurable due months and due day. Invoice generation
respects each group's schedule - only generating invoices when the
selected month is a billing month for that group. Adds a generation
preview showing which groups will be billed, period tracking on
invoices, and billing period context in the payments UI.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 19:08:56 -05:00
15 changed files with 374 additions and 46 deletions

View File

@@ -1,5 +1,5 @@
import { Module, MiddlewareConsumer, NestModule } from '@nestjs/common'; import { Module, MiddlewareConsumer, NestModule } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core'; import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
import { ConfigModule, ConfigService } from '@nestjs/config'; import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { ThrottlerModule } from '@nestjs/throttler'; import { ThrottlerModule } from '@nestjs/throttler';
@@ -7,6 +7,7 @@ import { AppController } from './app.controller';
import { DatabaseModule } from './database/database.module'; import { DatabaseModule } from './database/database.module';
import { TenantMiddleware } from './database/tenant.middleware'; import { TenantMiddleware } from './database/tenant.middleware';
import { WriteAccessGuard } from './common/guards/write-access.guard'; import { WriteAccessGuard } from './common/guards/write-access.guard';
import { NoCacheInterceptor } from './common/interceptors/no-cache.interceptor';
import { AuthModule } from './modules/auth/auth.module'; import { AuthModule } from './modules/auth/auth.module';
import { OrganizationsModule } from './modules/organizations/organizations.module'; import { OrganizationsModule } from './modules/organizations/organizations.module';
import { UsersModule } from './modules/users/users.module'; import { UsersModule } from './modules/users/users.module';
@@ -29,6 +30,7 @@ import { AttachmentsModule } from './modules/attachments/attachments.module';
import { InvestmentPlanningModule } from './modules/investment-planning/investment-planning.module'; import { InvestmentPlanningModule } from './modules/investment-planning/investment-planning.module';
import { HealthScoresModule } from './modules/health-scores/health-scores.module'; import { HealthScoresModule } from './modules/health-scores/health-scores.module';
import { BoardPlanningModule } from './modules/board-planning/board-planning.module'; import { BoardPlanningModule } from './modules/board-planning/board-planning.module';
import { EmailModule } from './modules/email/email.module';
import { ScheduleModule } from '@nestjs/schedule'; import { ScheduleModule } from '@nestjs/schedule';
@Module({ @Module({
@@ -81,6 +83,7 @@ import { ScheduleModule } from '@nestjs/schedule';
InvestmentPlanningModule, InvestmentPlanningModule,
HealthScoresModule, HealthScoresModule,
BoardPlanningModule, BoardPlanningModule,
EmailModule,
ScheduleModule.forRoot(), ScheduleModule.forRoot(),
], ],
controllers: [AppController], controllers: [AppController],
@@ -89,6 +92,10 @@ import { ScheduleModule } from '@nestjs/schedule';
provide: APP_GUARD, provide: APP_GUARD,
useClass: WriteAccessGuard, useClass: WriteAccessGuard,
}, },
{
provide: APP_INTERCEPTOR,
useClass: NoCacheInterceptor,
},
], ],
}) })
export class AppModule implements NestModule { export class AppModule implements NestModule {

View File

@@ -0,0 +1,16 @@
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable } from 'rxjs';
/**
* Prevents browsers and proxies from caching authenticated API responses
* containing sensitive financial data (account balances, transactions, PII).
*/
@Injectable()
export class NoCacheInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const res = context.switchToHttp().getResponse();
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, private');
res.setHeader('Pragma', 'no-cache');
return next.handle();
}
}

View File

@@ -6,6 +6,9 @@ import {
UseGuards, UseGuards,
Request, Request,
Get, Get,
HttpCode,
ForbiddenException,
BadRequestException,
} from '@nestjs/common'; } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
@@ -17,15 +20,22 @@ import { SwitchOrgDto } from './dto/switch-org.dto';
import { JwtAuthGuard } from './guards/jwt-auth.guard'; import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { AllowViewer } from '../../common/decorators/allow-viewer.decorator'; import { AllowViewer } from '../../common/decorators/allow-viewer.decorator';
const isOpenRegistration = process.env.ALLOW_OPEN_REGISTRATION === 'true';
@ApiTags('auth') @ApiTags('auth')
@Controller('auth') @Controller('auth')
export class AuthController { export class AuthController {
constructor(private authService: AuthService) {} constructor(private authService: AuthService) {}
@Post('register') @Post('register')
@ApiOperation({ summary: 'Register a new user' }) @ApiOperation({ summary: 'Register a new user (disabled unless ALLOW_OPEN_REGISTRATION=true)' })
@Throttle({ default: { limit: 5, ttl: 60000 } }) @Throttle({ default: { limit: 5, ttl: 60000 } })
async register(@Body() dto: RegisterDto) { async register(@Body() dto: RegisterDto) {
if (!isOpenRegistration) {
throw new ForbiddenException(
'Open registration is disabled. Please use an invitation link to create your account.',
);
}
return this.authService.register(dto); return this.authService.register(dto);
} }
@@ -39,6 +49,16 @@ export class AuthController {
return this.authService.login(req.user, ip, ua); return this.authService.login(req.user, ip, ua);
} }
@Post('logout')
@ApiOperation({ summary: 'Logout (invalidate current session)' })
@HttpCode(200)
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
async logout(@Request() req: any) {
await this.authService.logout(req.user.sub);
return { success: true };
}
@Get('profile') @Get('profile')
@ApiOperation({ summary: 'Get current user profile' }) @ApiOperation({ summary: 'Get current user profile' })
@ApiBearerAuth() @ApiBearerAuth()
@@ -67,4 +87,51 @@ export class AuthController {
const ua = req.headers['user-agent']; const ua = req.headers['user-agent'];
return this.authService.switchOrganization(req.user.sub, dto.organizationId, ip, ua); return this.authService.switchOrganization(req.user.sub, dto.organizationId, ip, ua);
} }
// ─── Password Reset Flow ──────────────────────────────────────────
@Post('forgot-password')
@ApiOperation({ summary: 'Request a password reset email' })
@HttpCode(200)
@Throttle({ default: { limit: 3, ttl: 60000 } })
async forgotPassword(@Body() body: { email: string }) {
if (!body.email) throw new BadRequestException('Email is required');
await this.authService.requestPasswordReset(body.email);
// Always return same message to prevent account enumeration
return { message: 'If that email exists, a password reset link has been sent.' };
}
@Post('reset-password')
@ApiOperation({ summary: 'Reset password using a reset token' })
@HttpCode(200)
@Throttle({ default: { limit: 5, ttl: 60000 } })
async resetPassword(@Body() body: { token: string; newPassword: string }) {
if (!body.token || !body.newPassword) {
throw new BadRequestException('Token and newPassword are required');
}
if (body.newPassword.length < 8) {
throw new BadRequestException('Password must be at least 8 characters');
}
await this.authService.resetPassword(body.token, body.newPassword);
return { message: 'Password updated successfully.' };
}
@Patch('change-password')
@ApiOperation({ summary: 'Change password (authenticated)' })
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@AllowViewer()
async changePassword(
@Request() req: any,
@Body() body: { currentPassword: string; newPassword: string },
) {
if (!body.currentPassword || !body.newPassword) {
throw new BadRequestException('currentPassword and newPassword are required');
}
if (body.newPassword.length < 8) {
throw new BadRequestException('Password must be at least 8 characters');
}
await this.authService.changePassword(req.user.sub, body.currentPassword, body.newPassword);
return { message: 'Password changed successfully.' };
}
} }

View File

@@ -21,7 +21,7 @@ import { OrganizationsModule } from '../organizations/organizations.module';
inject: [ConfigService], inject: [ConfigService],
useFactory: (configService: ConfigService) => ({ useFactory: (configService: ConfigService) => ({
secret: configService.get<string>('JWT_SECRET'), secret: configService.get<string>('JWT_SECRET'),
signOptions: { expiresIn: '24h' }, signOptions: { expiresIn: '1h' },
}), }),
}), }),
], ],

View File

@@ -4,21 +4,33 @@ import {
ConflictException, ConflictException,
ForbiddenException, ForbiddenException,
NotFoundException, NotFoundException,
BadRequestException,
Logger,
} from '@nestjs/common'; } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import * as bcrypt from 'bcryptjs'; import * as bcrypt from 'bcryptjs';
import { randomBytes, createHash } from 'crypto';
import { UsersService } from '../users/users.service'; import { UsersService } from '../users/users.service';
import { EmailService } from '../email/email.service';
import { RegisterDto } from './dto/register.dto'; import { RegisterDto } from './dto/register.dto';
import { User } from '../users/entities/user.entity'; import { User } from '../users/entities/user.entity';
@Injectable() @Injectable()
export class AuthService { export class AuthService {
private readonly logger = new Logger(AuthService.name);
private readonly appUrl: string;
constructor( constructor(
private usersService: UsersService, private usersService: UsersService,
private jwtService: JwtService, private jwtService: JwtService,
private configService: ConfigService,
private dataSource: DataSource, private dataSource: DataSource,
) {} private emailService: EmailService,
) {
this.appUrl = this.configService.get<string>('APP_URL') || 'http://localhost:5173';
}
async register(dto: RegisterDto) { async register(dto: RegisterDto) {
const existing = await this.usersService.findByEmail(dto.email); const existing = await this.usersService.findByEmail(dto.email);
@@ -75,6 +87,14 @@ export class AuthService {
return this.generateTokenResponse(u); return this.generateTokenResponse(u);
} }
/**
* Logout — currently a no-op on the server since JWT is stateless.
* When refresh tokens are added, this should revoke the refresh token.
*/
async logout(_userId: string): Promise<void> {
// Placeholder for refresh token revocation
}
async getProfile(userId: string) { async getProfile(userId: string) {
const user = await this.usersService.findByIdWithOrgs(userId); const user = await this.usersService.findByIdWithOrgs(userId);
if (!user) { if (!user) {
@@ -139,6 +159,105 @@ export class AuthService {
await this.usersService.markIntroSeen(userId); await this.usersService.markIntroSeen(userId);
} }
// ─── Password Reset Flow ──────────────────────────────────────────
/**
* Request a password reset. Generates a token, stores its hash, and sends an email.
* Silently succeeds even if the email doesn't exist (prevents enumeration).
*/
async requestPasswordReset(email: string): Promise<void> {
const user = await this.usersService.findByEmail(email);
if (!user) {
// Silently return — don't reveal whether the account exists
return;
}
// Invalidate any existing reset tokens for this user
await this.dataSource.query(
`UPDATE shared.password_reset_tokens SET used_at = NOW()
WHERE user_id = $1 AND used_at IS NULL`,
[user.id],
);
// Generate a 64-byte random token
const rawToken = randomBytes(64).toString('base64url');
const tokenHash = createHash('sha256').update(rawToken).digest('hex');
const expiresAt = new Date(Date.now() + 15 * 60 * 1000); // 15 minutes
await this.dataSource.query(
`INSERT INTO shared.password_reset_tokens (user_id, token_hash, expires_at)
VALUES ($1, $2, $3)`,
[user.id, tokenHash, expiresAt],
);
const resetUrl = `${this.appUrl}/reset-password?token=${rawToken}`;
await this.emailService.sendPasswordResetEmail(user.email, resetUrl);
}
/**
* Reset password using a valid reset token.
*/
async resetPassword(rawToken: string, newPassword: string): Promise<void> {
const tokenHash = createHash('sha256').update(rawToken).digest('hex');
const rows = await this.dataSource.query(
`SELECT id, user_id, expires_at, used_at
FROM shared.password_reset_tokens
WHERE token_hash = $1`,
[tokenHash],
);
if (rows.length === 0) {
throw new BadRequestException('Invalid or expired reset token');
}
const record = rows[0];
if (record.used_at) {
throw new BadRequestException('This reset link has already been used');
}
if (new Date(record.expires_at) < new Date()) {
throw new BadRequestException('This reset link has expired');
}
// Update password
const passwordHash = await bcrypt.hash(newPassword, 12);
await this.dataSource.query(
`UPDATE shared.users SET password_hash = $1, updated_at = NOW() WHERE id = $2`,
[passwordHash, record.user_id],
);
// Mark token as used
await this.dataSource.query(
`UPDATE shared.password_reset_tokens SET used_at = NOW() WHERE id = $1`,
[record.id],
);
}
/**
* Change password for an authenticated user (requires current password).
*/
async changePassword(userId: string, currentPassword: string, newPassword: string): Promise<void> {
const user = await this.usersService.findById(userId);
if (!user || !user.passwordHash) {
throw new UnauthorizedException('User not found');
}
const isValid = await bcrypt.compare(currentPassword, user.passwordHash);
if (!isValid) {
throw new UnauthorizedException('Current password is incorrect');
}
const passwordHash = await bcrypt.hash(newPassword, 12);
await this.dataSource.query(
`UPDATE shared.users SET password_hash = $1, updated_at = NOW() WHERE id = $2`,
[passwordHash, userId],
);
}
// ─── Private Helpers ──────────────────────────────────────────────
private async recordLoginHistory( private async recordLoginHistory(
userId: string, userId: string,
organizationId: string | null, organizationId: string | null,

View File

@@ -0,0 +1,9 @@
import { Module, Global } from '@nestjs/common';
import { EmailService } from './email.service';
@Global()
@Module({
providers: [EmailService],
exports: [EmailService],
})
export class EmailModule {}

View File

@@ -0,0 +1,49 @@
import { Injectable, Logger } from '@nestjs/common';
import { DataSource } from 'typeorm';
/**
* Stubbed email service — logs to console and stores in shared.email_log.
* Replace internals with Resend/SendGrid when ready for production.
*/
@Injectable()
export class EmailService {
private readonly logger = new Logger(EmailService.name);
constructor(private dataSource: DataSource) {}
async sendPasswordResetEmail(email: string, resetUrl: string): Promise<void> {
const subject = 'Reset your HOA LedgerIQ password';
const body = [
`You requested a password reset for your HOA LedgerIQ account.`,
``,
`Click the link below to reset your password:`,
resetUrl,
``,
`This link expires in 15 minutes. If you didn't request this, ignore this email.`,
].join('\n');
await this.log(email, subject, body, 'password_reset', { resetUrl });
}
private async log(
toEmail: string,
subject: string,
body: string,
template: string,
metadata: Record<string, any>,
): Promise<void> {
this.logger.log(`EMAIL STUB -> ${toEmail}`);
this.logger.log(` Subject: ${subject}`);
this.logger.log(` Body:\n${body}`);
try {
await this.dataSource.query(
`INSERT INTO shared.email_log (to_email, subject, body, template, metadata)
VALUES ($1, $2, $3, $4, $5)`,
[toEmail, subject, body, template, JSON.stringify(metadata)],
);
} catch (err) {
this.logger.warn(`Failed to log email: ${err}`);
}
}
}

View File

@@ -0,0 +1,25 @@
-- Migration 016: Password Reset Tokens
-- Adds table for password reset token storage (hashed, single-use, short-lived).
CREATE TABLE IF NOT EXISTS shared.password_reset_tokens (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES shared.users(id) ON DELETE CASCADE,
token_hash VARCHAR(255) UNIQUE NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_hash ON shared.password_reset_tokens(token_hash);
CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_user ON shared.password_reset_tokens(user_id);
-- Also ensure email_log table exists (may not exist if migration 015 hasn't been applied)
CREATE TABLE IF NOT EXISTS shared.email_log (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
to_email VARCHAR(255) NOT NULL,
subject VARCHAR(500) NOT NULL,
body TEXT,
template VARCHAR(100),
metadata JSONB,
sent_at TIMESTAMPTZ DEFAULT NOW()
);

View File

@@ -587,7 +587,7 @@ export function AccountsPage() {
{investments.filter(i => i.is_active).length > 0 && ( {investments.filter(i => i.is_active).length > 0 && (
<> <>
<Divider label="Investment Accounts" labelPosition="center" my="xs" /> <Divider label="Investment Accounts" labelPosition="center" my="xs" />
<InvestmentMiniTable investments={investments.filter(i => i.is_active)} onEdit={handleEditInvestment} /> <InvestmentMiniTable investments={investments.filter(i => i.is_active)} onEdit={handleEditInvestment} isReadOnly={isReadOnly} />
</> </>
)} )}
</Stack> </Stack>
@@ -605,7 +605,7 @@ export function AccountsPage() {
{operatingInvestments.length > 0 && ( {operatingInvestments.length > 0 && (
<> <>
<Divider label="Operating Investment Accounts" labelPosition="center" my="xs" /> <Divider label="Operating Investment Accounts" labelPosition="center" my="xs" />
<InvestmentMiniTable investments={operatingInvestments} onEdit={handleEditInvestment} /> <InvestmentMiniTable investments={operatingInvestments} onEdit={handleEditInvestment} isReadOnly={isReadOnly} />
</> </>
)} )}
</Stack> </Stack>
@@ -623,7 +623,7 @@ export function AccountsPage() {
{reserveInvestments.length > 0 && ( {reserveInvestments.length > 0 && (
<> <>
<Divider label="Reserve Investment Accounts" labelPosition="center" my="xs" /> <Divider label="Reserve Investment Accounts" labelPosition="center" my="xs" />
<InvestmentMiniTable investments={reserveInvestments} onEdit={handleEditInvestment} /> <InvestmentMiniTable investments={reserveInvestments} onEdit={handleEditInvestment} isReadOnly={isReadOnly} />
</> </>
)} )}
</Stack> </Stack>
@@ -1087,9 +1087,11 @@ function AccountTable({
function InvestmentMiniTable({ function InvestmentMiniTable({
investments, investments,
onEdit, onEdit,
isReadOnly = false,
}: { }: {
investments: Investment[]; investments: Investment[];
onEdit: (inv: Investment) => void; onEdit: (inv: Investment) => void;
isReadOnly?: boolean;
}) { }) {
const totalPrincipal = investments.reduce((s, i) => s + parseFloat(i.principal || '0'), 0); const totalPrincipal = investments.reduce((s, i) => s + parseFloat(i.principal || '0'), 0);
const totalValue = investments.reduce( const totalValue = investments.reduce(
@@ -1132,7 +1134,7 @@ function InvestmentMiniTable({
<Table.Th ta="right">Maturity Value</Table.Th> <Table.Th ta="right">Maturity Value</Table.Th>
<Table.Th>Maturity Date</Table.Th> <Table.Th>Maturity Date</Table.Th>
<Table.Th ta="right">Days Remaining</Table.Th> <Table.Th ta="right">Days Remaining</Table.Th>
<Table.Th></Table.Th> {!isReadOnly && <Table.Th></Table.Th>}
</Table.Tr> </Table.Tr>
</Table.Thead> </Table.Thead>
<Table.Tbody> <Table.Tbody>
@@ -1182,6 +1184,7 @@ function InvestmentMiniTable({
'-' '-'
)} )}
</Table.Td> </Table.Td>
{!isReadOnly && (
<Table.Td> <Table.Td>
<Tooltip label="Edit investment"> <Tooltip label="Edit investment">
<ActionIcon variant="subtle" onClick={() => onEdit(inv)}> <ActionIcon variant="subtle" onClick={() => onEdit(inv)}>
@@ -1189,6 +1192,7 @@ function InvestmentMiniTable({
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
</Table.Td> </Table.Td>
)}
</Table.Tr> </Table.Tr>
))} ))}
</Table.Tbody> </Table.Tbody>

View File

@@ -72,9 +72,10 @@ interface KanbanCardProps {
project: Project; project: Project;
onEdit: (p: Project) => void; onEdit: (p: Project) => void;
onDragStart: (e: DragEvent<HTMLDivElement>, project: Project) => void; onDragStart: (e: DragEvent<HTMLDivElement>, project: Project) => void;
isReadOnly?: boolean;
} }
function KanbanCard({ project, onEdit, onDragStart }: KanbanCardProps) { function KanbanCard({ project, onEdit, onDragStart, isReadOnly }: KanbanCardProps) {
const plannedLabel = formatPlannedDate(project.planned_date); const plannedLabel = formatPlannedDate(project.planned_date);
// For projects in the Future bucket with a specific year, show the year // For projects in the Future bucket with a specific year, show the year
const currentYear = new Date().getFullYear(); const currentYear = new Date().getFullYear();
@@ -86,21 +87,23 @@ function KanbanCard({ project, onEdit, onDragStart }: KanbanCardProps) {
padding="sm" padding="sm"
radius="md" radius="md"
withBorder withBorder
draggable draggable={!isReadOnly}
onDragStart={(e) => onDragStart(e, project)} onDragStart={!isReadOnly ? (e) => onDragStart(e, project) : undefined}
style={{ cursor: 'grab', userSelect: 'none' }} style={{ cursor: isReadOnly ? 'default' : 'grab', userSelect: 'none' }}
mb="xs" mb="xs"
> >
<Group justify="space-between" wrap="nowrap" mb={4}> <Group justify="space-between" wrap="nowrap" mb={4}>
<Group gap={6} wrap="nowrap" style={{ overflow: 'hidden' }}> <Group gap={6} wrap="nowrap" style={{ overflow: 'hidden' }}>
<IconGripVertical size={14} style={{ flexShrink: 0, color: 'var(--mantine-color-dimmed)' }} /> {!isReadOnly && <IconGripVertical size={14} style={{ flexShrink: 0, color: 'var(--mantine-color-dimmed)' }} />}
<Text fw={600} size="sm" truncate> <Text fw={600} size="sm" truncate>
{project.name} {project.name}
</Text> </Text>
</Group> </Group>
{!isReadOnly && (
<ActionIcon variant="subtle" size="sm" onClick={() => onEdit(project)}> <ActionIcon variant="subtle" size="sm" onClick={() => onEdit(project)}>
<IconEdit size={14} /> <IconEdit size={14} />
</ActionIcon> </ActionIcon>
)}
</Group> </Group>
<Group gap={6} mb={6}> <Group gap={6} mb={6}>
@@ -148,11 +151,12 @@ interface KanbanColumnProps {
isDragOver: boolean; isDragOver: boolean;
onDragOverHandler: (e: DragEvent<HTMLDivElement>, year: number) => void; onDragOverHandler: (e: DragEvent<HTMLDivElement>, year: number) => void;
onDragLeave: () => void; onDragLeave: () => void;
isReadOnly?: boolean;
} }
function KanbanColumn({ function KanbanColumn({
year, projects, onEdit, onDragStart, onDrop, year, projects, onEdit, onDragStart, onDrop,
isDragOver, onDragOverHandler, onDragLeave, isDragOver, onDragOverHandler, onDragLeave, isReadOnly,
}: KanbanColumnProps) { }: KanbanColumnProps) {
const totalEst = projects.reduce((s, p) => s + parseFloat(p.estimated_cost || '0'), 0); const totalEst = projects.reduce((s, p) => s + parseFloat(p.estimated_cost || '0'), 0);
const isFuture = year === FUTURE_YEAR; const isFuture = year === FUTURE_YEAR;
@@ -178,9 +182,9 @@ function KanbanColumn({
border: isDragOver ? '2px dashed var(--mantine-color-blue-4)' : undefined, border: isDragOver ? '2px dashed var(--mantine-color-blue-4)' : undefined,
transition: 'background-color 150ms ease, border 150ms ease', transition: 'background-color 150ms ease, border 150ms ease',
}} }}
onDragOver={(e) => onDragOverHandler(e, year)} onDragOver={!isReadOnly ? (e) => onDragOverHandler(e, year) : undefined}
onDragLeave={onDragLeave} onDragLeave={!isReadOnly ? onDragLeave : undefined}
onDrop={(e) => onDrop(e, year)} onDrop={!isReadOnly ? (e) => onDrop(e, year) : undefined}
> >
<Group justify="space-between" mb="sm"> <Group justify="space-between" mb="sm">
<Title order={5}>{yearLabel(year)}</Title> <Title order={5}>{yearLabel(year)}</Title>
@@ -199,7 +203,7 @@ function KanbanColumn({
<Box style={{ flex: 1, minHeight: 60 }}> <Box style={{ flex: 1, minHeight: 60 }}>
{projects.length === 0 ? ( {projects.length === 0 ? (
<Text size="xs" c="dimmed" ta="center" py="lg"> <Text size="xs" c="dimmed" ta="center" py="lg">
Drop projects here {isReadOnly ? 'No projects' : 'Drop projects here'}
</Text> </Text>
) : useWideLayout ? ( ) : useWideLayout ? (
<div style={{ <div style={{
@@ -208,12 +212,12 @@ function KanbanColumn({
gap: 'var(--mantine-spacing-xs)', gap: 'var(--mantine-spacing-xs)',
}}> }}>
{projects.map((p) => ( {projects.map((p) => (
<KanbanCard key={p.id} project={p} onEdit={onEdit} onDragStart={onDragStart} /> <KanbanCard key={p.id} project={p} onEdit={onEdit} onDragStart={onDragStart} isReadOnly={isReadOnly} />
))} ))}
</div> </div>
) : ( ) : (
projects.map((p) => ( projects.map((p) => (
<KanbanCard key={p.id} project={p} onEdit={onEdit} onDragStart={onDragStart} /> <KanbanCard key={p.id} project={p} onEdit={onEdit} onDragStart={onDragStart} isReadOnly={isReadOnly} />
)) ))
)} )}
</Box> </Box>
@@ -595,6 +599,7 @@ export function CapitalProjectsPage() {
isDragOver={dragOverYear === year} isDragOver={dragOverYear === year}
onDragOverHandler={handleDragOver} onDragOverHandler={handleDragOver}
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}
isReadOnly={isReadOnly}
/> />
); );
})} })}

View File

@@ -18,7 +18,7 @@ import {
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useAuthStore } from '../../stores/authStore'; import { useAuthStore, useIsReadOnly } from '../../stores/authStore';
import api from '../../services/api'; import api from '../../services/api';
interface HealthScore { interface HealthScore {
@@ -313,6 +313,7 @@ interface DashboardData {
export function DashboardPage() { export function DashboardPage() {
const currentOrg = useAuthStore((s) => s.currentOrg); const currentOrg = useAuthStore((s) => s.currentOrg);
const isReadOnly = useIsReadOnly();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
// Track whether a refresh is in progress (per score type) for async polling // Track whether a refresh is in progress (per score type) for async polling
@@ -426,7 +427,7 @@ export function DashboardPage() {
</ThemeIcon> </ThemeIcon>
} }
isRefreshing={operatingRefreshing} isRefreshing={operatingRefreshing}
onRefresh={handleRefreshOperating} onRefresh={!isReadOnly ? handleRefreshOperating : undefined}
lastFailed={!!healthScores?.operating_last_failed} lastFailed={!!healthScores?.operating_last_failed}
/> />
<HealthScoreCard <HealthScoreCard
@@ -438,7 +439,7 @@ export function DashboardPage() {
</ThemeIcon> </ThemeIcon>
} }
isRefreshing={reserveRefreshing} isRefreshing={reserveRefreshing}
onRefresh={handleRefreshReserve} onRefresh={!isReadOnly ? handleRefreshReserve : undefined}
lastFailed={!!healthScores?.reserve_last_failed} lastFailed={!!healthScores?.reserve_last_failed}
/> />
</SimpleGrid> </SimpleGrid>

View File

@@ -43,6 +43,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { notifications } from '@mantine/notifications'; import { notifications } from '@mantine/notifications';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import api from '../../services/api'; import api from '../../services/api';
import { useIsReadOnly } from '../../stores/authStore';
// ── Types ── // ── Types ──
@@ -384,6 +385,7 @@ export function InvestmentPlanningPage() {
const [targetScenarioId, setTargetScenarioId] = useState<string | null>(null); const [targetScenarioId, setTargetScenarioId] = useState<string | null>(null);
const [newScenarioName, setNewScenarioName] = useState(''); const [newScenarioName, setNewScenarioName] = useState('');
const [investmentStartDate, setInvestmentStartDate] = useState<Date | null>(new Date()); const [investmentStartDate, setInvestmentStartDate] = useState<Date | null>(new Date());
const isReadOnly = useIsReadOnly();
// Load investment scenarios for the "Add to Plan" modal // Load investment scenarios for the "Add to Plan" modal
const { data: investmentScenarios } = useQuery<any[]>({ const { data: investmentScenarios } = useQuery<any[]>({
@@ -821,6 +823,7 @@ export function InvestmentPlanningPage() {
</Text> </Text>
</div> </div>
</Group> </Group>
{!isReadOnly && (
<Button <Button
leftSection={<IconSparkles size={16} />} leftSection={<IconSparkles size={16} />}
onClick={handleTriggerAI} onClick={handleTriggerAI}
@@ -830,6 +833,7 @@ export function InvestmentPlanningPage() {
> >
{aiResult ? 'Refresh Recommendations' : 'Get AI Recommendations'} {aiResult ? 'Refresh Recommendations' : 'Get AI Recommendations'}
</Button> </Button>
)}
</Group> </Group>
{/* Processing State - shown as banner when refreshing with existing results */} {/* Processing State - shown as banner when refreshing with existing results */}

View File

@@ -9,6 +9,7 @@ import { notifications } from '@mantine/notifications';
import { IconSend, IconInfoCircle, IconCheck, IconX } from '@tabler/icons-react'; import { IconSend, IconInfoCircle, IconCheck, IconX } from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api'; import api from '../../services/api';
import { useIsReadOnly } from '../../stores/authStore';
interface Invoice { interface Invoice {
id: string; invoice_number: string; unit_number: string; unit_id: string; id: string; invoice_number: string; unit_number: string; unit_id: string;
@@ -64,6 +65,7 @@ export function InvoicesPage() {
const [preview, setPreview] = useState<Preview | null>(null); const [preview, setPreview] = useState<Preview | null>(null);
const [previewLoading, setPreviewLoading] = useState(false); const [previewLoading, setPreviewLoading] = useState(false);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const isReadOnly = useIsReadOnly();
const { data: invoices = [], isLoading } = useQuery<Invoice[]>({ const { data: invoices = [], isLoading } = useQuery<Invoice[]>({
queryKey: ['invoices'], queryKey: ['invoices'],
@@ -124,10 +126,12 @@ export function InvoicesPage() {
<Stack> <Stack>
<Group justify="space-between"> <Group justify="space-between">
<Title order={2}>Invoices</Title> <Title order={2}>Invoices</Title>
{!isReadOnly && (
<Group> <Group>
<Button variant="outline" onClick={() => lateFeesMutation.mutate()} loading={lateFeesMutation.isPending}>Apply Late Fees</Button> <Button variant="outline" onClick={() => lateFeesMutation.mutate()} loading={lateFeesMutation.isPending}>Apply Late Fees</Button>
<Button leftSection={<IconSend size={16} />} onClick={openBulk}>Generate Invoices</Button> <Button leftSection={<IconSend size={16} />} onClick={openBulk}>Generate Invoices</Button>
</Group> </Group>
)}
</Group> </Group>
<Group> <Group>
<Card withBorder p="sm"><Text size="xs" c="dimmed">Total Invoices</Text><Text fw={700}>{invoices.length}</Text></Card> <Card withBorder p="sm"><Text size="xs" c="dimmed">Total Invoices</Text><Text fw={700}>{invoices.length}</Text></Card>

View File

@@ -12,6 +12,9 @@
# #
# Replace "app.yourdomain.com" with your actual hostname throughout this file. # Replace "app.yourdomain.com" with your actual hostname throughout this file.
# Hide nginx version from Server header
server_tokens off;
# --- Rate limiting --- # --- Rate limiting ---
# 10 requests/sec per IP for API routes (shared memory zone: 10 MB ≈ 160k IPs) # 10 requests/sec per IP for API routes (shared memory zone: 10 MB ≈ 160k IPs)
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s; limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;
@@ -49,6 +52,12 @@ server {
ssl_session_timeout 10m; ssl_session_timeout 10m;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# Security headers — applied to all routes
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
# --- Proxy defaults --- # --- Proxy defaults ---
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Host $host; proxy_set_header Host $host;

View File

@@ -8,6 +8,9 @@ upstream frontend {
keepalive 16; keepalive 16;
} }
# Hide nginx version from Server header
server_tokens off;
# Shared proxy settings # Shared proxy settings
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Connection ""; # enable keepalive to upstreams proxy_set_header Connection ""; # enable keepalive to upstreams
@@ -30,6 +33,12 @@ server {
listen 80; listen 80;
server_name _; server_name _;
# Security headers — applied to all routes at the nginx layer
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
# --- API routes → backend --- # --- API routes → backend ---
location /api/ { location /api/ {
limit_req zone=api_limit burst=30 nodelay; limit_req zone=api_limit burst=30 nodelay;