diff --git a/backend/src/auth/auth.controller.spec.ts b/backend/src/auth/auth.controller.spec.ts
index ef7eb726..e3b1431f 100644
--- a/backend/src/auth/auth.controller.spec.ts
+++ b/backend/src/auth/auth.controller.spec.ts
@@ -15,6 +15,9 @@ describe('AuthController', () => {
refresh: jest.fn(),
logout: jest.fn(),
verifyEmail: jest.fn(),
+ resendEmailVerification: jest.fn(),
+ verifyPhone: jest.fn(),
+ resendPhoneVerification: jest.fn(),
forgotPassword: jest.fn(),
};
@@ -43,6 +46,7 @@ describe('AuthController', () => {
firstName: 'Test',
lastName: 'User',
password: 'Password123!',
+ phone: '+15551234567',
};
const expectedUser = {
@@ -173,12 +177,76 @@ describe('AuthController', () => {
token: 'verification-token',
};
- mockAuthService.verifyEmail.mockResolvedValue(undefined);
+ mockAuthService.verifyEmail.mockResolvedValue({
+ message: 'Email verified successfully',
+ emailVerified: true,
+ phoneVerified: false,
+ isVerified: false,
+ email: 'test@example.com',
+ });
const result = await controller.verifyEmail(verifyEmailDto);
expect(authService.verifyEmail).toHaveBeenCalledWith(verifyEmailDto);
- expect(result).toEqual({ message: 'Email verified successfully' });
+ expect(result).toEqual({
+ message: 'Email verified successfully',
+ emailVerified: true,
+ phoneVerified: false,
+ isVerified: false,
+ email: 'test@example.com',
+ });
+ });
+ });
+
+ describe('verifyPhone', () => {
+ it('should verify phone successfully', async () => {
+ const verifyPhoneDto = {
+ email: 'test@example.com',
+ code: '123456',
+ };
+
+ mockAuthService.verifyPhone.mockResolvedValue({
+ message: 'Phone number verified successfully',
+ emailVerified: true,
+ phoneVerified: true,
+ isVerified: true,
+ });
+
+ const result = await controller.verifyPhone(verifyPhoneDto);
+
+ expect(authService.verifyPhone).toHaveBeenCalledWith(verifyPhoneDto);
+ expect(result).toEqual({
+ message: 'Phone number verified successfully',
+ emailVerified: true,
+ phoneVerified: true,
+ isVerified: true,
+ });
+ });
+ });
+
+ describe('resend verification', () => {
+ it('should resend email verification successfully', async () => {
+ const dto = { email: 'test@example.com' };
+ mockAuthService.resendEmailVerification.mockResolvedValue(undefined);
+
+ const result = await controller.resendEmailVerification(dto);
+
+ expect(authService.resendEmailVerification).toHaveBeenCalledWith(dto);
+ expect(result).toEqual({
+ message: 'If the account exists, a new verification email was sent',
+ });
+ });
+
+ it('should resend phone verification successfully', async () => {
+ const dto = { email: 'test@example.com' };
+ mockAuthService.resendPhoneVerification.mockResolvedValue(undefined);
+
+ const result = await controller.resendPhoneVerification(dto);
+
+ expect(authService.resendPhoneVerification).toHaveBeenCalledWith(dto);
+ expect(result).toEqual({
+ message: 'If the account exists, a new verification code was sent',
+ });
});
});
diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts
index 90ebfa20..e8bef30a 100644
--- a/backend/src/auth/auth.controller.ts
+++ b/backend/src/auth/auth.controller.ts
@@ -15,6 +15,8 @@ import {
RefreshDto,
LogoutDto,
VerifyEmailDto,
+ ResendVerificationDto,
+ VerifyPhoneDto,
ForgotPasswordDto,
ResetPasswordDto,
} from './dto/auth.dto';
@@ -58,8 +60,31 @@ export class AuthController {
@Post('verify-email')
@HttpCode(HttpStatus.OK)
async verifyEmail(@Body() verifyEmailDto: VerifyEmailDto) {
- await this.authService.verifyEmail(verifyEmailDto);
- return { message: 'Email verified successfully' };
+ return this.authService.verifyEmail(verifyEmailDto);
+ }
+
+ @Post('resend-email-verification')
+ @HttpCode(HttpStatus.OK)
+ async resendEmailVerification(
+ @Body() resendVerificationDto: ResendVerificationDto,
+ ) {
+ await this.authService.resendEmailVerification(resendVerificationDto);
+ return { message: 'If the account exists, a new verification email was sent' };
+ }
+
+ @Post('verify-phone')
+ @HttpCode(HttpStatus.OK)
+ async verifyPhone(@Body() verifyPhoneDto: VerifyPhoneDto) {
+ return this.authService.verifyPhone(verifyPhoneDto);
+ }
+
+ @Post('resend-phone-verification')
+ @HttpCode(HttpStatus.OK)
+ async resendPhoneVerification(
+ @Body() resendVerificationDto: ResendVerificationDto,
+ ) {
+ await this.authService.resendPhoneVerification(resendVerificationDto);
+ return { message: 'If the account exists, a new verification code was sent' };
}
@Post('forgot-password')
diff --git a/backend/src/auth/auth.module.ts b/backend/src/auth/auth.module.ts
index 5a6517c7..1155506a 100644
--- a/backend/src/auth/auth.module.ts
+++ b/backend/src/auth/auth.module.ts
@@ -18,12 +18,14 @@ import { UserRole } from './entities/user-role.entity';
import { RolePermission } from './entities/role-permission.entity';
import { RoleAuditLog } from './entities/role-audit-log.entity';
import { FailedLoginAttempt } from './entities/failed-login-attempt.entity';
-import { EmailServiceImpl } from './services/email.service';
import { EMAIL_SERVICE } from './interfaces/email-service.interface';
import { RolesService } from './services/roles.service';
import { RolesController } from './controllers/roles.controller';
import { PermissionsService } from './services/permissions.service';
import { RolesPermissionsSeeder } from './seeds/roles-permissions.seed';
+import { SmsModule } from '../modules/sms/sms.module';
+import { EmailModule } from '../modules/email/email.module';
+import { EmailService as AppEmailService } from '../modules/email/email.service';
@Module({
imports: [
@@ -77,6 +79,8 @@ import { RolesPermissionsSeeder } from './seeds/roles-permissions.seed';
},
}),
forwardRef(() => UsersModule),
+ SmsModule,
+ EmailModule,
],
controllers: [AuthController, RolesController],
providers: [
@@ -89,7 +93,7 @@ import { RolesPermissionsSeeder } from './seeds/roles-permissions.seed';
RolesPermissionsSeeder,
{
provide: EMAIL_SERVICE,
- useClass: EmailServiceImpl,
+ useExisting: AppEmailService,
},
],
exports: [
diff --git a/backend/src/auth/auth.service.spec.ts b/backend/src/auth/auth.service.spec.ts
index 2926bcfc..097b70af 100644
--- a/backend/src/auth/auth.service.spec.ts
+++ b/backend/src/auth/auth.service.spec.ts
@@ -14,11 +14,14 @@ import { UsersService } from '../modules/users/users.service';
import { User } from '../modules/users/entities/user.entity';
import { RefreshToken } from './entities/refresh-token.entity';
import { Session } from './entities/session.entity';
-import { EmailService } from './interfaces/email-service.interface';
-import { EmailServiceImpl } from './services/email.service';
+import {
+ EMAIL_SERVICE,
+ type EmailService,
+} from './interfaces/email-service.interface';
import { PasswordUtil } from './utils/password.util';
import { DeviceFingerprintUtil } from './utils/device-fingerprint.util';
import { TokenUtil } from './utils/token.util';
+import { SmsService } from '../modules/sms/sms.service';
describe('AuthService', () => {
let service: AuthService;
@@ -41,6 +44,7 @@ describe('AuthService', () => {
save: jest.fn(),
findOne: jest.fn(),
remove: jest.fn(),
+ delete: jest.fn(),
};
const mockSessionRepository = {
@@ -69,6 +73,10 @@ describe('AuthService', () => {
sendPasswordResetEmail: jest.fn(),
};
+ const mockSmsService = {
+ sendSms: jest.fn(),
+ };
+
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
@@ -98,9 +106,13 @@ describe('AuthService', () => {
useValue: mockConfigService,
},
{
- provide: EmailService,
+ provide: EMAIL_SERVICE,
useValue: mockEmailService,
},
+ {
+ provide: SmsService,
+ useValue: mockSmsService,
+ },
],
}).compile();
@@ -115,7 +127,7 @@ describe('AuthService', () => {
usersService = module.get(UsersService);
jwtService = module.get(JwtService);
configService = module.get(ConfigService);
- emailService = module.get(EmailService);
+ emailService = module.get(EMAIL_SERVICE);
// Reset all mocks
jest.clearAllMocks();
@@ -127,6 +139,7 @@ describe('AuthService', () => {
firstName: 'Test',
lastName: 'User',
password: 'Password123!',
+ phone: '+15551234567',
};
it('should register a new user successfully', async () => {
@@ -142,6 +155,7 @@ describe('AuthService', () => {
.mockReturnValue('hashed-verification-token');
mockConfigService.get.mockImplementation((key: string) => {
if (key === 'auth.emailVerificationExpiration') return '24h';
+ if (key === 'auth.phoneVerificationExpiration') return '24h';
return null;
});
@@ -152,12 +166,16 @@ describe('AuthService', () => {
emailVerified: false,
emailVerificationToken: 'hashed-verification-token',
emailVerificationExpires: new Date(),
+ phoneVerified: false,
+ phoneVerificationCode: 'hashed-verification-token',
+ phoneVerificationExpires: new Date(),
isActive: true,
failedLoginAttempts: 0,
};
mockUserRepository.create.mockReturnValue(mockUser);
mockUserRepository.save.mockResolvedValue(mockUser);
+ mockSmsService.sendSms.mockResolvedValue({ success: true });
const result = await service.register(registerDto);
@@ -201,6 +219,7 @@ describe('AuthService', () => {
password: 'hashedPassword',
isActive: true,
emailVerified: true,
+ phoneVerified: true,
failedLoginAttempts: 0,
lockedUntil: null,
};
@@ -437,6 +456,7 @@ describe('AuthService', () => {
id: 'user-id',
email: 'test@example.com',
emailVerified: false,
+ phoneVerified: false,
emailVerificationToken: 'hashed-token',
emailVerificationExpires: new Date(Date.now() + 24 * 60 * 60 * 1000),
};
@@ -446,11 +466,12 @@ describe('AuthService', () => {
emailVerified: true,
});
- await service.verifyEmail(verifyEmailDto);
+ const result = await service.verifyEmail(verifyEmailDto);
expect(TokenUtil.hashToken).toHaveBeenCalledWith(verifyEmailDto.token);
expect(mockUserRepository.findOne).toHaveBeenCalled();
expect(mockUserRepository.save).toHaveBeenCalled();
+ expect(result.emailVerified).toBe(true);
});
it('should throw BadRequestException for invalid token', async () => {
@@ -477,6 +498,47 @@ describe('AuthService', () => {
});
});
+ describe('verifyPhone', () => {
+ const verifyPhoneDto = {
+ email: 'test@example.com',
+ code: '123456',
+ };
+
+ it('should verify phone successfully', async () => {
+ jest.spyOn(TokenUtil, 'hashToken').mockReturnValue('hashed-code');
+ const mockUser = {
+ id: 'user-id',
+ email: 'test@example.com',
+ emailVerified: true,
+ phoneVerified: false,
+ phoneVerificationCode: 'hashed-code',
+ phoneVerificationExpires: new Date(Date.now() + 24 * 60 * 60 * 1000),
+ };
+ mockUsersService.findByEmail.mockResolvedValue(mockUser);
+ mockUserRepository.save.mockResolvedValue({
+ ...mockUser,
+ phoneVerified: true,
+ });
+
+ const result = await service.verifyPhone(verifyPhoneDto);
+
+ expect(result.phoneVerified).toBe(true);
+ expect(mockUserRepository.save).toHaveBeenCalled();
+ });
+
+ it('should throw when the code is invalid', async () => {
+ jest.spyOn(TokenUtil, 'hashToken').mockReturnValue('wrong-code');
+ mockUsersService.findByEmail.mockResolvedValue({
+ phoneVerificationCode: 'hashed-code',
+ phoneVerificationExpires: new Date(Date.now() + 1000),
+ });
+
+ await expect(service.verifyPhone(verifyPhoneDto)).rejects.toThrow(
+ BadRequestException,
+ );
+ });
+ });
+
describe('forgotPassword', () => {
const forgotPasswordDto = {
email: 'test@example.com',
diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts
index 32908cd0..9db79e60 100644
--- a/backend/src/auth/auth.service.ts
+++ b/backend/src/auth/auth.service.ts
@@ -18,6 +18,8 @@ import {
LoginDto,
RefreshDto,
VerifyEmailDto,
+ ResendVerificationDto,
+ VerifyPhoneDto,
ForgotPasswordDto,
ResetPasswordDto,
} from './dto/auth.dto';
@@ -33,6 +35,7 @@ import {
type IEmailService,
} from './interfaces/email-service.interface';
import { JwtPayload } from './strategies/jwt.strategy';
+import { SmsService } from '../modules/sms/sms.service';
/**
* User data without sensitive fields
@@ -41,7 +44,11 @@ export type SafeUser = Omit<
User,
| 'password'
| 'emailVerificationToken'
+ | 'emailVerificationExpires'
+ | 'phoneVerificationCode'
+ | 'phoneVerificationExpires'
| 'passwordResetToken'
+ | 'passwordResetExpires'
| 'getActiveRoles'
| 'getProfileCompletionScore' // method added on User entity, omit for type safety
>;
@@ -52,6 +59,14 @@ export interface AuthResponse {
user: SafeUser;
}
+export interface VerificationStatusResponse {
+ message: string;
+ emailVerified: boolean;
+ phoneVerified: boolean;
+ isVerified: boolean;
+ email?: string;
+}
+
@Injectable()
export class AuthService {
constructor(
@@ -66,6 +81,7 @@ export class AuthService {
private readonly configService: ConfigService,
@Inject(EMAIL_SERVICE)
private readonly emailService: IEmailService,
+ private readonly smsService: SmsService,
) {}
/**
@@ -86,35 +102,30 @@ export class AuthService {
bcryptRounds,
);
- // Generate email verification token
const verificationToken = TokenUtil.generateToken();
- const verificationExpires = new Date();
- const expirationStr =
+ const verificationExpires = this.createExpiryDate(
this.configService.get('auth.emailVerificationExpiration') ||
- '24h';
- if (expirationStr.endsWith('h')) {
- verificationExpires.setHours(
- verificationExpires.getHours() +
- parseInt(expirationStr.replace('h', ''), 10),
- );
- } else if (expirationStr.endsWith('d')) {
- verificationExpires.setDate(
- verificationExpires.getDate() +
- parseInt(expirationStr.replace('d', ''), 10),
- );
- } else {
- verificationExpires.setHours(verificationExpires.getHours() + 24); // Default 24 hours
- }
+ '24h',
+ );
+ const phoneVerificationCode = this.generatePhoneVerificationCode();
+ const phoneVerificationExpires = this.createExpiryDate(
+ this.configService.get('auth.phoneVerificationExpiration') ||
+ '24h',
+ );
// Create user
const user = this.userRepository.create({
email: registerDto.email,
firstName: registerDto.firstName,
lastName: registerDto.lastName,
+ phone: registerDto.phone,
password: hashedPassword,
emailVerified: false,
emailVerificationToken: TokenUtil.hashToken(verificationToken),
emailVerificationExpires: verificationExpires,
+ phoneVerified: false,
+ phoneVerificationCode: TokenUtil.hashToken(phoneVerificationCode),
+ phoneVerificationExpires,
isActive: true,
failedLoginAttempts: 0,
});
@@ -132,15 +143,24 @@ export class AuthService {
console.error('Failed to send verification email:', error);
}
+ await this.sendPhoneVerificationCode(savedUser, phoneVerificationCode);
+
// Return user without sensitive data
const {
password,
emailVerificationToken,
+ emailVerificationExpires,
+ phoneVerificationCode,
+ phoneVerificationExpires,
passwordResetToken,
+ passwordResetExpires,
getActiveRoles,
...userResponse
} = savedUser as User & { getActiveRoles: unknown };
- return userResponse;
+ return {
+ ...userResponse,
+ isVerified: savedUser.isVerified,
+ };
}
/**
@@ -205,10 +225,11 @@ export class AuthService {
await this.userRepository.save(user);
}
- // Check email verification (optional - can be made required)
- // if (!user.emailVerified) {
- // throw new ForbiddenException('Please verify your email before logging in');
- // }
+ if (!user.emailVerified || !user.phoneVerified) {
+ throw new ForbiddenException(
+ 'Please verify your email and phone before logging in',
+ );
+ }
// Create device fingerprint
const deviceFingerprint = DeviceFingerprintUtil.createFingerprint(
@@ -229,13 +250,20 @@ export class AuthService {
const {
password,
emailVerificationToken,
+ emailVerificationExpires,
+ phoneVerificationCode,
+ phoneVerificationExpires,
passwordResetToken,
+ passwordResetExpires,
getActiveRoles,
...userResponse
} = user as User & { getActiveRoles: unknown };
return {
...tokens,
- user: userResponse,
+ user: {
+ ...userResponse,
+ isVerified: user.isVerified,
+ },
};
}
@@ -299,13 +327,20 @@ export class AuthService {
const {
password,
emailVerificationToken,
+ emailVerificationExpires,
+ phoneVerificationCode,
+ phoneVerificationExpires,
passwordResetToken,
+ passwordResetExpires,
getActiveRoles,
...userResponse
} = user as User & { getActiveRoles: unknown };
return {
...newTokens,
- user: userResponse,
+ user: {
+ ...userResponse,
+ isVerified: user.isVerified,
+ },
};
}
@@ -341,7 +376,9 @@ export class AuthService {
/**
* Verify email
*/
- async verifyEmail(verifyEmailDto: VerifyEmailDto): Promise {
+ async verifyEmail(
+ verifyEmailDto: VerifyEmailDto,
+ ): Promise {
const tokenHash = TokenUtil.hashToken(verifyEmailDto.token);
const user = await this.userRepository.findOne({
where: { emailVerificationToken: tokenHash },
@@ -365,6 +402,87 @@ export class AuthService {
user as { emailVerificationExpires: Date | null }
).emailVerificationExpires = null;
await this.userRepository.save(user);
+
+ return this.buildVerificationStatus(
+ user,
+ 'Email verified successfully',
+ );
+ }
+
+ async resendEmailVerification(
+ resendVerificationDto: ResendVerificationDto,
+ ): Promise {
+ const user = await this.usersService.findByEmail(resendVerificationDto.email);
+
+ if (!user || user.emailVerified) {
+ return;
+ }
+
+ const verificationToken = TokenUtil.generateToken();
+ user.emailVerificationToken = TokenUtil.hashToken(verificationToken);
+ user.emailVerificationExpires = this.createExpiryDate(
+ this.configService.get('auth.emailVerificationExpiration') ||
+ '24h',
+ );
+ await this.userRepository.save(user);
+
+ try {
+ await this.emailService.sendVerificationEmail(user.email, verificationToken);
+ } catch (error) {
+ console.error('Failed to resend verification email:', error);
+ }
+ }
+
+ async verifyPhone(
+ verifyPhoneDto: VerifyPhoneDto,
+ ): Promise {
+ const user = await this.usersService.findByEmail(verifyPhoneDto.email);
+
+ if (!user || !user.phoneVerificationCode) {
+ throw new BadRequestException('Invalid verification code');
+ }
+
+ if (
+ user.phoneVerificationExpires &&
+ user.phoneVerificationExpires < new Date()
+ ) {
+ throw new BadRequestException('Verification code has expired');
+ }
+
+ const codeHash = TokenUtil.hashToken(verifyPhoneDto.code);
+ if (user.phoneVerificationCode !== codeHash) {
+ throw new BadRequestException('Invalid verification code');
+ }
+
+ user.phoneVerified = true;
+ user.phoneVerificationCode = null;
+ user.phoneVerificationExpires = null;
+ await this.userRepository.save(user);
+
+ return this.buildVerificationStatus(
+ user,
+ 'Phone number verified successfully',
+ );
+ }
+
+ async resendPhoneVerification(
+ resendVerificationDto: ResendVerificationDto,
+ ): Promise {
+ const user = await this.usersService.findByEmail(resendVerificationDto.email);
+
+ if (!user || user.phoneVerified || !user.phone) {
+ return;
+ }
+
+ const phoneVerificationCode = this.generatePhoneVerificationCode();
+ user.phoneVerificationCode = TokenUtil.hashToken(phoneVerificationCode);
+ user.phoneVerificationExpires = this.createExpiryDate(
+ this.configService.get('auth.phoneVerificationExpiration') ||
+ '24h',
+ );
+ await this.userRepository.save(user);
+
+ await this.sendPhoneVerificationCode(user, phoneVerificationCode);
}
/**
@@ -564,4 +682,65 @@ export class AuthService {
await this.sessionRepository.save(newSession);
}
}
+
+ private createExpiryDate(duration: string): Date {
+ const expiresAt = new Date();
+ const match = duration.match(/^(\d+)([hd])$/);
+
+ if (!match) {
+ expiresAt.setHours(expiresAt.getHours() + 24);
+ return expiresAt;
+ }
+
+ const value = parseInt(match[1], 10);
+ const unit = match[2];
+
+ if (unit === 'd') {
+ expiresAt.setDate(expiresAt.getDate() + value);
+ return expiresAt;
+ }
+
+ expiresAt.setHours(expiresAt.getHours() + value);
+ return expiresAt;
+ }
+
+ private generatePhoneVerificationCode(): string {
+ return `${Math.floor(100000 + Math.random() * 900000)}`;
+ }
+
+ private async sendPhoneVerificationCode(
+ user: User,
+ code: string,
+ ): Promise {
+ if (!user.phone) {
+ return;
+ }
+
+ const result = await this.smsService.sendSms(
+ user.id,
+ user.phone,
+ `Your PetChain verification code is ${code}. It expires in 24 hours.`,
+ {
+ templateName: 'account-verification',
+ skipPreferenceCheck: true,
+ },
+ );
+
+ if (!result.success) {
+ console.error('Failed to send verification SMS:', result.error);
+ }
+ }
+
+ private buildVerificationStatus(
+ user: User,
+ message: string,
+ ): VerificationStatusResponse {
+ return {
+ message,
+ email: user.email,
+ emailVerified: user.emailVerified,
+ phoneVerified: user.phoneVerified,
+ isVerified: user.isVerified,
+ };
+ }
}
diff --git a/backend/src/auth/dto/auth.dto.ts b/backend/src/auth/dto/auth.dto.ts
index 292a63e7..94440d07 100644
--- a/backend/src/auth/dto/auth.dto.ts
+++ b/backend/src/auth/dto/auth.dto.ts
@@ -4,6 +4,8 @@ import {
IsString,
IsOptional,
MinLength,
+ IsPhoneNumber,
+ Length,
} from 'class-validator';
import { IsStrongPassword } from '../utils/password.util';
@@ -20,6 +22,10 @@ export class RegisterDto {
@IsNotEmpty()
lastName: string;
+ @IsPhoneNumber()
+ @IsNotEmpty()
+ phone: string;
+
@IsString()
@IsNotEmpty()
@IsStrongPassword()
@@ -54,6 +60,22 @@ export class VerifyEmailDto {
token: string;
}
+export class ResendVerificationDto {
+ @IsEmail()
+ @IsNotEmpty()
+ email: string;
+}
+
+export class VerifyPhoneDto {
+ @IsEmail()
+ @IsNotEmpty()
+ email: string;
+
+ @IsString()
+ @Length(6, 6)
+ code: string;
+}
+
export class ForgotPasswordDto {
@IsEmail()
@IsNotEmpty()
diff --git a/backend/src/config/auth.config.ts b/backend/src/config/auth.config.ts
index 588d482e..68b94e7f 100644
--- a/backend/src/config/auth.config.ts
+++ b/backend/src/config/auth.config.ts
@@ -15,5 +15,7 @@ export const authConfig = registerAs('auth', () => ({
passwordResetExpiration: process.env.PASSWORD_RESET_EXPIRATION || '1h',
emailVerificationExpiration:
process.env.EMAIL_VERIFICATION_EXPIRATION || '24h',
+ phoneVerificationExpiration:
+ process.env.PHONE_VERIFICATION_EXPIRATION || '24h',
maxFailedLoginAttempts: 5,
}));
diff --git a/backend/src/database/migrations/1742688000000-account-verification.ts b/backend/src/database/migrations/1742688000000-account-verification.ts
new file mode 100644
index 00000000..bdb385b1
--- /dev/null
+++ b/backend/src/database/migrations/1742688000000-account-verification.ts
@@ -0,0 +1,23 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+export class AccountVerification1742688000000 implements MigrationInterface {
+ name = 'AccountVerification1742688000000';
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ ALTER TABLE "users"
+ ADD COLUMN IF NOT EXISTS "phoneVerified" boolean NOT NULL DEFAULT false,
+ ADD COLUMN IF NOT EXISTS "phoneVerificationCode" character varying NULL,
+ ADD COLUMN IF NOT EXISTS "phoneVerificationExpires" TIMESTAMP NULL
+ `);
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ ALTER TABLE "users"
+ DROP COLUMN IF EXISTS "phoneVerificationExpires",
+ DROP COLUMN IF EXISTS "phoneVerificationCode",
+ DROP COLUMN IF EXISTS "phoneVerified"
+ `);
+ }
+}
diff --git a/backend/src/modules/users/entities/user.entity.ts b/backend/src/modules/users/entities/user.entity.ts
index ff16213e..743a3f1d 100644
--- a/backend/src/modules/users/entities/user.entity.ts
+++ b/backend/src/modules/users/entities/user.entity.ts
@@ -28,6 +28,9 @@ export class User {
@Column({ nullable: true })
phone: string;
+ @Column({ default: false })
+ phoneVerified: boolean;
+
@Column({ nullable: true })
avatarUrl: string;
@@ -59,6 +62,12 @@ export class User {
@Column({ type: 'timestamp', nullable: true })
emailVerificationExpires: Date | null;
+ @Column({ nullable: true })
+ phoneVerificationCode: string | null;
+
+ @Column({ type: 'timestamp', nullable: true })
+ phoneVerificationExpires: Date | null;
+
@Column({ default: 0 })
failedLoginAttempts: number;
@@ -98,6 +107,10 @@ export class User {
@UpdateDateColumn()
updatedAt: Date;
+ get isVerified(): boolean {
+ return this.emailVerified && this.phoneVerified;
+ }
+
/**
* Get active role assignments
*/
diff --git a/backend/src/modules/users/users.controller.ts b/backend/src/modules/users/users.controller.ts
index 0202b6be..a4a18747 100644
--- a/backend/src/modules/users/users.controller.ts
+++ b/backend/src/modules/users/users.controller.ts
@@ -103,7 +103,8 @@ export class UsersController {
@Get('me')
@UseGuards(JwtAuthGuard)
async getMe(@CurrentUser() user: User) {
- return this.usersService.findOne(user.id);
+ const currentUser = await this.usersService.findOne(user.id);
+ return this.usersService.sanitizeUser(currentUser);
}
/**
@@ -117,7 +118,7 @@ export class UsersController {
const completion = await this.usersService.getProfileCompletion(user.id);
return {
- ...userProfile,
+ ...this.usersService.sanitizeUser(userProfile),
profileCompletion: completion,
};
}
@@ -140,7 +141,7 @@ export class UsersController {
async replaceProfile(
@CurrentUser() user: User,
@Body() updateProfileDto: UpdateUserProfileDto,
- ): Promise {
+ ) {
return this.updateProfile(user, updateProfileDto);
}
@@ -153,7 +154,7 @@ export class UsersController {
async updateProfile(
@CurrentUser() user: User,
@Body() updateProfileDto: UpdateUserProfileDto,
- ): Promise {
+ ) {
const updated = await this.usersService.updateProfile(
user.id,
updateProfileDto,
@@ -166,7 +167,7 @@ export class UsersController {
description: 'Profile updated',
});
- return updated;
+ return this.usersService.sanitizeUser(updated);
}
/**
@@ -178,7 +179,7 @@ export class UsersController {
async updateAvatar(
@CurrentUser() user: User,
@Body() body: { avatarUrl: string },
- ): Promise {
+ ) {
const updated = await this.usersService.updateAvatar(
user.id,
body.avatarUrl,
@@ -191,7 +192,7 @@ export class UsersController {
description: 'Avatar uploaded',
});
- return updated;
+ return this.usersService.sanitizeUser(updated);
}
/**
diff --git a/backend/src/modules/users/users.service.ts b/backend/src/modules/users/users.service.ts
index cd0a20b3..b58d5007 100644
--- a/backend/src/modules/users/users.service.ts
+++ b/backend/src/modules/users/users.service.ts
@@ -10,6 +10,19 @@ import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { UpdateUserProfileDto } from './dto/update-user-profile.dto';
+export type SafeUserProfile = Omit<
+ User,
+ | 'password'
+ | 'emailVerificationToken'
+ | 'emailVerificationExpires'
+ | 'phoneVerificationCode'
+ | 'phoneVerificationExpires'
+ | 'passwordResetToken'
+ | 'passwordResetExpires'
+ | 'getActiveRoles'
+ | 'getProfileCompletionScore'
+> & { isVerified: boolean };
+
@Injectable()
export class UsersService {
constructor(
@@ -59,6 +72,29 @@ export class UsersService {
return await this.userRepository.save(user);
}
+ sanitizeUser(user: User): SafeUserProfile {
+ const {
+ password,
+ emailVerificationToken,
+ emailVerificationExpires,
+ phoneVerificationCode,
+ phoneVerificationExpires,
+ passwordResetToken,
+ passwordResetExpires,
+ getActiveRoles,
+ getProfileCompletionScore,
+ ...safeUser
+ } = user as User & {
+ getActiveRoles: unknown;
+ getProfileCompletionScore: unknown;
+ };
+
+ return {
+ ...safeUser,
+ isVerified: user.isVerified,
+ };
+ }
+
/**
* Update user profile
*/
@@ -76,6 +112,18 @@ export class UsersService {
}
}
+ if (updateProfileDto.email && updateProfileDto.email !== user.email) {
+ user.emailVerified = false;
+ user.emailVerificationToken = null;
+ user.emailVerificationExpires = null;
+ }
+
+ if (updateProfileDto.phone && updateProfileDto.phone !== user.phone) {
+ user.phoneVerified = false;
+ user.phoneVerificationCode = null;
+ user.phoneVerificationExpires = null;
+ }
+
// if dateOfBirth provided as string, convert to Date object
if (updateProfileDto.dateOfBirth) {
// allow either Date or string
@@ -115,6 +163,9 @@ export class UsersService {
lastName: user.lastName,
email: user.email,
avatarUrl: user.avatarUrl,
+ emailVerified: user.emailVerified,
+ phoneVerified: user.phoneVerified,
+ isVerified: user.isVerified,
createdAt: user.createdAt,
};
}
diff --git a/src/components/Profile/ProfileEditForm.module.css b/src/components/Profile/ProfileEditForm.module.css
index a033576e..32c78a2c 100644
--- a/src/components/Profile/ProfileEditForm.module.css
+++ b/src/components/Profile/ProfileEditForm.module.css
@@ -14,6 +14,74 @@
margin-bottom: 32px;
}
+.verificationCard {
+ margin-bottom: 24px;
+ padding: 20px;
+ border: 1px solid #dbe4f0;
+ border-radius: 12px;
+ background: linear-gradient(135deg, #f8fbff 0%, #eef6ff 100%);
+}
+
+.verificationHeader {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 16px;
+}
+
+.verificationCopy {
+ margin: 8px 0 0 0;
+ font-size: 14px;
+ color: #4b5563;
+}
+
+.verificationGrid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 12px;
+ margin-top: 18px;
+}
+
+.verificationItem {
+ padding: 14px;
+ border-radius: 10px;
+ background: rgba(255, 255, 255, 0.86);
+ border: 1px solid #dbe4f0;
+ color: #1f2937;
+}
+
+.verificationLabel {
+ display: block;
+ margin-bottom: 6px;
+ font-size: 12px;
+ font-weight: 600;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ color: #64748b;
+}
+
+.verifiedBadge,
+.pendingBadge {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 90px;
+ padding: 8px 12px;
+ border-radius: 999px;
+ font-size: 12px;
+ font-weight: 700;
+}
+
+.verifiedBadge {
+ background: #dcfce7;
+ color: #166534;
+}
+
+.pendingBadge {
+ background: #fef3c7;
+ color: #92400e;
+}
+
.section:last-of-type {
margin-bottom: 24px;
}
@@ -134,6 +202,14 @@
margin-bottom: 24px;
}
+ .verificationHeader {
+ flex-direction: column;
+ }
+
+ .verificationGrid {
+ grid-template-columns: 1fr;
+ }
+
.sectionTitle {
font-size: 16px;
}
diff --git a/src/components/Profile/ProfileEditForm.tsx b/src/components/Profile/ProfileEditForm.tsx
index a913dced..109cffd3 100644
--- a/src/components/Profile/ProfileEditForm.tsx
+++ b/src/components/Profile/ProfileEditForm.tsx
@@ -11,6 +11,13 @@ interface ProfileEditFormProps {
email: string;
phone?: string;
avatarUrl?: string;
+ emailVerified?: boolean;
+ phoneVerified?: boolean;
+ isVerified?: boolean;
+ dateOfBirth?: string;
+ address?: string;
+ city?: string;
+ country?: string;
};
onSubmit: (data: any) => Promise;
onAvatarUpload: (file: File) => Promise;
@@ -195,6 +202,36 @@ export const ProfileEditForm: React.FC = ({
/>
)}
+ {user && (
+
+
+
+
Verification Status
+
+ New accounts need both email and phone verification before sign-in is fully enabled.
+
+
+
+ {user.isVerified ? 'Verified' : 'Pending'}
+
+
+
+
+ Email
+ {user.emailVerified ? 'Verified' : 'Pending'}
+
+
+ Phone
+ {user.phoneVerified ? 'Verified' : 'Pending'}
+
+
+
+ )}
+
- Need a new verification link?
+ Need a new verification link or SMS code?
Register again
+
+ Open verification center
+
Back to login
@@ -110,4 +124,4 @@ export default function VerifyEmailPage() {
);
-}
\ No newline at end of file
+}
diff --git a/src/styles/pages/ProfilePage.module.css b/src/styles/pages/ProfilePage.module.css
index f974fd89..39197cda 100644
--- a/src/styles/pages/ProfilePage.module.css
+++ b/src/styles/pages/ProfilePage.module.css
@@ -10,6 +10,14 @@
text-align: center;
}
+.titleRow {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 12px;
+ flex-wrap: wrap;
+}
+
.header h1 {
margin: 0 0 8px 0;
font-size: 32px;
@@ -23,6 +31,27 @@
color: #6b7280;
}
+.verifiedBadge,
+.pendingBadge {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 8px 12px;
+ border-radius: 999px;
+ font-size: 12px;
+ font-weight: 700;
+}
+
+.verifiedBadge {
+ background: #dcfce7;
+ color: #166534;
+}
+
+.pendingBadge {
+ background: #fef3c7;
+ color: #92400e;
+}
+
.error {
max-width: 600px;
margin: 0 auto 16px auto;