feat: Implement comprehensive user authentication with JWT, social login, and password management.

This commit is contained in:
pradeepkumar
2025-12-20 19:22:30 +05:30
parent 1812fd3c98
commit 158a617600
26 changed files with 1608 additions and 4 deletions

View File

@@ -1,9 +1,11 @@
import { Controller, Get } from '@nestjs/common'; import { Controller, Get } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { AppService } from './app.service'; import { AppService } from './app.service';
import { Public } from './auth';
@ApiTags('Health') @ApiTags('Health')
@Controller() @Controller()
@Public()
export class AppController { export class AppController {
constructor(private readonly appService: AppService) {} constructor(private readonly appService: AppService) {}

View File

@@ -7,6 +7,8 @@ import { EventEmitterModule } from '@nestjs/event-emitter';
import { configuration } from './config'; import { configuration } from './config';
import { PrismaModule } from './prisma'; import { PrismaModule } from './prisma';
import { AuthModule, JwtAuthGuard, RolesGuard } from './auth';
import { UsersModule } from './users';
import { AppController } from './app.controller'; import { AppController } from './app.controller';
import { AppService } from './app.service'; import { AppService } from './app.service';
@@ -36,10 +38,9 @@ import { AppService } from './app.service';
// Database // Database
PrismaModule, PrismaModule,
// Feature Modules (will be added as we develop them) // Feature Modules
// AuthModule, AuthModule,
// UsersModule, UsersModule,
// AgentsModule,
], ],
controllers: [AppController], controllers: [AppController],
providers: [ providers: [
@@ -49,6 +50,16 @@ import { AppService } from './app.service';
provide: APP_GUARD, provide: APP_GUARD,
useClass: ThrottlerGuard, useClass: ThrottlerGuard,
}, },
// Global JWT Auth Guard
{
provide: APP_GUARD,
useClass: JwtAuthGuard,
},
// Global Roles Guard
{
provide: APP_GUARD,
useClass: RolesGuard,
},
], ],
}) })
export class AppModule {} export class AppModule {}

308
src/auth/auth.controller.ts Normal file
View File

