Add password login, set-password, business images migration, and user serialization.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-23 12:10:30 +03:30
co-authored by Cursor
parent 715ca585a8
commit 97a9ee022f
11 changed files with 290 additions and 36 deletions
+16
View File
@@ -2,7 +2,9 @@ import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthUser } from './auth.types';
import { CurrentUser } from './decorators/current-user.decorator';
import { LoginPasswordDto } from './dto/login-password.dto';
import { RequestOtpDto } from './dto/request-otp.dto';
import { SetPasswordDto } from './dto/set-password.dto';
import { VerifyOtpDto } from './dto/verify-otp.dto';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
@@ -20,6 +22,20 @@ export class AuthController {
return this.auth.verifyOtp(payload);
}
@Post('login-password')
loginWithPassword(@Body() payload: LoginPasswordDto) {
return this.auth.loginWithPassword(payload);
}
@Post('set-password')
@UseGuards(JwtAuthGuard)
setPassword(
@CurrentUser() user: AuthUser,
@Body() payload: SetPasswordDto,
) {
return this.auth.setPassword(user.id, payload);
}
@Get('me')
@UseGuards(JwtAuthGuard)
me(@CurrentUser() user: AuthUser) {
+91 -24
View File
@@ -16,10 +16,17 @@ import {
isValidIranMobile,
normalizeIranMobile,
} from '../../common/utils/iran-mobile.util';
import {
hashPassword,
verifyPassword,
} from '../../common/utils/password.util';
import { serializeUser } from '../../common/utils/user-serialize.util';
import { AuthOtpEntity } from '../../entities/auth-otp.entity';
import { UserEntity, UserRole } from '../../entities/user.entity';
import { JwtPayload } from './auth.types';
import { LoginPasswordDto } from './dto/login-password.dto';
import { RequestOtpDto } from './dto/request-otp.dto';
import { SetPasswordDto } from './dto/set-password.dto';
import { VerifyOtpDto } from './dto/verify-otp.dto';
import { SmsService } from './sms.service';
@@ -28,9 +35,9 @@ type RequestOtpResponse = {
otpCode?: string;
};
type VerifyOtpResponse = {
type AuthSessionResponse = {
accessToken: string;
user: Pick<UserEntity, 'id' | 'mobile' | 'fullName' | 'role' | 'isActive'>;
user: ReturnType<typeof serializeUser>;
};
@Injectable()
@@ -113,7 +120,7 @@ export class AuthService {
return response;
}
async verifyOtp(dto: VerifyOtpDto): Promise<VerifyOtpResponse> {
async verifyOtp(dto: VerifyOtpDto): Promise<AuthSessionResponse> {
const mobile = this.normalizeMobile(dto.mobile);
const code = String(dto.code ?? '').trim();
@@ -148,24 +155,94 @@ export class AuthService {
await this.otps.save(otp);
const user = await this.findOrCreateCustomer(mobile);
const payload: JwtPayload = {
sub: user.id,
mobile: user.mobile,
role: user.role,
};
return {
accessToken: await this.jwt.signAsync(payload),
user: this.serializeUser(user),
};
return this.issueSession(user);
}
async loginWithPassword(dto: LoginPasswordDto): Promise<AuthSessionResponse> {
const mobile = this.normalizeMobile(dto.mobile);
const user = await this.users
.createQueryBuilder('user')
.addSelect('user.passwordHash')
.where('user.mobile = :mobile', { mobile })
.getOne();
if (!user || !user.isActive) {
throw new UnauthorizedException('Invalid mobile or password');
}
if (!user.passwordHash) {
throw new UnauthorizedException(
'Password is not set. Sign in with OTP first, then set a password in your profile.',
);
}
const valid = await verifyPassword(dto.password, user.passwordHash);
if (!valid) {
throw new UnauthorizedException('Invalid mobile or password');
}
return this.issueSession(user);
}
async setPassword(userId: string, dto: SetPasswordDto) {
if (dto.password !== dto.confirmPassword) {
throw new BadRequestException('Password confirmation does not match');
}
const user = await this.users
.createQueryBuilder('user')
.addSelect('user.passwordHash')
.where('user.id = :id', { id: userId })
.getOne();
if (!user || !user.isActive) {
throw new UnauthorizedException('User not found');
}
if (user.passwordHash) {
if (!dto.currentPassword) {
throw new BadRequestException('Current password is required');
}
const valid = await verifyPassword(dto.currentPassword, user.passwordHash);
if (!valid) {
throw new UnauthorizedException('Current password is incorrect');
}
}
user.passwordHash = await hashPassword(dto.password);
const saved = await this.users.save(user);
return serializeUser(saved);
}
async me(userId: string) {
const user = await this.users.findOne({ where: { id: userId } });
const user = await this.users
.createQueryBuilder('user')
.addSelect('user.passwordHash')
.where('user.id = :id', { id: userId })
.getOne();
if (!user) {
throw new UnauthorizedException('User not found');
}
return this.serializeUser(user);
return serializeUser(user);
}
private async issueSession(user: UserEntity): Promise<AuthSessionResponse> {
const full = await this.users
.createQueryBuilder('user')
.addSelect('user.passwordHash')
.where('user.id = :id', { id: user.id })
.getOne();
const profile = full ?? user;
const payload: JwtPayload = {
sub: profile.id,
mobile: profile.mobile,
role: profile.role,
};
return {
accessToken: await this.jwt.signAsync(payload),
user: serializeUser(profile),
};
}
private async findOrCreateCustomer(mobile: string): Promise<UserEntity> {
@@ -193,16 +270,6 @@ export class AuthService {
);
}
private serializeUser(user: UserEntity) {
return {
id: user.id,
mobile: user.mobile,
fullName: user.fullName,
role: user.role,
isActive: user.isActive,
};
}
private normalizeMobile(mobile: string): string {
const normalized = normalizeIranMobile(mobile);
if (!isValidIranMobile(normalized)) {
@@ -0,0 +1,15 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, MaxLength, MinLength } from 'class-validator';
import { IsIranMobile } from '../../../common/decorators/is-iran-mobile.decorator';
export class LoginPasswordDto {
@ApiProperty({ example: '09122222222' })
@IsIranMobile()
mobile!: string;
@ApiProperty({ example: 'mySecurePass123' })
@IsString()
@MinLength(1)
@MaxLength(72)
password!: string;
}
+22
View File
@@ -0,0 +1,22 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
export class SetPasswordDto {
@ApiPropertyOptional({ description: 'Required when changing an existing password' })
@IsOptional()
@IsString()
@MaxLength(72)
currentPassword?: string;
@ApiProperty({ example: 'mySecurePass123', minLength: 8 })
@IsString()
@MinLength(8)
@MaxLength(72)
password!: string;
@ApiProperty({ example: 'mySecurePass123' })
@IsString()
@MinLength(8)
@MaxLength(72)
confirmPassword!: string;
}