Compare commits
17 Commits
claude/rev
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5845334454 | |||
| 170461c359 | |||
| 6b12fcd7d7 | |||
| c2e52bee64 | |||
| 8abab40778 | |||
| 19fb2c037c | |||
| e62f3e7b07 | |||
| e3022f20c5 | |||
| 9cd20a1867 | |||
| 420227d70c | |||
| e893319cfe | |||
| 93eeacfe8f | |||
| 267d92933e | |||
| 9d137a40d3 | |||
| 2b83defbc3 | |||
| a59dac7fe1 | |||
| 1e31595d7f |
@@ -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';
|
||||||
@@ -95,6 +96,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 {
|
||||||
|
|||||||
16
backend/src/common/interceptors/no-cache.interceptor.ts
Normal file
16
backend/src/common/interceptors/no-cache.interceptor.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,8 @@ import {
|
|||||||
Get,
|
Get,
|
||||||
Res,
|
Res,
|
||||||
Query,
|
Query,
|
||||||
|
HttpCode,
|
||||||
|
ForbiddenException,
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
@@ -23,6 +25,7 @@ import { AllowViewer } from '../../common/decorators/allow-viewer.decorator';
|
|||||||
|
|
||||||
const COOKIE_NAME = 'ledgeriq_rt';
|
const COOKIE_NAME = 'ledgeriq_rt';
|
||||||
const isProduction = process.env.NODE_ENV === 'production';
|
const isProduction = process.env.NODE_ENV === 'production';
|
||||||
|
const isOpenRegistration = process.env.ALLOW_OPEN_REGISTRATION === 'true';
|
||||||
|
|
||||||
function setRefreshCookie(res: Response, token: string) {
|
function setRefreshCookie(res: Response, token: string) {
|
||||||
res.cookie(COOKIE_NAME, token, {
|
res.cookie(COOKIE_NAME, token, {
|
||||||
@@ -49,9 +52,14 @@ 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, @Res({ passthrough: true }) res: Response) {
|
async register(@Body() dto: RegisterDto, @Res({ passthrough: true }) res: Response) {
|
||||||
|
if (!isOpenRegistration) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'Open registration is disabled. Please use an invitation link to create your account.',
|
||||||
|
);
|
||||||
|
}
|
||||||
const result = await this.authService.register(dto);
|
const result = await this.authService.register(dto);
|
||||||
if (result.refreshToken) {
|
if (result.refreshToken) {
|
||||||
setRefreshCookie(res, result.refreshToken);
|
setRefreshCookie(res, result.refreshToken);
|
||||||
@@ -93,6 +101,7 @@ export class AuthController {
|
|||||||
|
|
||||||
@Post('logout')
|
@Post('logout')
|
||||||
@ApiOperation({ summary: 'Logout and revoke refresh token' })
|
@ApiOperation({ summary: 'Logout and revoke refresh token' })
|
||||||
|
@HttpCode(200)
|
||||||
async logout(@Request() req: any, @Res({ passthrough: true }) res: Response) {
|
async logout(@Request() req: any, @Res({ passthrough: true }) res: Response) {
|
||||||
const rawToken = req.cookies?.[COOKIE_NAME];
|
const rawToken = req.cookies?.[COOKIE_NAME];
|
||||||
if (rawToken) {
|
if (rawToken) {
|
||||||
@@ -104,6 +113,7 @@ export class AuthController {
|
|||||||
|
|
||||||
@Post('logout-everywhere')
|
@Post('logout-everywhere')
|
||||||
@ApiOperation({ summary: 'Revoke all sessions' })
|
@ApiOperation({ summary: 'Revoke all sessions' })
|
||||||
|
@HttpCode(200)
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
async logoutEverywhere(@Request() req: any, @Res({ passthrough: true }) res: Response) {
|
async logoutEverywhere(@Request() req: any, @Res({ passthrough: true }) res: Response) {
|
||||||
@@ -183,4 +193,51 @@ export class AuthController {
|
|||||||
// Stubbed — will be implemented when email service is ready
|
// Stubbed — will be implemented when email service is ready
|
||||||
return { success: true, message: 'If an account exists, a new activation link has been sent.' };
|
return { success: true, message: 'If an account exists, a new activation link has been sent.' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── 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.' };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,8 +11,9 @@ import { JwtService } from '@nestjs/jwt';
|
|||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import * as bcrypt from 'bcryptjs';
|
import * as bcrypt from 'bcryptjs';
|
||||||
import { createHash } from 'crypto';
|
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';
|
||||||
import { RefreshTokenService } from './refresh-token.service';
|
import { RefreshTokenService } from './refresh-token.service';
|
||||||
@@ -21,6 +22,7 @@ import { RefreshTokenService } from './refresh-token.service';
|
|||||||
export class AuthService {
|
export class AuthService {
|
||||||
private readonly logger = new Logger(AuthService.name);
|
private readonly logger = new Logger(AuthService.name);
|
||||||
private readonly inviteSecret: string;
|
private readonly inviteSecret: string;
|
||||||
|
private readonly appUrl: string;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private usersService: UsersService,
|
private usersService: UsersService,
|
||||||
@@ -28,8 +30,10 @@ export class AuthService {
|
|||||||
private configService: ConfigService,
|
private configService: ConfigService,
|
||||||
private dataSource: DataSource,
|
private dataSource: DataSource,
|
||||||
private refreshTokenService: RefreshTokenService,
|
private refreshTokenService: RefreshTokenService,
|
||||||
|
private emailService: EmailService,
|
||||||
) {
|
) {
|
||||||
this.inviteSecret = this.configService.get<string>('INVITE_TOKEN_SECRET') || 'dev-invite-secret';
|
this.inviteSecret = this.configService.get<string>('INVITE_TOKEN_SECRET') || 'dev-invite-secret';
|
||||||
|
this.appUrl = this.configService.get<string>('APP_URL') || 'http://localhost:5173';
|
||||||
}
|
}
|
||||||
|
|
||||||
async register(dto: RegisterDto) {
|
async register(dto: RegisterDto) {
|
||||||
@@ -309,6 +313,105 @@ export class AuthService {
|
|||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── 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,
|
||||||
|
|||||||
25
db/migrations/016-password-reset-tokens.sql
Normal file
25
db/migrations/016-password-reset-tokens.sql
Normal 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()
|
||||||
|
);
|
||||||
@@ -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,13 +1184,15 @@ function InvestmentMiniTable({
|
|||||||
'-'
|
'-'
|
||||||
)}
|
)}
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
{!isReadOnly && (
|
||||||
<Tooltip label="Edit investment">
|
<Table.Td>
|
||||||
<ActionIcon variant="subtle" onClick={() => onEdit(inv)}>
|
<Tooltip label="Edit investment">
|
||||||
<IconEdit size={16} />
|
<ActionIcon variant="subtle" onClick={() => onEdit(inv)}>
|
||||||
</ActionIcon>
|
<IconEdit size={16} />
|
||||||
</Tooltip>
|
</ActionIcon>
|
||||||
</Table.Td>
|
</Tooltip>
|
||||||
|
</Table.Td>
|
||||||
|
)}
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
))}
|
))}
|
||||||
</Table.Tbody>
|
</Table.Tbody>
|
||||||
|
|||||||
@@ -89,20 +89,20 @@ export function ProjectionChart({ datapoints, title = 'Financial Projection', su
|
|||||||
<AreaChart data={chartData}>
|
<AreaChart data={chartData}>
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="opCash" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="opCash" x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="5%" stopColor="#228be6" stopOpacity={0.3} />
|
<stop offset="5%" stopColor="#228be6" stopOpacity={0.6} />
|
||||||
<stop offset="95%" stopColor="#228be6" stopOpacity={0} />
|
<stop offset="95%" stopColor="#228be6" stopOpacity={0.15} />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
<linearGradient id="opInv" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="opInv" x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="5%" stopColor="#74c0fc" stopOpacity={0.3} />
|
<stop offset="5%" stopColor="#74c0fc" stopOpacity={0.6} />
|
||||||
<stop offset="95%" stopColor="#74c0fc" stopOpacity={0} />
|
<stop offset="95%" stopColor="#74c0fc" stopOpacity={0.15} />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
<linearGradient id="resCash" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="resCash" x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="5%" stopColor="#7950f2" stopOpacity={0.3} />
|
<stop offset="5%" stopColor="#7950f2" stopOpacity={0.6} />
|
||||||
<stop offset="95%" stopColor="#7950f2" stopOpacity={0} />
|
<stop offset="95%" stopColor="#7950f2" stopOpacity={0.15} />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
<linearGradient id="resInv" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="resInv" x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="5%" stopColor="#b197fc" stopOpacity={0.3} />
|
<stop offset="5%" stopColor="#b197fc" stopOpacity={0.6} />
|
||||||
<stop offset="95%" stopColor="#b197fc" stopOpacity={0} />
|
<stop offset="95%" stopColor="#b197fc" stopOpacity={0.15} />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
<CartesianGrid strokeDasharray="3 3" opacity={0.3} />
|
<CartesianGrid strokeDasharray="3 3" opacity={0.3} />
|
||||||
|
|||||||
@@ -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>
|
||||||
<ActionIcon variant="subtle" size="sm" onClick={() => onEdit(project)}>
|
{!isReadOnly && (
|
||||||
<IconEdit size={14} />
|
<ActionIcon variant="subtle" size="sm" onClick={() => onEdit(project)}>
|
||||||
</ActionIcon>
|
<IconEdit size={14} />
|
||||||
|
</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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { useState, useMemo } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
Title, Text, Stack, Card, Group, SimpleGrid, ThemeIcon,
|
Title, Text, Stack, Card, Group,
|
||||||
SegmentedControl, Loader, Center, ActionIcon, Tooltip, Badge,
|
SegmentedControl, Loader, Center, ActionIcon, Tooltip, Badge,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import {
|
import {
|
||||||
IconCash, IconBuildingBank, IconChartAreaLine,
|
|
||||||
IconArrowLeft, IconArrowRight, IconCalendar,
|
IconArrowLeft, IconArrowRight, IconCalendar,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
@@ -108,30 +107,6 @@ export function CashFlowForecastPage() {
|
|||||||
return datapoints.slice(viewStartIndex, viewStartIndex + 12);
|
return datapoints.slice(viewStartIndex, viewStartIndex + 12);
|
||||||
}, [datapoints, viewStartIndex]);
|
}, [datapoints, viewStartIndex]);
|
||||||
|
|
||||||
// Compute summary stats for the current view
|
|
||||||
const summaryStats = useMemo(() => {
|
|
||||||
if (!viewData.length) return null;
|
|
||||||
const last = viewData[viewData.length - 1];
|
|
||||||
const first = viewData[0];
|
|
||||||
|
|
||||||
const totalOperating = last.operating_cash + last.operating_investments;
|
|
||||||
const totalReserve = last.reserve_cash + last.reserve_investments;
|
|
||||||
const totalAll = totalOperating + totalReserve;
|
|
||||||
|
|
||||||
const firstTotal = first.operating_cash + first.operating_investments +
|
|
||||||
first.reserve_cash + first.reserve_investments;
|
|
||||||
const netChange = totalAll - firstTotal;
|
|
||||||
|
|
||||||
return {
|
|
||||||
totalOperating,
|
|
||||||
totalReserve,
|
|
||||||
totalAll,
|
|
||||||
netChange,
|
|
||||||
periodStart: first.month,
|
|
||||||
periodEnd: last.month,
|
|
||||||
};
|
|
||||||
}, [viewData]);
|
|
||||||
|
|
||||||
// Determine the first forecast month index within the view
|
// Determine the first forecast month index within the view
|
||||||
const forecastStartLabel = useMemo(() => {
|
const forecastStartLabel = useMemo(() => {
|
||||||
const idx = viewData.findIndex((d) => d.is_forecast);
|
const idx = viewData.findIndex((d) => d.is_forecast);
|
||||||
@@ -181,65 +156,6 @@ export function CashFlowForecastPage() {
|
|||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{/* Summary Cards */}
|
|
||||||
{summaryStats && (
|
|
||||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }}>
|
|
||||||
<Card withBorder p="md">
|
|
||||||
<Group gap="xs" mb={4}>
|
|
||||||
<ThemeIcon variant="light" color="blue" size="sm">
|
|
||||||
<IconCash size={14} />
|
|
||||||
</ThemeIcon>
|
|
||||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Operating Total</Text>
|
|
||||||
</Group>
|
|
||||||
<Text fw={700} size="xl" ff="monospace">
|
|
||||||
{fmt(summaryStats.totalOperating)}
|
|
||||||
</Text>
|
|
||||||
</Card>
|
|
||||||
<Card withBorder p="md">
|
|
||||||
<Group gap="xs" mb={4}>
|
|
||||||
<ThemeIcon variant="light" color="violet" size="sm">
|
|
||||||
<IconBuildingBank size={14} />
|
|
||||||
</ThemeIcon>
|
|
||||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Reserve Total</Text>
|
|
||||||
</Group>
|
|
||||||
<Text fw={700} size="xl" ff="monospace">
|
|
||||||
{fmt(summaryStats.totalReserve)}
|
|
||||||
</Text>
|
|
||||||
</Card>
|
|
||||||
<Card withBorder p="md">
|
|
||||||
<Group gap="xs" mb={4}>
|
|
||||||
<ThemeIcon variant="light" color="teal" size="sm">
|
|
||||||
<IconChartAreaLine size={14} />
|
|
||||||
</ThemeIcon>
|
|
||||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Combined Total</Text>
|
|
||||||
</Group>
|
|
||||||
<Text fw={700} size="xl" ff="monospace">
|
|
||||||
{fmt(summaryStats.totalAll)}
|
|
||||||
</Text>
|
|
||||||
</Card>
|
|
||||||
<Card withBorder p="md">
|
|
||||||
<Group gap="xs" mb={4}>
|
|
||||||
<ThemeIcon
|
|
||||||
variant="light"
|
|
||||||
color={summaryStats.netChange >= 0 ? 'green' : 'red'}
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
<IconCash size={14} />
|
|
||||||
</ThemeIcon>
|
|
||||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Period Change</Text>
|
|
||||||
</Group>
|
|
||||||
<Text
|
|
||||||
fw={700}
|
|
||||||
size="xl"
|
|
||||||
ff="monospace"
|
|
||||||
c={summaryStats.netChange >= 0 ? 'green' : 'red'}
|
|
||||||
>
|
|
||||||
{fmt(summaryStats.netChange)}
|
|
||||||
</Text>
|
|
||||||
</Card>
|
|
||||||
</SimpleGrid>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Chart Navigation */}
|
{/* Chart Navigation */}
|
||||||
<Card withBorder p="lg">
|
<Card withBorder p="lg">
|
||||||
<Group justify="space-between" mb="md">
|
<Group justify="space-between" mb="md">
|
||||||
@@ -287,20 +203,20 @@ export function CashFlowForecastPage() {
|
|||||||
<AreaChart data={chartData} margin={{ top: 10, right: 30, left: 10, bottom: 0 }}>
|
<AreaChart data={chartData} margin={{ top: 10, right: 30, left: 10, bottom: 0 }}>
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="opCash" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="opCash" x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="5%" stopColor="#339af0" stopOpacity={0.4} />
|
<stop offset="5%" stopColor="#339af0" stopOpacity={0.6} />
|
||||||
<stop offset="95%" stopColor="#339af0" stopOpacity={0.05} />
|
<stop offset="95%" stopColor="#339af0" stopOpacity={0.15} />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
<linearGradient id="opInv" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="opInv" x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="5%" stopColor="#74c0fc" stopOpacity={0.4} />
|
<stop offset="5%" stopColor="#74c0fc" stopOpacity={0.6} />
|
||||||
<stop offset="95%" stopColor="#74c0fc" stopOpacity={0.05} />
|
<stop offset="95%" stopColor="#74c0fc" stopOpacity={0.15} />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
<linearGradient id="resCash" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="resCash" x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="5%" stopColor="#7950f2" stopOpacity={0.4} />
|
<stop offset="5%" stopColor="#7950f2" stopOpacity={0.6} />
|
||||||
<stop offset="95%" stopColor="#7950f2" stopOpacity={0.05} />
|
<stop offset="95%" stopColor="#7950f2" stopOpacity={0.15} />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
<linearGradient id="resInv" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="resInv" x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="5%" stopColor="#b197fc" stopOpacity={0.4} />
|
<stop offset="5%" stopColor="#b197fc" stopOpacity={0.6} />
|
||||||
<stop offset="95%" stopColor="#b197fc" stopOpacity={0.05} />
|
<stop offset="95%" stopColor="#b197fc" stopOpacity={0.15} />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#e9ecef" />
|
<CartesianGrid strokeDasharray="3 3" stroke="#e9ecef" />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,15 +823,17 @@ export function InvestmentPlanningPage() {
|
|||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
</Group>
|
</Group>
|
||||||
<Button
|
{!isReadOnly && (
|
||||||
leftSection={<IconSparkles size={16} />}
|
<Button
|
||||||
onClick={handleTriggerAI}
|
leftSection={<IconSparkles size={16} />}
|
||||||
loading={isProcessing}
|
onClick={handleTriggerAI}
|
||||||
variant="gradient"
|
loading={isProcessing}
|
||||||
gradient={{ from: 'grape', to: 'violet' }}
|
variant="gradient"
|
||||||
>
|
gradient={{ from: 'grape', to: 'violet' }}
|
||||||
{aiResult ? 'Refresh Recommendations' : 'Get AI Recommendations'}
|
>
|
||||||
</Button>
|
{aiResult ? 'Refresh Recommendations' : 'Get AI Recommendations'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{/* Processing State - shown as banner when refreshing with existing results */}
|
{/* Processing State - shown as banner when refreshing with existing results */}
|
||||||
|
|||||||
@@ -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>
|
||||||
<Group>
|
{!isReadOnly && (
|
||||||
<Button variant="outline" onClick={() => lateFeesMutation.mutate()} loading={lateFeesMutation.isPending}>Apply Late Fees</Button>
|
<Group>
|
||||||
<Button leftSection={<IconSend size={16} />} onClick={openBulk}>Generate Invoices</Button>
|
<Button variant="outline" onClick={() => lateFeesMutation.mutate()} loading={lateFeesMutation.isPending}>Apply Late Fees</Button>
|
||||||
</Group>
|
<Button leftSection={<IconSend size={16} />} onClick={openBulk}>Generate Invoices</Button>
|
||||||
|
</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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user