@@ -0,0 +1,308 @@
import {
Controller,
Post,
Get,
Body,
UseGuards,
Req,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiBody,
} from '@nestjs/swagger';
import type { Request } from 'express';
import { AuthService } from './auth.service';
import {
RegisterDto,
LoginDto,
SocialAuthDto,
ForgotPasswordDto,
ResetPasswordDto,
RefreshTokenDto,
} from './dto';
import { JwtAuthGuard } from './guards';
import { Public, CurrentUser } from './decorators';
@ApiTags('Auth')
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
// ==========================================
// REGISTER
// ==========================================
@Public()
@Post('register')
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'Register a new user' })
@ApiBody({ type: RegisterDto })
@ApiResponse({
status: 201,
description: 'User registered successfully',
schema: {
example: {
success: true,
data: {
user: {
id: 'uuid',
email: 'john@example.com',
role: 'USER',
firstName: 'John',
lastName: 'Doe',
},
accessToken: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
refreshToken: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
},
},
},
})
@ApiResponse({ status: 400, description: 'Validation error' })
@ApiResponse({ status: 409, description: 'Email already registered' })
async register(@Body() dto: RegisterDto) {
return this.authService.register(dto);
}
// ==========================================
// LOGIN
// ==========================================
@Public()
@Post('login')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Login with email and password' })
@ApiBody({ type: LoginDto })
@ApiResponse({
status: 200,
description: 'Login successful',
schema: {
example: {
success: true,
data: {
user: {
id: 'uuid',
email: 'john@example.com',
role: 'USER',
firstName: 'John',
lastName: 'Doe',
avatar: null,
},
accessToken: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
refreshToken: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
},
},
},
})
@ApiResponse({ status: 401, description: 'Invalid credentials' })
async login(@Body() dto: LoginDto, @Req() req: Request) {
const userAgent = req.headers['user-agent'];
const ipAddress =
req.ip || req.headers['x-forwarded-for']?.toString() || 'unknown';
return this.authService.login(dto, userAgent, ipAddress);
}
// ==========================================
// SOCIAL AUTH
// ==========================================
@Public()
@Post('social')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Login or register with social provider' })
@ApiBody({ type: SocialAuthDto })
@ApiResponse({
status: 200,
description: 'Social auth successful',
schema: {
example: {
success: true,
data: {
user: {
id: 'uuid',
email: 'john@example.com',
role: 'USER',
firstName: 'John',
lastName: 'Doe',
avatar: 'https://example.com/avatar.jpg',
},
accessToken: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
refreshToken: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
},
},
},
})
@ApiResponse({ status: 400, description: 'Invalid provider data' })
async socialAuth(@Body() dto: SocialAuthDto) {
return this.authService.socialAuth(dto);
}
// ==========================================
// FORGOT PASSWORD
// ==========================================
@Public()
@Post('forgot-password')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Request password reset email' })
@ApiBody({ type: ForgotPasswordDto })
@ApiResponse({
status: 200,
description: 'Password reset email sent (if email exists)',
schema: {
example: {
success: true,
data: {
message: 'If the email exists, a password reset link will be sent',
},
},
},
})
async forgotPassword(@Body() dto: ForgotPasswordDto) {
return this.authService.forgotPassword(dto);
}
// ==========================================
// RESET PASSWORD
// ==========================================
@Public()
@Post('reset-password')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Reset password with token' })
@ApiBody({ type: ResetPasswordDto })
@ApiResponse({
status: 200,
description: 'Password reset successful',
schema: {
example: {
success: true,
data: {
message: 'Password reset successful. Please login with your new password.',
},
},
},
})
@ApiResponse({ status: 400, description: 'Invalid or expired token' })
async resetPassword(@Body() dto: ResetPasswordDto) {
return this.authService.resetPassword(dto);
}
// ==========================================
// REFRESH TOKEN
// ==========================================
@Public()
@Post('refresh')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Refresh access token' })
@ApiBody({ type: RefreshTokenDto })
@ApiResponse({
status: 200,
description: 'Tokens refreshed successfully',
schema: {
example: {
success: true,
data: {
accessToken: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
refreshToken: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
},
},
},
})
@ApiResponse({ status: 401, description: 'Invalid refresh token' })
async refreshToken(@Body() dto: RefreshTokenDto) {
return this.authService.refreshToken(dto);
}
// ==========================================
// GET CURRENT USER
// ==========================================
@Get('me')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Get current user profile' })
@ApiResponse({
status: 200,
description: 'Current user profile',
schema: {
example: {
success: true,
data: {
id: 'uuid',
email: 'john@example.com',
role: 'USER',
status: 'ACTIVE',
emailVerified: true,
authProvider: 'LOCAL',
firstName: 'John',
lastName: 'Doe',
avatar: null,
phone: null,
city: null,
state: null,
country: null,
createdAt: '2024-01-01T00:00:00.000Z',
lastLoginAt: '2024-01-01T00:00:00.000Z',
},
},
},
})
@ApiResponse({ status: 401, description: 'Unauthorized' })
async getCurrentUser(@CurrentUser('id') userId: string) {
return this.authService.getCurrentUser(userId);
}
// ==========================================
// LOGOUT
// ==========================================
@Post('logout')
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Logout current session' })
@ApiResponse({
status: 200,
description: 'Logged out successfully',
schema: {
example: {
success: true,
data: {
message: 'Logged out successfully',
},
},
},
})
@ApiResponse({ status: 401, description: 'Unauthorized' })
async logout(@CurrentUser('id') userId: string, @Req() req: Request) {
const authHeader = req.headers.authorization;
const accessToken = authHeader?.replace('Bearer ', '');
return this.authService.logout(userId, accessToken);
}
// ==========================================
// LOGOUT ALL SESSIONS
// ==========================================
@Post('logout-all')
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Logout all sessions' })
@ApiResponse({
status: 200,
description: 'All sessions logged out',
schema: {
example: {
success: true,
data: {
message: 'Logged out successfully',
},
},
},
})
@ApiResponse({ status: 401, description: 'Unauthorized' })
async logoutAll(@CurrentUser('id') userId: string) {
return this.authService.logout(userId);
}
}

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

@@ -0,0 +1,33 @@
import { Module } from '@nestjs/common';
import { JwtModule, JwtModuleOptions } 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';
import { PrismaModule } from '../prisma';
@Module({
imports: [
PrismaModule,
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (configService: ConfigService): JwtModuleOptions => {
const expiresIn = configService.get<string>('jwt.accessExpiration') ?? '15m';
return {
secret: configService.get<string>('jwt.secret'),
signOptions: {
expiresIn: expiresIn as `${number}${'s' | 'm' | 'h' | 'd'}`,
},
};
},
}),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy],
exports: [AuthService, JwtModule, PassportModule],
})
export class AuthModule {}

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

