Initial commit: HOA Financial Intelligence Platform MVP
Multi-tenant financial management platform for homeowner associations featuring: - NestJS backend with 16 modules (auth, accounts, transactions, budgets, units, invoices, payments, vendors, reserves, investments, capital projects, reports) - React + Mantine frontend with dashboard, CRUD pages, and financial reports - Schema-per-tenant PostgreSQL isolation with JWT-based tenant resolution - Docker Compose infrastructure (nginx, backend, frontend, postgres, redis) - Comprehensive seed data for Sunrise Valley HOA demo - 39 API endpoints with Swagger documentation - Double-entry bookkeeping with journal entries - Budget vs actual reporting and Sankey cash flow visualization Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
50
backend/src/modules/auth/auth.controller.ts
Normal file
50
backend/src/modules/auth/auth.controller.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Body,
|
||||
UseGuards,
|
||||
Request,
|
||||
Get,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { AuthService } from './auth.service';
|
||||
import { RegisterDto } from './dto/register.dto';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { SwitchOrgDto } from './dto/switch-org.dto';
|
||||
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||
|
||||
@ApiTags('auth')
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private authService: AuthService) {}
|
||||
|
||||
@Post('register')
|
||||
@ApiOperation({ summary: 'Register a new user' })
|
||||
async register(@Body() dto: RegisterDto) {
|
||||
return this.authService.register(dto);
|
||||
}
|
||||
|
||||
@Post('login')
|
||||
@ApiOperation({ summary: 'Login with email and password' })
|
||||
@UseGuards(AuthGuard('local'))
|
||||
async login(@Request() req: any, @Body() _dto: LoginDto) {
|
||||
return this.authService.login(req.user);
|
||||
}
|
||||
|
||||
@Get('profile')
|
||||
@ApiOperation({ summary: 'Get current user profile' })
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async getProfile(@Request() req: any) {
|
||||
return this.authService.getProfile(req.user.sub);
|
||||
}
|
||||
|
||||
@Post('switch-org')
|
||||
@ApiOperation({ summary: 'Switch active organization' })
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async switchOrg(@Request() req: any, @Body() dto: SwitchOrgDto) {
|
||||
return this.authService.switchOrganization(req.user.sub, dto.organizationId);
|
||||
}
|
||||
}
|
||||
28
backend/src/modules/auth/auth.module.ts
Normal file
28
backend/src/modules/auth/auth.module.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||
import { LocalStrategy } from './strategies/local.strategy';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
UsersModule,
|
||||
PassportModule,
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (configService: ConfigService) => ({
|
||||
secret: configService.get<string>('JWT_SECRET'),
|
||||
signOptions: { expiresIn: '24h' },
|
||||
}),
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, JwtStrategy, LocalStrategy],
|
||||
exports: [AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
136
backend/src/modules/auth/auth.service.ts
Normal file
136
backend/src/modules/auth/auth.service.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import {
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
ConflictException,
|
||||
} from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import { RegisterDto } from './dto/register.dto';
|
||||
import { User } from '../users/entities/user.entity';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private usersService: UsersService,
|
||||
private jwtService: JwtService,
|
||||
) {}
|
||||
|
||||
async register(dto: RegisterDto) {
|
||||
const existing = await this.usersService.findByEmail(dto.email);
|
||||
if (existing) {
|
||||
throw new ConflictException('Email already registered');
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(dto.password, 12);
|
||||
const user = await this.usersService.create({
|
||||
email: dto.email,
|
||||
passwordHash,
|
||||
firstName: dto.firstName,
|
||||
lastName: dto.lastName,
|
||||
});
|
||||
|
||||
return this.generateTokenResponse(user);
|
||||
}
|
||||
|
||||
async validateUser(email: string, password: string): Promise<User> {
|
||||
const user = await this.usersService.findByEmail(email);
|
||||
if (!user || !user.passwordHash) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
|
||||
const isValid = await bcrypt.compare(password, user.passwordHash);
|
||||
if (!isValid) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
async login(user: User) {
|
||||
await this.usersService.updateLastLogin(user.id);
|
||||
const fullUser = await this.usersService.findByIdWithOrgs(user.id);
|
||||
return this.generateTokenResponse(fullUser || user);
|
||||
}
|
||||
|
||||
async getProfile(userId: string) {
|
||||
const user = await this.usersService.findByIdWithOrgs(userId);
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('User not found');
|
||||
}
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
organizations: user.userOrganizations?.map((uo) => ({
|
||||
id: uo.organization.id,
|
||||
name: uo.organization.name,
|
||||
role: uo.role,
|
||||
})) || [],
|
||||
};
|
||||
}
|
||||
|
||||
async switchOrganization(userId: string, organizationId: string) {
|
||||
const user = await this.usersService.findByIdWithOrgs(userId);
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('User not found');
|
||||
}
|
||||
|
||||
const membership = user.userOrganizations?.find(
|
||||
(uo) => uo.organizationId === organizationId && uo.isActive,
|
||||
);
|
||||
if (!membership) {
|
||||
throw new UnauthorizedException('Not a member of this organization');
|
||||
}
|
||||
|
||||
const payload = {
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
orgId: membership.organizationId,
|
||||
orgSchema: membership.organization.schemaName,
|
||||
role: membership.role,
|
||||
};
|
||||
|
||||
return {
|
||||
accessToken: this.jwtService.sign(payload),
|
||||
organization: {
|
||||
id: membership.organization.id,
|
||||
name: membership.organization.name,
|
||||
role: membership.role,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private generateTokenResponse(user: User) {
|
||||
const orgs = user.userOrganizations || [];
|
||||
const defaultOrg = orgs[0];
|
||||
|
||||
const payload: Record<string, any> = {
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
};
|
||||
|
||||
if (defaultOrg) {
|
||||
payload.orgId = defaultOrg.organizationId;
|
||||
payload.orgSchema = defaultOrg.organization?.schemaName;
|
||||
payload.role = defaultOrg.role;
|
||||
}
|
||||
|
||||
return {
|
||||
accessToken: this.jwtService.sign(payload),
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
},
|
||||
organizations: orgs.map((uo) => ({
|
||||
id: uo.organizationId,
|
||||
name: uo.organization?.name,
|
||||
schemaName: uo.organization?.schemaName,
|
||||
role: uo.role,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
12
backend/src/modules/auth/dto/login.dto.ts
Normal file
12
backend/src/modules/auth/dto/login.dto.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { IsEmail, IsString } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class LoginDto {
|
||||
@ApiProperty({ example: 'treasurer@sunrisevalley.org' })
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@ApiProperty({ example: 'SecurePass123!' })
|
||||
@IsString()
|
||||
password: string;
|
||||
}
|
||||
23
backend/src/modules/auth/dto/register.dto.ts
Normal file
23
backend/src/modules/auth/dto/register.dto.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { IsEmail, IsString, MinLength, IsOptional } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class RegisterDto {
|
||||
@ApiProperty({ example: 'treasurer@sunrisevalley.org' })
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@ApiProperty({ example: 'SecurePass123!' })
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
password: string;
|
||||
|
||||
@ApiProperty({ example: 'Jane', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
firstName?: string;
|
||||
|
||||
@ApiProperty({ example: 'Doe', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
lastName?: string;
|
||||
}
|
||||
8
backend/src/modules/auth/dto/switch-org.dto.ts
Normal file
8
backend/src/modules/auth/dto/switch-org.dto.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { IsUUID } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class SwitchOrgDto {
|
||||
@ApiProperty({ example: '550e8400-e29b-41d4-a716-446655440000' })
|
||||
@IsUUID()
|
||||
organizationId: string;
|
||||
}
|
||||
5
backend/src/modules/auth/guards/jwt-auth.guard.ts
Normal file
5
backend/src/modules/auth/guards/jwt-auth.guard.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {}
|
||||
25
backend/src/modules/auth/strategies/jwt.strategy.ts
Normal file
25
backend/src/modules/auth/strategies/jwt.strategy.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(configService: ConfigService) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: configService.get<string>('JWT_SECRET'),
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: any) {
|
||||
return {
|
||||
sub: payload.sub,
|
||||
email: payload.email,
|
||||
orgId: payload.orgId,
|
||||
orgSchema: payload.orgSchema,
|
||||
role: payload.role,
|
||||
};
|
||||
}
|
||||
}
|
||||
15
backend/src/modules/auth/strategies/local.strategy.ts
Normal file
15
backend/src/modules/auth/strategies/local.strategy.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { Strategy } from 'passport-local';
|
||||
import { AuthService } from '../auth.service';
|
||||
|
||||
@Injectable()
|
||||
export class LocalStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(private authService: AuthService) {
|
||||
super({ usernameField: 'email' });
|
||||
}
|
||||
|
||||
async validate(email: string, password: string) {
|
||||
return this.authService.validateUser(email, password);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user