initial: standalone repo from monorepo split

This commit is contained in:
kevin-asprec
2026-04-13 09:36:55 +08:00
commit 4a8f1e9318
157 changed files with 9198 additions and 0 deletions

View File

@@ -0,0 +1,62 @@
import {
Controller,
Post,
Get,
Body,
UseGuards,
Req,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { Throttle, SkipThrottle } from '@nestjs/throttler';
import { AuthService } from './auth.service';
import { LoginDto } from './dto/login.dto';
import { RegisterTenantDto } from './dto/register-tenant.dto';
import { RefreshTokenDto } from './dto/refresh-token.dto';
import { ChangePasswordDto } from './dto/change-password.dto';
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post('login')
@HttpCode(HttpStatus.OK)
@Throttle({ short: { ttl: 60000, limit: 50 } }) // 50 login attempts per minute per IP
async login(@Body() dto: LoginDto) {
return this.authService.login(dto);
}
@Post('register')
@Throttle({ short: { ttl: 60000, limit: 3 } }) // 3 registrations per minute
async register(@Body() dto: RegisterTenantDto) {
return this.authService.registerTenant(dto);
}
@Post('refresh')
@HttpCode(HttpStatus.OK)
async refresh(@Body() dto: RefreshTokenDto) {
return this.authService.refreshTokens(dto.refreshToken);
}
@Post('logout')
@UseGuards(AuthGuard('jwt'))
@HttpCode(HttpStatus.OK)
async logout(@Req() req: any) {
await this.authService.logout(req.user.sub);
return { message: 'Logged out successfully' };
}
@Get('profile')
@UseGuards(AuthGuard('jwt'))
async profile(@Req() req: any) {
return this.authService.getProfile(req.user.sub);
}
@Post('change-password')
@UseGuards(AuthGuard('jwt'))
@HttpCode(HttpStatus.OK)
async changePassword(@Req() req: any, @Body() dto: ChangePasswordDto) {
return this.authService.changePassword(req.user.sub, dto.currentPassword, dto.newPassword);
}
}

26
src/auth/auth.module.ts Normal file
View File

@@ -0,0 +1,26 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { ConfigService } from '@nestjs/config';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtStrategy } from './strategies/jwt.strategy';
@Module({
imports: [
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.registerAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
secret: config.get<string>('JWT_SECRET'),
signOptions: {
expiresIn: config.get<string>('JWT_EXPIRES_IN', '15m') as any,
},
}),
}),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy],
exports: [AuthService, JwtModule],
})
export class AuthModule {}

267
src/auth/auth.service.ts Normal file
View File