@@ -0,0 +1,657 @@
import {
Injectable,
UnauthorizedException,
ConflictException,
BadRequestException,
NotFoundException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { EventEmitter2 } from '@nestjs/event-emitter';
import * as argon2 from 'argon2';
import slugify from 'slugify';
import { PrismaService } from '../prisma/prisma.service';
import {
RegisterDto,
LoginDto,
SocialAuthDto,
ForgotPasswordDto,
ResetPasswordDto,
RefreshTokenDto,
} from './dto';
import { UserRole, AuthProvider, UserStatus } from '@prisma/client';
@Injectable()
export class AuthService {
constructor(
private readonly prisma: PrismaService,
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
private readonly eventEmitter: EventEmitter2,
) {}
// ==========================================
// REGISTER
// ==========================================
async register(dto: RegisterDto) {
// Check if user exists
const existingUser = await this.prisma.user.findUnique({
where: { email: dto.email.toLowerCase() },
});
if (existingUser) {
throw new ConflictException('Email already registered');
}
// Hash password
const hashedPassword = await argon2.hash(dto.password);
// Determine role
const role = dto.role === 'AGENT' ? UserRole.AGENT : UserRole.USER;
// Create user
const user = await this.prisma.user.create({
data: {
email: dto.email.toLowerCase(),
password: hashedPassword,
role,
status: UserStatus.ACTIVE,
authProvider: AuthProvider.LOCAL,
},
});
// Create profile based on role
if (role === UserRole.USER) {
await this.prisma.userProfile.create({
data: {
userId: user.id,
firstName: dto.firstName,
lastName: dto.lastName,
},
});
} else if (role === UserRole.AGENT) {
const slug = await this.generateUniqueSlug(dto.firstName, dto.lastName);
await this.prisma.agentProfile.create({
data: {
userId: user.id,
slug,
firstName: dto.firstName,
lastName: dto.lastName,
},
});
}
// Generate tokens
const tokens = await this.generateTokens(user.id, user.email, user.role);
// Create session
await this.createSession(user.id, tokens.accessToken, tokens.refreshToken);
// Emit event for email verification (can be handled by email service)
this.eventEmitter.emit('user.registered', {
userId: user.id,
email: user.email,
role: user.role,
});
return {
user: {
id: user.id,
email: user.email,
role: user.role,
firstName: dto.firstName,
lastName: dto.lastName,
},
...tokens,
};
}
// ==========================================
// LOGIN
// ==========================================
async login(dto: LoginDto, userAgent?: string, ipAddress?: string) {
const user = await this.prisma.user.findUnique({
where: { email: dto.email.toLowerCase() },
include: {
userProfile: true,
agentProfile: true,
},
});
if (!user) {
throw new UnauthorizedException('Invalid email or password');
}
if (!user.password) {
throw new UnauthorizedException(
'This account uses social login. Please sign in with Google or Facebook.',
);
}
if (user.status !== UserStatus.ACTIVE) {
throw new UnauthorizedException('Account is not active');
}
// Verify password
const isPasswordValid = await argon2.verify(user.password, dto.password);
if (!isPasswordValid) {
throw new UnauthorizedException('Invalid email or password');
}
// Generate tokens
const tokens = await this.generateTokens(user.id, user.email, user.role);
// Create session
await this.createSession(
user.id,
tokens.accessToken,
tokens.refreshToken,
userAgent,
ipAddress,
);
// Update last login
await this.prisma.user.update({
where: { id: user.id },
data: { lastLoginAt: new Date() },
});
// Get profile based on role
const profile =
user.role === UserRole.AGENT ? user.agentProfile : user.userProfile;
return {
user: {
id: user.id,
email: user.email,
role: user.role,
firstName: profile?.firstName,
lastName: profile?.lastName,
avatar: profile?.avatar,
},
...tokens,
};
}
// ==========================================
// SOCIAL AUTH
// ==========================================
async socialAuth(dto: SocialAuthDto) {
const providerIdField =
dto.provider === 'google' ? 'googleId' : 'facebookId';
// Check if user exists with this provider ID
let user = await this.prisma.user.findFirst({
where: { [providerIdField]: dto.providerId },
include: {
userProfile: true,
agentProfile: true,
},
});
if (!user) {
// Check if user exists with this email
user = await this.prisma.user.findUnique({
where: { email: dto.email.toLowerCase() },
include: {
userProfile: true,
agentProfile: true,
},
});
if (user) {
// Link provider to existing account
await this.prisma.user.update({
where: { id: user.id },
data: { [providerIdField]: dto.providerId },
});
} else {
// Create new user
const role = dto.role === 'AGENT' ? UserRole.AGENT : UserRole.USER;
const authProvider =
dto.provider === 'google'
? AuthProvider.GOOGLE
: AuthProvider.FACEBOOK;
// Parse name
const nameParts = dto.name?.split(' ') || [];
const firstName = nameParts[0] || '';
const lastName = nameParts.slice(1).join(' ') || '';
user = await this.prisma.user.create({
data: {
email: dto.email.toLowerCase(),
role,
status: UserStatus.ACTIVE,
emailVerified: true,
emailVerifiedAt: new Date(),
authProvider,
[providerIdField]: dto.providerId,
},
include: {
userProfile: true,
agentProfile: true,
},
});
// Create profile based on role
if (role === UserRole.USER) {
await this.prisma.userProfile.create({
data: {
userId: user.id,
firstName,
lastName,
avatar: dto.avatar,
},
});
} else if (role === UserRole.AGENT) {
const slug = await this.generateUniqueSlug(firstName, lastName);
await this.prisma.agentProfile.create({
data: {
userId: user.id,
slug,
firstName,
lastName,
avatar: dto.avatar,
},
});
}
// Refetch user with profile
user = await this.prisma.user.findUnique({
where: { id: user.id },
include: {
userProfile: true,
agentProfile: true,
},
});
}
}
if (!user) {
throw new BadRequestException('Failed to create or find user');
}
if (user.status !== UserStatus.ACTIVE) {
throw new UnauthorizedException('Account is not active');
}
// Generate tokens
const tokens = await this.generateTokens(user.id, user.email, user.role);
// Create session
await this.createSession(user.id, tokens.accessToken, tokens.refreshToken);
// Update last login
await this.prisma.user.update({
where: { id: user.id },
data: { lastLoginAt: new Date() },
});
const profile =
user.role === UserRole.AGENT ? user.agentProfile : user.userProfile;
return {
user: {
id: user.id,
email: user.email,
role: user.role,
firstName: profile?.firstName,
lastName: profile?.lastName,
avatar: profile?.avatar,
},
...tokens,
};
}
// ==========================================
// FORGOT PASSWORD
// ==========================================
async forgotPassword(dto: ForgotPasswordDto) {
const user = await this.prisma.user.findUnique({
where: { email: dto.email.toLowerCase() },
});
// Always return success to prevent email enumeration
if (!user) {
return {
message: 'If the email exists, a password reset link will be sent',
};
}
if (!user.password) {
return {
message: 'If the email exists, a password reset link will be sent',
};
}
// Generate reset token (short-lived JWT)
const resetToken = this.jwtService.sign(
{ sub: user.id, email: user.email, type: 'password-reset' },
{
secret: this.configService.get<string>('jwt.secret'),
expiresIn: '1h',
},
);
// Emit event for email service
this.eventEmitter.emit('password.reset-requested', {
userId: user.id,
email: user.email,
resetToken,
resetUrl: `${this.configService.get<string>('app.frontendUrl')}/reset-password?token=${resetToken}`,
});
return {
message: 'If the email exists, a password reset link will be sent',
};
}
// ==========================================
// RESET PASSWORD
// ==========================================
async resetPassword(dto: ResetPasswordDto) {
try {
const payload = this.jwtService.verify(dto.token, {
secret: this.configService.get<string>('jwt.secret'),
});
if (payload.type !== 'password-reset') {
throw new BadRequestException('Invalid reset token');
}
const user = await this.prisma.user.findUnique({
where: { id: payload.sub },
});
if (!user) {
throw new NotFoundException('User not found');
}
// Hash new password
const hashedPassword = await argon2.hash(dto.password);
// Update password
await this.prisma.user.update({
where: { id: user.id },
data: { password: hashedPassword },
});
// Invalidate all existing sessions
await this.prisma.session.deleteMany({
where: { userId: user.id },
});
// Emit event
this.eventEmitter.emit('password.reset-completed', {
userId: user.id,
email: user.email,
});
return {
message: 'Password reset successful. Please login with your new password.',
};
} catch (error) {
if (error.name === 'TokenExpiredError') {
throw new BadRequestException('Reset token has expired');
}
if (error.name === 'JsonWebTokenError') {
throw new BadRequestException('Invalid reset token');
}
throw error;
}
}
// ==========================================
// REFRESH TOKEN
// ==========================================
async refreshToken(dto: RefreshTokenDto) {
try {
const payload = this.jwtService.verify(dto.refreshToken, {
secret: this.configService.get<string>('jwt.secret'),
});
// Check if session exists
const session = await this.prisma.session.findFirst({
where: {
userId: payload.sub,
refreshToken: dto.refreshToken,
},
});
if (!session) {
throw new UnauthorizedException('Invalid refresh token');
}
// Get user
const user = await this.prisma.user.findUnique({
where: { id: payload.sub },
});
if (!user || user.status !== UserStatus.ACTIVE) {
throw new UnauthorizedException('User not found or inactive');
}
// Generate new tokens
const tokens = await this.generateTokens(user.id, user.email, user.role);
// Update session
await this.prisma.session.update({
where: { id: session.id },
data: {
token: tokens.accessToken,
refreshToken: tokens.refreshToken,
expiresAt: new Date(
Date.now() +
this.parseExpiration(
this.configService.get<string>('jwt.refreshExpiration') || '7d',
),
),
},
});
return tokens;
} catch (error) {
if (
error.name === 'TokenExpiredError' ||
error.name === 'JsonWebTokenError'
) {
throw new UnauthorizedException('Invalid or expired refresh token');
}
throw error;
}
}
// ==========================================
// LOGOUT
// ==========================================
async logout(userId: string, accessToken?: string) {
if (accessToken) {
// Delete specific session
await this.prisma.session.deleteMany({
where: {
userId,
token: accessToken,
},
});
} else {
// Delete all sessions for user
await this.prisma.session.deleteMany({
where: { userId },
});
}
return { message: 'Logged out successfully' };
}
// ==========================================
// GET CURRENT USER
// ==========================================
async getCurrentUser(userId: string) {
const user = await this.prisma.user.findUnique({
where: { id: userId },
include: {
userProfile: true,
agentProfile: true,
},
});
if (!user) {
throw new NotFoundException('User not found');
}
const profile =
user.role === UserRole.AGENT ? user.agentProfile : user.userProfile;
return {
id: user.id,
email: user.email,
role: user.role,
status: user.status,
emailVerified: user.emailVerified,
authProvider: user.authProvider,
firstName: profile?.firstName,
lastName: profile?.lastName,
avatar: profile?.avatar,
phone: profile?.phone,
city: profile?.city,
state: profile?.state,
country: profile?.country,
...(user.role === UserRole.AGENT && user.agentProfile
? {
slug: user.agentProfile.slug,
bio: user.agentProfile.bio,
headline: user.agentProfile.headline,
yearsOfExperience: user.agentProfile.yearsOfExperience,
licenseNumber: user.agentProfile.licenseNumber,
companyName: user.agentProfile.companyName,
isProfileComplete: user.agentProfile.isProfileComplete,
profileCompleteness: user.agentProfile.profileCompleteness,
}
: {}),
createdAt: user.createdAt,
lastLoginAt: user.lastLoginAt,
};
}
// ==========================================
// HELPER METHODS
// ==========================================
private async generateTokens(userId: string, email: string, role: UserRole) {
const payload = { sub: userId, email, role };
const secret = this.configService.get<string>('jwt.secret');
const accessExpiresIn = this.parseExpirationToSeconds(
this.configService.get<string>('jwt.accessExpiration') || '15m',
);
const refreshExpiresIn = this.parseExpirationToSeconds(
this.configService.get<string>('jwt.refreshExpiration') || '7d',
);
const accessToken = this.jwtService.sign(payload, {
secret,
expiresIn: accessExpiresIn,
});
const refreshToken = this.jwtService.sign(payload, {
secret,
expiresIn: refreshExpiresIn,
});
return { accessToken, refreshToken };
}
private parseExpirationToSeconds(expiration: string): number {
const match = expiration.match(/^(\d+)([smhd])$/);
if (!match) return 900; // Default 15 minutes
const value = parseInt(match[1], 10);
const unit = match[2];
switch (unit) {
case 's':
return value;
case 'm':
return value * 60;
case 'h':
return value * 60 * 60;
case 'd':
return value * 24 * 60 * 60;
default:
return 900;
}
}
private async createSession(
userId: string,
accessToken: string,
refreshToken: string,
userAgent?: string,
ipAddress?: string,
) {
const expiresAt = new Date(
Date.now() +
this.parseExpiration(
this.configService.get<string>('jwt.refreshExpiration') || '7d',
),
);
await this.prisma.session.create({
data: {
userId,
token: accessToken,
refreshToken,
userAgent,
ipAddress,
expiresAt,
},
});
}
private parseExpiration(expiration: string): number {
const match = expiration.match(/^(\d+)([smhd])$/);
if (!match) return 7 * 24 * 60 * 60 * 1000; // Default 7 days
const value = parseInt(match[1], 10);
const unit = match[2];
switch (unit) {
case 's':
return value * 1000;
case 'm':
return value * 60 * 1000;
case 'h':
return value * 60 * 60 * 1000;
case 'd':
return value * 24 * 60 * 60 * 1000;
default:
return 7 * 24 * 60 * 60 * 1000;
}
}
private async generateUniqueSlug(
firstName?: string,
lastName?: string,
): Promise<string> {
const baseName = `${firstName || ''} ${lastName || ''}`.trim() || 'agent';
let slug = slugify(baseName, { lower: true, strict: true });
// Check if slug exists
let counter = 0;
let uniqueSlug = slug;
while (true) {
const existing = await this.prisma.agentProfile.findUnique({
where: { slug: uniqueSlug },
});
if (!existing) break;
counter++;
uniqueSlug = `${slug}-${counter}`;
}
return uniqueSlug;
}
}

View File

@@ -0,0 +1,14 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export const CurrentUser = createParamDecorator(
(data: string | undefined, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
const user = request.user;
if (!user) {
return null;
}
return data ? user[data] : user;
},
);

View File

@@ -0,0 +1,3 @@
export * from './public.decorator';
export * from './roles.decorator';
export * from './current-user.decorator';

View File

@@ -0,0 +1,4 @@
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

View File

@@ -0,0 +1,5 @@
import { SetMetadata } from '@nestjs/common';
import { UserRole } from '@prisma/client';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles);

