Compare commits
10 Commits
claude/rev
...
fb20c917e1
| Author | SHA1 | Date | |
|---|---|---|---|
| fb20c917e1 | |||
| 9cd20a1867 | |||
| 420227d70c | |||
| e893319cfe | |||
| 93eeacfe8f | |||
| 267d92933e | |||
| 9d137a40d3 | |||
| 2b83defbc3 | |||
| a59dac7fe1 | |||
| 1e31595d7f |
@@ -1,5 +1,5 @@
|
||||
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 { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ThrottlerModule } from '@nestjs/throttler';
|
||||
@@ -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 { NoCacheInterceptor } from './common/interceptors/no-cache.interceptor';
|
||||
import { AuthModule } from './modules/auth/auth.module';
|
||||
import { OrganizationsModule } from './modules/organizations/organizations.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 { HealthScoresModule } from './modules/health-scores/health-scores.module';
|
||||
import { BoardPlanningModule } from './modules/board-planning/board-planning.module';
|
||||
import { EmailModule } from './modules/email/email.module';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
|
||||
@Module({
|
||||
@@ -81,6 +83,7 @@ import { ScheduleModule } from '@nestjs/schedule';
|
||||
InvestmentPlanningModule,
|
||||
HealthScoresModule,
|
||||
BoardPlanningModule,
|
||||
EmailModule,
|
||||
ScheduleModule.forRoot(),
|
||||
],
|
||||
controllers: [AppController],
|
||||
@@ -89,6 +92,10 @@ import { ScheduleModule } from '@nestjs/schedule';
|
||||
provide: APP_GUARD,
|
||||
useClass: WriteAccessGuard,
|
||||
},
|
||||
{
|
||||
provide: APP_INTERCEPTOR,
|
||||
useClass: NoCacheInterceptor,
|
||||
},
|
||||
],
|
||||
})
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,9 @@ import {
|
||||
UseGuards,
|
||||
Request,
|
||||
Get,
|
||||
HttpCode,
|
||||
ForbiddenException,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
@@ -17,15 +20,22 @@ import { SwitchOrgDto } from './dto/switch-org.dto';
|
||||
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||
import { AllowViewer } from '../../common/decorators/allow-viewer.decorator';
|
||||
|
||||
const isOpenRegistration = process.env.ALLOW_OPEN_REGISTRATION === 'true';
|
||||
|
||||
@ApiTags('auth')
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private authService: AuthService) {}
|
||||
|
||||
@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 } })
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -39,6 +49,16 @@ export class AuthController {
|
||||
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')
|
||||
@ApiOperation({ summary: 'Get current user profile' })
|
||||
@ApiBearerAuth()
|
||||
@@ -67,4 +87,51 @@ export class AuthController {
|
||||
const ua = req.headers['user-agent'];
|
||||
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.' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ import { OrganizationsModule } from '../organizations/organizations.module';
|
||||
inject: [ConfigService],
|
||||
useFactory: (configService: ConfigService) => ({
|
||||
secret: configService.get<string>('JWT_SECRET'),
|
||||
signOptions: { expiresIn: '24h' },
|
||||
signOptions: { expiresIn: '1h' },
|
||||
}),
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -4,21 +4,33 @@ import {
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
NotFoundException,
|
||||
BadRequestException,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { DataSource } from 'typeorm';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import { randomBytes, createHash } from 'crypto';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import { EmailService } from '../email/email.service';
|
||||
import { RegisterDto } from './dto/register.dto';
|
||||
import { User } from '../users/entities/user.entity';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
private readonly logger = new Logger(AuthService.name);
|
||||
private readonly appUrl: string;
|
||||
|
||||
constructor(
|
||||
private usersService: UsersService,
|
||||
private jwtService: JwtService,
|
||||
private configService: ConfigService,
|
||||
private dataSource: DataSource,
|
||||
) {}
|
||||
private emailService: EmailService,
|
||||
) {
|
||||
this.appUrl = this.configService.get<string>('APP_URL') || 'http://localhost:5173';
|
||||
}
|
||||
|
||||
async register(dto: RegisterDto) {
|
||||
const existing = await this.usersService.findByEmail(dto.email);
|
||||
@@ -75,6 +87,14 @@ export class AuthService {
|
||||
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) {
|
||||
const user = await this.usersService.findByIdWithOrgs(userId);
|
||||
if (!user) {
|
||||
@@ -139,6 +159,105 @@ export class AuthService {
|
||||
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(
|
||||
userId: string,
|
||||
organizationId: string | null,
|
||||
|
||||
9
backend/src/modules/email/email.module.ts
Normal file
9
backend/src/modules/email/email.module.ts
Normal 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 {}
|
||||
49
backend/src/modules/email/email.service.ts
Normal file
49
backend/src/modules/email/email.service.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
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 && (
|
||||
<>
|
||||
<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>
|
||||
@@ -605,7 +605,7 @@ export function AccountsPage() {
|
||||
{operatingInvestments.length > 0 && (
|
||||
<>
|
||||
<Divider label="Operating Investment Accounts" labelPosition="center" my="xs" />
|
||||
<InvestmentMiniTable investments={operatingInvestments} onEdit={handleEditInvestment} />
|
||||
<InvestmentMiniTable investments={operatingInvestments} onEdit={handleEditInvestment} isReadOnly={isReadOnly} />
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
@@ -623,7 +623,7 @@ export function AccountsPage() {
|
||||
{reserveInvestments.length > 0 && (
|
||||
<>
|
||||
<Divider label="Reserve Investment Accounts" labelPosition="center" my="xs" />
|
||||
<InvestmentMiniTable investments={reserveInvestments} onEdit={handleEditInvestment} />
|
||||
<InvestmentMiniTable investments={reserveInvestments} onEdit={handleEditInvestment} isReadOnly={isReadOnly} />
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
@@ -1087,9 +1087,11 @@ function AccountTable({
|
||||
function InvestmentMiniTable({
|
||||
investments,
|
||||
onEdit,
|
||||
isReadOnly = false,
|
||||
}: {
|
||||
investments: Investment[];
|
||||
onEdit: (inv: Investment) => void;
|
||||
isReadOnly?: boolean;
|
||||
}) {
|
||||
const totalPrincipal = investments.reduce((s, i) => s + parseFloat(i.principal || '0'), 0);
|
||||
const totalValue = investments.reduce(
|
||||
@@ -1132,7 +1134,7 @@ function InvestmentMiniTable({
|
||||
<Table.Th ta="right">Maturity Value</Table.Th>
|
||||
<Table.Th>Maturity Date</Table.Th>
|
||||
<Table.Th ta="right">Days Remaining</Table.Th>
|
||||
<Table.Th></Table.Th>
|
||||
{!isReadOnly && <Table.Th></Table.Th>}
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
@@ -1182,13 +1184,15 @@ function InvestmentMiniTable({
|
||||
'-'
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Tooltip label="Edit investment">
|
||||
<ActionIcon variant="subtle" onClick={() => onEdit(inv)}>
|
||||
<IconEdit size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Table.Td>
|
||||
{!isReadOnly && (
|
||||
<Table.Td>
|
||||
<Tooltip label="Edit investment">
|
||||
<ActionIcon variant="subtle" onClick={() => onEdit(inv)}>
|
||||
<IconEdit size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Table.Td>
|
||||
)}
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
|
||||
@@ -72,9 +72,10 @@ interface KanbanCardProps {
|
||||
project: Project;
|
||||
onEdit: (p: 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);
|
||||
// For projects in the Future bucket with a specific year, show the year
|
||||
const currentYear = new Date().getFullYear();
|
||||
@@ -86,21 +87,23 @@ function KanbanCard({ project, onEdit, onDragStart }: KanbanCardProps) {
|
||||
padding="sm"
|
||||
radius="md"
|
||||
withBorder
|
||||
draggable
|
||||
onDragStart={(e) => onDragStart(e, project)}
|
||||
style={{ cursor: 'grab', userSelect: 'none' }}
|
||||
draggable={!isReadOnly}
|
||||
onDragStart={!isReadOnly ? (e) => onDragStart(e, project) : undefined}
|
||||
style={{ cursor: isReadOnly ? 'default' : 'grab', userSelect: 'none' }}
|
||||
mb="xs"
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap" mb={4}>
|
||||
<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>
|
||||
{project.name}
|
||||
</Text>
|
||||
</Group>
|
||||
<ActionIcon variant="subtle" size="sm" onClick={() => onEdit(project)}>
|
||||
<IconEdit size={14} />
|
||||
</ActionIcon>
|
||||
{!isReadOnly && (
|
||||
<ActionIcon variant="subtle" size="sm" onClick={() => onEdit(project)}>
|
||||
<IconEdit size={14} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Group gap={6} mb={6}>
|
||||
@@ -148,11 +151,12 @@ interface KanbanColumnProps {
|
||||
isDragOver: boolean;
|
||||
onDragOverHandler: (e: DragEvent<HTMLDivElement>, year: number) => void;
|
||||
onDragLeave: () => void;
|
||||
isReadOnly?: boolean;
|
||||
}
|
||||
|
||||
function KanbanColumn({
|
||||
year, projects, onEdit, onDragStart, onDrop,
|
||||
isDragOver, onDragOverHandler, onDragLeave,
|
||||
isDragOver, onDragOverHandler, onDragLeave, isReadOnly,
|
||||
}: KanbanColumnProps) {
|
||||
const totalEst = projects.reduce((s, p) => s + parseFloat(p.estimated_cost || '0'), 0);
|
||||
const isFuture = year === FUTURE_YEAR;
|
||||
@@ -178,9 +182,9 @@ function KanbanColumn({
|
||||
border: isDragOver ? '2px dashed var(--mantine-color-blue-4)' : undefined,
|
||||
transition: 'background-color 150ms ease, border 150ms ease',
|
||||
}}
|
||||
onDragOver={(e) => onDragOverHandler(e, year)}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={(e) => onDrop(e, year)}
|
||||
onDragOver={!isReadOnly ? (e) => onDragOverHandler(e, year) : undefined}
|
||||
onDragLeave={!isReadOnly ? onDragLeave : undefined}
|
||||
onDrop={!isReadOnly ? (e) => onDrop(e, year) : undefined}
|
||||
>
|
||||
<Group justify="space-between" mb="sm">
|
||||
<Title order={5}>{yearLabel(year)}</Title>
|
||||
@@ -199,7 +203,7 @@ function KanbanColumn({
|
||||
<Box style={{ flex: 1, minHeight: 60 }}>
|
||||
{projects.length === 0 ? (
|
||||
<Text size="xs" c="dimmed" ta="center" py="lg">
|
||||
Drop projects here
|
||||
{isReadOnly ? 'No projects' : 'Drop projects here'}
|
||||
</Text>
|
||||
) : useWideLayout ? (
|
||||
<div style={{
|
||||
@@ -208,12 +212,12 @@ function KanbanColumn({
|
||||
gap: 'var(--mantine-spacing-xs)',
|
||||
}}>
|
||||
{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>
|
||||
) : (
|
||||
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>
|
||||
@@ -595,6 +599,7 @@ export function CapitalProjectsPage() {
|
||||
isDragOver={dragOverYear === year}
|
||||
onDragOverHandler={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
} from '@tabler/icons-react';
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
import { useAuthStore, useIsReadOnly } from '../../stores/authStore';
|
||||
import api from '../../services/api';
|
||||
|
||||
interface HealthScore {
|
||||
@@ -313,6 +313,7 @@ interface DashboardData {
|
||||
|
||||
export function DashboardPage() {
|
||||
const currentOrg = useAuthStore((s) => s.currentOrg);
|
||||
const isReadOnly = useIsReadOnly();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Track whether a refresh is in progress (per score type) for async polling
|
||||
@@ -426,7 +427,7 @@ export function DashboardPage() {
|
||||
</ThemeIcon>
|
||||
}
|
||||
isRefreshing={operatingRefreshing}
|
||||
onRefresh={handleRefreshOperating}
|
||||
onRefresh={!isReadOnly ? handleRefreshOperating : undefined}
|
||||
lastFailed={!!healthScores?.operating_last_failed}
|
||||
/>
|
||||
<HealthScoreCard
|
||||
@@ -438,7 +439,7 @@ export function DashboardPage() {
|
||||
</ThemeIcon>
|
||||
}
|
||||
isRefreshing={reserveRefreshing}
|
||||
onRefresh={handleRefreshReserve}
|
||||
onRefresh={!isReadOnly ? handleRefreshReserve : undefined}
|
||||
lastFailed={!!healthScores?.reserve_last_failed}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
@@ -43,6 +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';
|
||||
|
||||
// ── Types ──
|
||||
|
||||
@@ -384,6 +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();
|
||||
|
||||
// Load investment scenarios for the "Add to Plan" modal
|
||||
const { data: investmentScenarios } = useQuery<any[]>({
|
||||
@@ -821,15 +823,17 @@ export function InvestmentPlanningPage() {
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Button
|
||||
leftSection={<IconSparkles size={16} />}
|
||||
onClick={handleTriggerAI}
|
||||
loading={isProcessing}
|
||||
variant="gradient"
|
||||
gradient={{ from: 'grape', to: 'violet' }}
|
||||
>
|
||||
{aiResult ? 'Refresh Recommendations' : 'Get AI Recommendations'}
|
||||
</Button>
|
||||
{!isReadOnly && (
|
||||
<Button
|
||||
leftSection={<IconSparkles size={16} />}
|
||||
onClick={handleTriggerAI}
|
||||
loading={isProcessing}
|
||||
variant="gradient"
|
||||
gradient={{ from: 'grape', to: 'violet' }}
|
||||
>
|
||||
{aiResult ? 'Refresh Recommendations' : 'Get AI Recommendations'}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{/* 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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import api from '../../services/api';
|
||||
import { useIsReadOnly } from '../../stores/authStore';
|
||||
|
||||
interface Invoice {
|
||||
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 [previewLoading, setPreviewLoading] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
const isReadOnly = useIsReadOnly();
|
||||
|
||||
const { data: invoices = [], isLoading } = useQuery<Invoice[]>({
|
||||
queryKey: ['invoices'],
|
||||
@@ -124,10 +126,12 @@ export function InvoicesPage() {
|
||||
<Stack>
|
||||
<Group justify="space-between">
|
||||
<Title order={2}>Invoices</Title>
|
||||
<Group>
|
||||
<Button variant="outline" onClick={() => lateFeesMutation.mutate()} loading={lateFeesMutation.isPending}>Apply Late Fees</Button>
|
||||
<Button leftSection={<IconSend size={16} />} onClick={openBulk}>Generate Invoices</Button>
|
||||
</Group>
|
||||
{!isReadOnly && (
|
||||
<Group>
|
||||
<Button variant="outline" onClick={() => lateFeesMutation.mutate()} loading={lateFeesMutation.isPending}>Apply Late Fees</Button>
|
||||
<Button leftSection={<IconSend size={16} />} onClick={openBulk}>Generate Invoices</Button>
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
<Group>
|
||||
<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.
|
||||
|
||||
# Hide nginx version from Server header
|
||||
server_tokens off;
|
||||
|
||||
# --- Rate limiting ---
|
||||
# 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;
|
||||
@@ -49,6 +52,12 @@ server {
|
||||
ssl_session_timeout 10m;
|
||||
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_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
|
||||
@@ -8,6 +8,9 @@ upstream frontend {
|
||||
keepalive 16;
|
||||
}
|
||||
|
||||
# Hide nginx version from Server header
|
||||
server_tokens off;
|
||||
|
||||
# Shared proxy settings
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection ""; # enable keepalive to upstreams
|
||||
@@ -30,6 +33,12 @@ server {
|
||||
listen 80;
|
||||
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 ---
|
||||
location /api/ {
|
||||
limit_req zone=api_limit burst=30 nodelay;
|
||||
|
||||
Reference in New Issue
Block a user