Restore email OTP verification flow
فشلت بعض الفحوصات
Deploy To Ghaymah / deploy (push) Has been cancelled

هذا الالتزام موجود في:
2026-05-15 15:18:54 +03:00
الأصل dbdbb43f75
التزام bf62e57cf1
5 ملفات معدلة مع 75 إضافات و19 حذوفات

عرض الملف

@@ -62,10 +62,12 @@ export class AuthService {
username: generatedUsername, username: generatedUsername,
password: passwordHash, password: passwordHash,
}); });
return { const code = await this.issueEmailVerificationCode(user.id, user.email);
message: 'Registration successful.', const message = 'Registration successful. Verification code sent to email.';
email: user.email, const nodeEnv = this.configService.get<string>('nodeEnv', { infer: true });
}; return nodeEnv !== 'production'
? { message, email: user.email, debugCode: code }
: { message, email: user.email };
} }
async registerBasic(dto: RegisterBasicDto): Promise<{ message: string; email: string; debugCode?: string }> { async registerBasic(dto: RegisterBasicDto): Promise<{ message: string; email: string; debugCode?: string }> {
@@ -83,11 +85,12 @@ export class AuthService {
email: dto.email, email: dto.email,
password: passwordHash, password: passwordHash,
}); });
const code = await this.issueEmailVerificationCode(user.id, user.email);
return { const message = 'Registration successful. Verification code sent to email.';
message: 'Registration successful.', const nodeEnv = this.configService.get<string>('nodeEnv', { infer: true });
email: user.email, return nodeEnv !== 'production'
}; ? { message, email: user.email, debugCode: code }
: { message, email: user.email };
} }
async login(dto: LoginDto): Promise<AuthResult> { async login(dto: LoginDto): Promise<AuthResult> {
@@ -114,21 +117,57 @@ export class AuthService {
): Promise<{ message: string; debugCode?: string }> { ): Promise<{ message: string; debugCode?: string }> {
const normalizedEmail = dto.email.toLowerCase(); const normalizedEmail = dto.email.toLowerCase();
const user = await this.usersService.findByEmail(normalizedEmail); const user = await this.usersService.findByEmail(normalizedEmail);
const message = 'Account verification is optional and can be requested later'; const message = 'If this email exists, a verification code was sent';
if (!user || user.isDisabled) { if (!user || user.isDisabled) {
return { message }; return { message };
} }
if (user.isVerified) { if (user.isEmailVerified) {
return { message: 'Account is already verified' }; return { message: 'Email is already verified' };
} }
return { message: 'Account is not verified yet. Verification can be requested later.' }; const code = await this.issueEmailVerificationCode(user.id, normalizedEmail);
const nodeEnv = this.configService.get<string>('nodeEnv', { infer: true });
return nodeEnv !== 'production' ? { message, debugCode: code } : { message };
} }
async verifyEmail(_dto: VerifyEmailDto): Promise<{ message: string }> { async verifyEmail(dto: VerifyEmailDto): Promise<{ message: string }> {
return { const normalizedEmail = dto.email.toLowerCase();
message: 'Account verification is optional and can be requested later', const user = await this.usersService.findByEmail(normalizedEmail);
}; if (!user || user.isDisabled) {
throw new UnauthorizedException('Invalid or expired verification code');
}
if (user.isEmailVerified) {
return { message: 'Email is already verified' };
}
const codeRecord = await this.authRepository.findLatestActiveEmailVerificationCode(user.id);
if (!codeRecord) {
throw new UnauthorizedException('Invalid or expired verification code');
}
const maxAttempts = this.configService.get<number>('emailVerification.maxAttempts', { infer: true });
if (codeRecord.attempts >= maxAttempts) {
await this.authRepository.markEmailVerificationCodeUsed(codeRecord.id);
throw new UnauthorizedException('Verification code attempts exceeded');
}
const isMatch = await compareHash(dto.code, codeRecord.codeHash);
if (!isMatch) {
await this.authRepository.incrementEmailVerificationAttempts(codeRecord.id);
const attemptsAfter = codeRecord.attempts + 1;
if (attemptsAfter >= maxAttempts) {
await this.authRepository.markEmailVerificationCodeUsed(codeRecord.id);
}
throw new UnauthorizedException('Invalid or expired verification code');
}
await Promise.all([
this.usersService.markEmailVerified(user.id),
this.authRepository.markEmailVerificationCodeUsed(codeRecord.id),
this.authRepository.markAllEmailVerificationCodesUsedByUser(user.id),
]);
return { message: 'Email verified successfully' };
} }
async refresh(dto: RefreshTokenDto): Promise<AuthResult> { async refresh(dto: RefreshTokenDto): Promise<AuthResult> {
@@ -210,6 +249,7 @@ export class AuthService {
password: passwordHash, password: passwordHash,
avatar: googleUser.avatar ?? '', avatar: googleUser.avatar ?? '',
isVerified: false, isVerified: false,
isEmailVerified: true,
}); });
} }