View File

@@ -0,0 +1,11 @@
import { IsEmail } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class ForgotPasswordDto {
@ApiProperty({
example: 'john@example.com',
description: 'Email address to send password reset link',
})
@IsEmail({}, { message: 'Please provide a valid email address' })
email: string;
}

6
src/auth/dto/index.ts Normal file
View File

@@ -0,0 +1,6 @@
export * from './register.dto';
export * from './login.dto';
export * from './social-auth.dto';
export * from './forgot-password.dto';
export * from './reset-password.dto';
export * from './refresh-token.dto';

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

@@ -0,0 +1,19 @@
import { IsEmail, IsString, MinLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class LoginDto {
@ApiProperty({
example: 'john@example.com',
description: 'User email address',
})
@IsEmail({}, { message: 'Please provide a valid email address' })
email: string;
@ApiProperty({
example: 'Password@123',
description: 'User password',
})
@IsString()
@MinLength(1, { message: 'Password is required' })
password: string;
}

View File

@@ -0,0 +1,11 @@
import { IsString } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class RefreshTokenDto {
@ApiProperty({
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
description: 'Refresh token',
})
@IsString()
refreshToken: string;
}

View File

@@ -0,0 +1,65 @@
import {
IsEmail,
IsString,
MinLength,
MaxLength,
Matches,
IsEnum,
IsOptional,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { UserRole } from '@prisma/client';
export class RegisterDto {
@ApiProperty({
example: 'john@example.com',
description: 'User email address',
})
@IsEmail({}, { message: 'Please provide a valid email address' })
email: string;
@ApiProperty({
example: 'Password@123',
description: 'Password (min 8 chars, 1 uppercase, 1 lowercase, 1 number, 1 special char)',
minLength: 8,
})
@IsString()
@MinLength(8, { message: 'Password must be at least 8 characters long' })
@MaxLength(50, { message: 'Password must not exceed 50 characters' })
@Matches(
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/,
{
message:
'Password must contain at least 1 uppercase, 1 lowercase, 1 number, and 1 special character',
},
)
password: string;
@ApiPropertyOptional({
example: 'USER',
description: 'User role (USER or AGENT)',
enum: ['USER', 'AGENT'],
default: 'USER',
})
@IsOptional()
@IsEnum(['USER', 'AGENT'], { message: 'Role must be either USER or AGENT' })
role?: 'USER' | 'AGENT';
@ApiPropertyOptional({
example: 'John',
description: 'First name',
})
@IsOptional()
@IsString()
@MaxLength(50)
firstName?: string;
@ApiPropertyOptional({
example: 'Doe',
description: 'Last name',
})
@IsOptional()
@IsString()
@MaxLength(50)
lastName?: string;
}

View File

@@ -0,0 +1,28 @@
import { IsString, MinLength, MaxLength, Matches } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class ResetPasswordDto {
@ApiProperty({
example: 'abc123token',
description: 'Password reset token from email',
})
@IsString()
token: string;
@ApiProperty({
example: 'NewPassword@123',
description: 'New password (min 8 chars, 1 uppercase, 1 lowercase, 1 number, 1 special char)',
minLength: 8,
})
@IsString()
@MinLength(8, { message: 'Password must be at least 8 characters long' })
@MaxLength(50, { message: 'Password must not exceed 50 characters' })
@Matches(
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/,
{
message:
'Password must contain at least 1 uppercase, 1 lowercase, 1 number, and 1 special character',
},
)
password: string;
}

View File

@@ -0,0 +1,60 @@
import {
IsEmail,
IsString,
IsEnum,
IsOptional,
IsUrl,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class SocialAuthDto {
@ApiProperty({
example: 'google',
description: 'OAuth provider',
enum: ['google', 'facebook'],
})
@IsEnum(['google', 'facebook'], {
message: 'Provider must be either google or facebook',
})
provider: 'google' | 'facebook';
@ApiProperty({
example: '123456789',
description: 'Provider user ID',
})
@IsString()
providerId: string;
@ApiProperty({
example: 'john@example.com',
description: 'User email from OAuth provider',
})
@IsEmail({}, { message: 'Please provide a valid email address' })
email: string;
@ApiPropertyOptional({
example: 'John Doe',
description: 'User name from OAuth provider',
})
@IsOptional()
@IsString()
name?: string;
@ApiPropertyOptional({
example: 'https://example.com/avatar.jpg',
description: 'User avatar URL from OAuth provider',
})
@IsOptional()
@IsUrl({}, { message: 'Avatar must be a valid URL' })
avatar?: string;
@ApiPropertyOptional({
example: 'USER',
description: 'User role (USER or AGENT)',
enum: ['USER', 'AGENT'],
default: 'USER',
})
@IsOptional()
@IsEnum(['USER', 'AGENT'], { message: 'Role must be either USER or AGENT' })
role?: 'USER' | 'AGENT';
}

2
src/auth/guards/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export * from './jwt-auth.guard';
export * from './roles.guard';

View File

@@ -0,0 +1,36 @@
import {
Injectable,
ExecutionContext,
UnauthorizedException,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { Reflector } from '@nestjs/core';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private reflector: Reflector) {
super();
}
canActivate(context: ExecutionContext) {
// Check if route is marked as public
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) {
return true;
}
return super.canActivate(context);
}
handleRequest(err: any, user: any, info: any) {
if (err || !user) {
throw err || new UnauthorizedException('Invalid or expired token');
}
return user;
}
}

View File

@@ -0,0 +1,41 @@
import {
Injectable,
CanActivate,
ExecutionContext,
ForbiddenException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ROLES_KEY } from '../decorators/roles.decorator';
import { UserRole } from '@prisma/client';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<UserRole[]>(
ROLES_KEY,
[context.getHandler(), context.getClass()],
);
if (!requiredRoles || requiredRoles.length === 0) {
return true;
}
const { user } = context.switchToHttp().getRequest();
if (!user) {
throw new ForbiddenException('Access denied');
}
const hasRole = requiredRoles.some((role) => user.role === role);
if (!hasRole) {
throw new ForbiddenException(
`Access denied. Required roles: ${requiredRoles.join(', ')}`,
);
}
return true;
}
}