@@ -0,0 +1,267 @@
import {
Injectable,
UnauthorizedException,
ConflictException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import * as bcrypt from 'bcrypt';
import { PrismaService } from '../prisma/prisma.service';
import { JwtPayload, TokenResponse, getPermissionsForRoles } from '@fiberops/shared';
import { LoginDto } from './dto/login.dto';
import { RegisterTenantDto } from './dto/register-tenant.dto';
@Injectable()
export class AuthService {
constructor(
private readonly prisma: PrismaService,
private readonly jwt: JwtService,
private readonly config: ConfigService,
) {}
async login(dto: LoginDto): Promise<TokenResponse> {
const user = await this.prisma.user.findFirst({
where: { email: dto.email, isActive: true },
include: { roles: true, tenant: true },
});
if (!user) {
throw new UnauthorizedException('Invalid credentials');
}
// Super admin may not have a tenant; regular users must have active tenant
const isSuperAdmin = user.roles.some((r) => r.role === 'super_admin');
if (!isSuperAdmin && (!user.tenant || !user.tenant.isActive)) {
throw new UnauthorizedException('Invalid credentials');
}
const passwordValid = await bcrypt.compare(dto.password, user.password);
if (!passwordValid) {
throw new UnauthorizedException('Invalid credentials');
}
const roles = user.roles.map((r) => r.role);
const tokens = await this.generateTokens({
sub: user.id,
tenantId: user.tenantId,
roles,
permissions: getPermissionsForRoles(roles),
});
return {
...tokens,
mustChangePassword: user.mustChangePassword,
};
}
async registerTenant(dto: RegisterTenantDto) {
const existing = await this.prisma.tenant.findUnique({
where: { slug: dto.slug },
});
if (existing) {
throw new ConflictException('Tenant slug already taken');
}
const hashedPassword = await bcrypt.hash(dto.adminPassword, 12);
const tenant = await this.prisma.tenant.create({
data: {
name: dto.tenantName,
slug: dto.slug,
settings: {
companyName: dto.tenantName,
currency: 'PHP',
timezone: 'Asia/Manila',
},
users: {
create: {
email: dto.adminEmail,
password: hashedPassword,
firstName: dto.adminFirstName,
lastName: dto.adminLastName,
roles: {
create: { role: 'tenant_admin' },
},
},
},
},
include: {
users: {
include: { roles: true },
},
},
});
const admin = tenant.users[0]!;
const roles = admin.roles.map((r) => r.role);
const tokens = await this.generateTokens({
sub: admin.id,
tenantId: tenant.id,
roles,
permissions: getPermissionsForRoles(roles),
});
return {
tenant: {
id: tenant.id,
name: tenant.name,
slug: tenant.slug,
},
user: {
id: admin.id,
email: admin.email,
firstName: admin.firstName,
lastName: admin.lastName,
roles,
},
...tokens,
};
}
async refreshTokens(refreshToken: string): Promise<TokenResponse> {
const stored = await this.prisma.refreshToken.findUnique({
where: { token: refreshToken },
});
if (!stored || stored.expiresAt < new Date()) {
if (stored) {
await this.prisma.refreshToken.delete({ where: { id: stored.id } });
}
throw new UnauthorizedException('Invalid or expired refresh token');
}
// Delete the used refresh token (rotation)
await this.prisma.refreshToken.delete({ where: { id: stored.id } });
const user = await this.prisma.user.findUnique({
where: { id: stored.userId },
include: { roles: true, tenant: true },
});
if (!user || !user.isActive) {
throw new UnauthorizedException('Account is inactive');
}
const isSuperAdmin = user.roles.some((r) => r.role === 'super_admin');
if (!isSuperAdmin && (!user.tenant || !user.tenant.isActive)) {
throw new UnauthorizedException('Account is inactive');
}
const roles = user.roles.map((r) => r.role);
return this.generateTokens({
sub: user.id,
tenantId: user.tenantId,
roles,
permissions: getPermissionsForRoles(roles),
});
}
async logout(userId: string): Promise<void> {
await this.prisma.refreshToken.deleteMany({
where: { userId },
});
}
async getProfile(userId: string) {
const user = await this.prisma.user.findUnique({
where: { id: userId },
include: {
roles: true,
tenant: { select: { id: true, name: true, slug: true, settings: true } },
tenantRoles: {
include: {
tenantRole: {
include: { permissions: true },
},
},
},
},
});
if (!user) {
throw new UnauthorizedException('User not found');
}
const roles = user.roles.map((r) => r.role);
const isSuperAdmin = roles.includes('super_admin');
// Resolve permission matrix from tenant roles
const accessMap: Record<string, Record<string, boolean>> = {};
if (!isSuperAdmin) {
for (const utr of user.tenantRoles) {
if (!utr.tenantRole.isActive || utr.tenantRole.deletedAt) continue;
for (const p of utr.tenantRole.permissions) {
if (!accessMap[p.module]) {
accessMap[p.module] = { canView: false, canCreate: false, canUpdate: false, canArchive: false, canApprove: false, canExport: false };
}
if (p.canView) accessMap[p.module].canView = true;
if (p.canCreate) accessMap[p.module].canCreate = true;
if (p.canUpdate) accessMap[p.module].canUpdate = true;
if (p.canArchive) accessMap[p.module].canArchive = true;
if (p.canApprove) accessMap[p.module].canApprove = true;
if (p.canExport) accessMap[p.module].canExport = true;
}
}
}
return {
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
mustChangePassword: user.mustChangePassword,
roles,
permissions: getPermissionsForRoles(roles),
accessMap: isSuperAdmin ? 'all' : accessMap,
tenantRoles: user.tenantRoles.map((tr) => ({
id: tr.tenantRole.id,
name: tr.tenantRole.name,
slug: tr.tenantRole.slug,
})),
tenant: user.tenant,
};
}
async changePassword(userId: string, currentPassword: string, newPassword: string) {
const user = await this.prisma.user.findUnique({ where: { id: userId } });
if (!user) throw new UnauthorizedException('User not found');
const valid = await bcrypt.compare(currentPassword, user.password);
if (!valid) throw new UnauthorizedException('Current password is incorrect');
const hashed = await bcrypt.hash(newPassword, 12);
await this.prisma.user.update({
where: { id: userId },
data: { password: hashed, mustChangePassword: false },
});
return { success: true };
}
private async generateTokens(payload: JwtPayload): Promise<TokenResponse> {
const jti = crypto.randomUUID();
const accessToken = this.jwt.sign({ ...payload, jti });
const refreshJti = crypto.randomUUID();
const refreshToken = this.jwt.sign({ ...payload, jti: refreshJti }, {
secret: this.config.get<string>('JWT_REFRESH_SECRET'),
expiresIn: this.config.get<string>('JWT_REFRESH_EXPIRES_IN', '7d') as any,
});
const expiresIn = this.config.get<string>('JWT_REFRESH_EXPIRES_IN', '7d');
const expiresAt = new Date();
const days = parseInt(expiresIn) || 7;
expiresAt.setDate(expiresAt.getDate() + days);
await this.prisma.refreshToken.create({
data: {
token: refreshToken,
userId: payload.sub,
expiresAt,
},
});
return { accessToken, refreshToken };
}
}

View File

@@ -0,0 +1,10 @@
import { IsString, MinLength } from 'class-validator';
export class ChangePasswordDto {
@IsString()
currentPassword: string;
@IsString()
@MinLength(8)
newPassword: string;
}

10
src/auth/dto/login.dto.ts Normal file
View File

@@ -0,0 +1,10 @@
import { IsEmail, IsString, MinLength } from 'class-validator';
export class LoginDto {
@IsEmail()
email: string;
@IsString()
@MinLength(8)
password: string;
}

View File

@@ -0,0 +1,6 @@
import { IsString } from 'class-validator';
export class RefreshTokenDto {
@IsString()
refreshToken: string;
}

View File

@@ -0,0 +1,30 @@
import { IsEmail, IsString, MinLength, MaxLength, Matches } from 'class-validator';
export class RegisterTenantDto {
@IsString()
@MinLength(2)
tenantName: string;
@IsString()
@MinLength(2)
@MaxLength(50)
@Matches(/^[a-z0-9-]+$/, {
message: 'Slug must contain only lowercase letters, numbers, and hyphens',
})
slug: string;
@IsEmail()
adminEmail: string;
@IsString()
@MinLength(8)
adminPassword: string;
@IsString()
@MinLength(1)
adminFirstName: string;
@IsString()
@MinLength(1)
adminLastName: string;
}

View File

@@ -0,0 +1,25 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { JwtPayload } from '@fiberops/shared';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(config: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: config.get<string>('JWT_SECRET') || 'fallback',
});
}
validate(payload: JwtPayload) {
return {
sub: payload.sub,
tenantId: payload.tenantId || null,
roles: payload.roles || [],
permissions: payload.permissions || [],
};
}
}