عرض الملف

@@ -73,9 +73,15 @@ export class EmailService {
private async send(to: string, subject: string, text: string, html: string): Promise<void> { private async send(to: string, subject: string, text: string, html: string): Promise<void> {
const enabled = this.configService.get<boolean>('email.enabled', { infer: true }); const enabled = this.configService.get<boolean>('email.enabled', { infer: true });
if (!enabled) { if (!enabled) {
const nodeEnv = this.configService.get<string>('nodeEnv', { infer: true });
if (nodeEnv !== 'production') {
this.logger.warn(`Email delivery is disabled. Skipping email to ${to}`);
return; return;
} }
throw new ServiceUnavailableException('Email delivery is disabled');
}
const fromName = this.configService.get<string>('email.fromName', { infer: true }) ?? 'Oudelaa'; const fromName = this.configService.get<string>('email.fromName', { infer: true }) ?? 'Oudelaa';
const fromEmail = this.configService.get<string>('email.fromEmail', { infer: true }) ?? ''; const fromEmail = this.configService.get<string>('email.fromEmail', { infer: true }) ?? '';
if (!fromEmail) { if (!fromEmail) {

عرض الملف

@@ -93,6 +93,12 @@ export class CreateUserDto {
@IsBoolean() @IsBoolean()
isVerified?: boolean; isVerified?: boolean;
@ApiProperty({ required: false, default: false })
@IsOptional()
@Transform(toBoolean)
@IsBoolean()
isEmailVerified?: boolean;
@ApiProperty({ required: false, enum: MusicRole, isArray: true }) @ApiProperty({ required: false, enum: MusicRole, isArray: true })
@IsOptional() @IsOptional()
@IsArray() @IsArray()

عرض الملف

@@ -96,6 +96,9 @@ export class User {
@Prop({ default: false, index: true }) @Prop({ default: false, index: true })
isVerified!: boolean; isVerified!: boolean;
@Prop({ default: false })
isEmailVerified!: boolean;
@Prop({ default: '', trim: true, maxlength: 120 }) @Prop({ default: '', trim: true, maxlength: 120 })
shopName!: string; shopName!: string;

عرض الملف

@@ -70,6 +70,7 @@ export class UsersService {
longitude: dto.longitude, longitude: dto.longitude,
isPrivate: dto.isPrivate ?? false, isPrivate: dto.isPrivate ?? false,
isVerified: dto.isVerified ?? false, isVerified: dto.isVerified ?? false,
isEmailVerified: dto.isEmailVerified ?? false,
musicRoles: roles, musicRoles: roles,
experienceLevel: dto.experienceLevel ?? ExperienceLevel.BEGINNER, experienceLevel: dto.experienceLevel ?? ExperienceLevel.BEGINNER,
musicGenres: dto.musicGenres ?? [], musicGenres: dto.musicGenres ?? [],
@@ -295,7 +296,7 @@ export class UsersService {
} }
async markEmailVerified(userId: string): Promise<void> { async markEmailVerified(userId: string): Promise<void> {
const updated = await this.usersRepository.updateById(userId, { isVerified: true }); const updated = await this.usersRepository.updateById(userId, { isEmailVerified: true });
if (!updated) { if (!updated) {
throw new NotFoundException('User not found'); throw new NotFoundException('User not found');
} }