7
src/auth/index.ts Normal file
View File

@@ -0,0 +1,7 @@
export * from './auth.module';
export * from './auth.service';
export * from './auth.controller';
export * from './dto';
export * from './guards';
export * from './strategies';
export * from './decorators';

View File

@@ -0,0 +1 @@
export * from './jwt.strategy';

View File

@@ -0,0 +1,60 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
import { PrismaService } from '../../prisma/prisma.service';
export interface JwtPayload {
sub: string;
email: string;
role: string;
iat?: number;
exp?: number;
}
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
constructor(
private readonly configService: ConfigService,
private readonly prisma: PrismaService,
) {
const secret = configService.get<string>('jwt.secret');
if (!secret) {
throw new Error('JWT_SECRET is not configured');
}
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: secret,
});
}
async validate(payload: JwtPayload) {
const user = await this.prisma.user.findUnique({
where: { id: payload.sub },
select: {
id: true,
email: true,
role: true,
status: true,
emailVerified: true,
},
});
if (!user) {
throw new UnauthorizedException('User not found');
}
if (user.status !== 'ACTIVE') {
throw new UnauthorizedException('Account is not active');
}
return {
id: user.id,
email: user.email,
role: user.role,
status: user.status,
emailVerified: user.emailVerified,
};
}
}

3
src/users/index.ts Normal file
View File

@@ -0,0 +1,3 @@
export * from './users.module';
export * from './users.service';
export * from './users.controller';

View File

@@ -0,0 +1,117 @@
import {
Controller,
Get,
Param,
Patch,
Body,
Query,
UseGuards,
NotFoundException,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiQuery,
} from '@nestjs/swagger';
import { UsersService } from './users.service';
import { JwtAuthGuard, RolesGuard } from '../auth/guards';
import { Roles } from '../auth/decorators';
import { UserRole, UserStatus } from '@prisma/client';
@ApiTags('Users')
@Controller('users')
@UseGuards(JwtAuthGuard, RolesGuard)
@ApiBearerAuth('JWT-auth')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Get()
@Roles(UserRole.ADMIN)
@ApiOperation({ summary: 'Get all users (Admin only)' })
@ApiQuery({ name: 'page', required: false, type: Number })
@ApiQuery({ name: 'limit', required: false, type: Number })
@ApiResponse({
status: 200,
description: 'List of users',
schema: {
example: {
success: true,
data: {
users: [
{
id: 'uuid',
email: 'user@example.com',
role: 'USER',
status: 'ACTIVE',
emailVerified: true,
authProvider: 'LOCAL',
createdAt: '2024-01-01T00:00:00.000Z',
lastLoginAt: '2024-01-01T00:00:00.000Z',
profile: {
firstName: 'John',
lastName: 'Doe',
avatar: null,
phone: null,
city: null,
state: null,
country: null,
},
},
],
total: 100,
page: 1,
limit: 10,
totalPages: 10,
},
},
},
})
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 403, description: 'Forbidden - Admin only' })
async findAll(
@Query('page') page?: string,
@Query('limit') limit?: string,
) {
const pageNum = page ? parseInt(page, 10) : 1;
const limitNum = limit ? parseInt(limit, 10) : 10;
return this.usersService.findAll(pageNum, limitNum);
}
@Get(':id')
@Roles(UserRole.ADMIN)
@ApiOperation({ summary: 'Get user by ID (Admin only)' })
@ApiResponse({
status: 200,
description: 'User details',
})
@ApiResponse({ status: 404, description: 'User not found' })
async findOne(@Param('id') id: string) {
const user = await this.usersService.findOne(id);
if (!user) {
throw new NotFoundException('User not found');
}
return user;
}
@Patch(':id/status')
@Roles(UserRole.ADMIN)
@ApiOperation({ summary: 'Update user status (Admin only)' })
@ApiResponse({
status: 200,
description: 'User status updated',
})
async updateStatus(
@Param('id') id: string,
@Body('status') status: UserStatus,
) {
const user = await this.usersService.updateStatus(id, status);
return {
id: user.id,
status: user.status,
message: 'User status updated successfully',
};
}
}

12
src/users/users.module.ts Normal file
View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
import { PrismaModule } from '../prisma/prisma.module';
@Module({
imports: [PrismaModule],
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}

View File

@@ -0,0 +1,88 @@
import { Injectable } from '@nestjs/common';
import { UserStatus, Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
type UserWithProfile = Prisma.UserGetPayload<{
include: { userProfile: true };
}>;
@Injectable()
export class UsersService {
constructor(private readonly prisma: PrismaService) {}
async findAll(page = 1, limit = 10) {
const skip = (page - 1) * limit;
const [users, total] = await Promise.all([
this.prisma.user.findMany({
skip,
take: limit,
orderBy: { createdAt: 'desc' },
include: {
userProfile: true,
},
}) as Promise<UserWithProfile[]>,
this.prisma.user.count(),
]);
return {
users: users.map((user) => ({
id: user.id,
email: user.email,
role: user.role,
status: user.status,
emailVerified: user.emailVerified,
authProvider: user.authProvider,
createdAt: user.createdAt,
lastLoginAt: user.lastLoginAt,
profile: user.userProfile
? {
firstName: user.userProfile.firstName,
lastName: user.userProfile.lastName,
avatar: user.userProfile.avatar,
phone: user.userProfile.phone,
city: user.userProfile.city,
state: user.userProfile.state,
country: user.userProfile.country,
}
: null,
})),
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
async findOne(id: string) {
const user = (await this.prisma.user.findUnique({
where: { id },
include: {
userProfile: true,
},
})) as UserWithProfile | null;
if (!user) {
return null;
}
return {
id: user.id,
email: user.email,
role: user.role,
status: user.status,
emailVerified: user.emailVerified,
authProvider: user.authProvider,
createdAt: user.createdAt,
lastLoginAt: user.lastLoginAt,
profile: user.userProfile,
};
}
async updateStatus(id: string, status: UserStatus) {
return this.prisma.user.update({
where: { id },
data: { status },
});
}
}