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'} +
+
+
+ )} +

Profile Picture

diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx index 0c50c4f7..f96aef7b 100644 --- a/src/contexts/AuthContext.tsx +++ b/src/contexts/AuthContext.tsx @@ -1,4 +1,5 @@ import React, { createContext, useContext, useEffect, useState } from 'react'; +import { getApiBaseUrl } from '../lib/api/apiBaseUrl'; export interface User { id: string; @@ -8,6 +9,8 @@ export interface User { phone?: string; avatarUrl?: string; emailVerified: boolean; + phoneVerified: boolean; + isVerified: boolean; isActive: boolean; createdAt: string; updatedAt: string; @@ -30,13 +33,28 @@ export interface AuthContextType extends AuthState { login: (email: string, password: string) => Promise; loginWith2FA: (email: string, password: string, totpToken: string) => Promise; recoverWith2FA: (email: string, password: string, backupCode: string) => Promise; - register: (email: string, password: string, firstName: string, lastName: string) => Promise; + register: (email: string, password: string, firstName: string, lastName: string, phone: string) => Promise; logout: () => Promise; refreshTokens: () => Promise; clearError: () => void; resetPassword: (token: string, newPassword: string) => Promise; forgotPassword: (email: string) => Promise; - verifyEmail: (token: string) => Promise; + verifyEmail: (token: string) => Promise<{ + message: string; + email?: string; + emailVerified: boolean; + phoneVerified: boolean; + isVerified: boolean; + }>; + verifyPhone: (email: string, code: string) => Promise<{ + message: string; + email?: string; + emailVerified: boolean; + phoneVerified: boolean; + isVerified: boolean; + }>; + resendEmailVerification: (email: string) => Promise; + resendPhoneVerification: (email: string) => Promise; } const AuthContext = createContext(undefined); @@ -53,7 +71,7 @@ interface AuthProviderProps { children: React.ReactNode; } -const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; +const API_BASE_URL = getApiBaseUrl(); export const AuthProvider: React.FC = ({ children }) => { const [state, setState] = useState({ @@ -109,6 +127,7 @@ export const AuthProvider: React.FC = ({ children }) => { // Store in localStorage localStorage.setItem('auth_tokens', JSON.stringify(tokens)); localStorage.setItem('auth_user', JSON.stringify(user)); + localStorage.setItem('authToken', tokens.accessToken); setupTokenRefresh(); }; @@ -124,6 +143,7 @@ export const AuthProvider: React.FC = ({ children }) => { localStorage.removeItem('auth_tokens'); localStorage.removeItem('auth_user'); + localStorage.removeItem('authToken'); clearTokenRefresh(); }; @@ -262,15 +282,16 @@ export const AuthProvider: React.FC = ({ children }) => { email: string, password: string, firstName: string, - lastName: string + lastName: string, + phone: string, ): Promise => { setLoading(true); clearError(); try { - const data = await makeRequest('/auth/register', { + await makeRequest('/auth/register', { method: 'POST', - body: JSON.stringify({ email, password, firstName, lastName }), + body: JSON.stringify({ email, password, firstName, lastName, phone }), }); // Registration doesn't return tokens, just user data @@ -361,12 +382,18 @@ export const AuthProvider: React.FC = ({ children }) => { } }; - const verifyEmail = async (token: string): Promise => { + const verifyEmail = async (token: string): Promise<{ + message: string; + email?: string; + emailVerified: boolean; + phoneVerified: boolean; + isVerified: boolean; + }> => { setLoading(true); clearError(); try { - await makeRequest('/auth/verify-email', { + return await makeRequest('/auth/verify-email', { method: 'POST', body: JSON.stringify({ token }), }); @@ -378,6 +405,43 @@ export const AuthProvider: React.FC = ({ children }) => { } }; + const verifyPhone = async (email: string, code: string): Promise<{ + message: string; + email?: string; + emailVerified: boolean; + phoneVerified: boolean; + isVerified: boolean; + }> => { + setLoading(true); + clearError(); + + try { + return await makeRequest('/auth/verify-phone', { + method: 'POST', + body: JSON.stringify({ email, code }), + }); + } catch (error) { + setError(error instanceof Error ? error.message : 'Phone verification failed'); + throw error; + } finally { + setLoading(false); + } + }; + + const resendEmailVerification = async (email: string): Promise => { + await makeRequest('/auth/resend-email-verification', { + method: 'POST', + body: JSON.stringify({ email }), + }); + }; + + const resendPhoneVerification = async (email: string): Promise => { + await makeRequest('/auth/resend-phone-verification', { + method: 'POST', + body: JSON.stringify({ email }), + }); + }; + const value: AuthContextType = { ...state, login, @@ -390,6 +454,9 @@ export const AuthProvider: React.FC = ({ children }) => { resetPassword, forgotPassword, verifyEmail, + verifyPhone, + resendEmailVerification, + resendPhoneVerification, }; return ( @@ -397,4 +464,4 @@ export const AuthProvider: React.FC = ({ children }) => { {children} ); -}; \ No newline at end of file +}; diff --git a/src/lib/api/apiBaseUrl.ts b/src/lib/api/apiBaseUrl.ts new file mode 100644 index 00000000..cbca5ffb --- /dev/null +++ b/src/lib/api/apiBaseUrl.ts @@ -0,0 +1,20 @@ +const DEFAULT_API_BASE_URL = 'http://localhost:3000/api/v1'; + +export function getApiBaseUrl(): string { + const configuredBaseUrl = process.env.NEXT_PUBLIC_API_URL?.trim(); + + if (!configuredBaseUrl) { + return DEFAULT_API_BASE_URL; + } + + const normalizedBaseUrl = configuredBaseUrl.replace(/\/$/, ''); + if (/\/api\/v\d+$/i.test(normalizedBaseUrl)) { + return normalizedBaseUrl; + } + + if (/\/api$/i.test(normalizedBaseUrl)) { + return `${normalizedBaseUrl}/v1`; + } + + return `${normalizedBaseUrl}/api/v1`; +} diff --git a/src/lib/api/twoFactorAPI.ts b/src/lib/api/twoFactorAPI.ts index 1c3e3204..7dd05692 100644 --- a/src/lib/api/twoFactorAPI.ts +++ b/src/lib/api/twoFactorAPI.ts @@ -1,4 +1,6 @@ -const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; +import { getApiBaseUrl } from './apiBaseUrl'; + +const API_BASE_URL = getApiBaseUrl(); export interface TwoFactorSetupResponse { qrCodeUrl: string; @@ -129,4 +131,4 @@ export const twoFactorAPI = { return response.json(); }, -}; \ No newline at end of file +}; diff --git a/src/lib/api/userAPI.ts b/src/lib/api/userAPI.ts index c017b63f..8f708764 100644 --- a/src/lib/api/userAPI.ts +++ b/src/lib/api/userAPI.ts @@ -1,6 +1,7 @@ import axios, { AxiosInstance } from 'axios'; +import { getApiBaseUrl } from './apiBaseUrl'; -const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api'; +const API_BASE_URL = getApiBaseUrl(); export type OnboardingStepId = 'welcome' | 'profile_setup' | 'add_pet' | 'notifications' | 'explore'; @@ -71,6 +72,9 @@ export interface UserProfile { lastName: string; phone?: string; avatarUrl?: string; + emailVerified: boolean; + phoneVerified: boolean; + isVerified: boolean; dateOfBirth?: string; address?: string; city?: string; @@ -121,7 +125,7 @@ class UserManagementAPI { // Add token to requests this.api.interceptors.request.use((config) => { - const token = localStorage.getItem('authToken'); + const token = this.getAccessToken(); if (token) { config.headers.Authorization = `Bearer ${token}`; } @@ -129,6 +133,25 @@ class UserManagementAPI { }); } + private getAccessToken(): string | null { + const legacyToken = localStorage.getItem('authToken'); + if (legacyToken) { + return legacyToken; + } + + const storedTokens = localStorage.getItem('auth_tokens'); + if (!storedTokens) { + return null; + } + + try { + const parsed = JSON.parse(storedTokens); + return parsed?.accessToken ?? null; + } catch { + return null; + } + } + // Profile endpoints async getCurrentProfile(): Promise { const response = await this.api.get('/me/profile'); @@ -162,7 +185,7 @@ class UserManagementAPI { withCredentials: true, }); - const token = localStorage.getItem('authToken'); + const token = this.getAccessToken(); if (token) { uploadsApi.defaults.headers.common.Authorization = `Bearer ${token}`; } diff --git a/src/pages/api/webhooks/twilio/status.ts b/src/pages/api/webhooks/twilio/status.ts index aa61335c..166d1f2c 100644 --- a/src/pages/api/webhooks/twilio/status.ts +++ b/src/pages/api/webhooks/twilio/status.ts @@ -1,6 +1,7 @@ import type { NextApiRequest, NextApiResponse } from 'next'; +import { getApiBaseUrl } from '../../../../lib/api/apiBaseUrl'; -const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api'; +const API_BASE_URL = getApiBaseUrl(); /** * Twilio status callback webhook. diff --git a/src/pages/profile.tsx b/src/pages/profile.tsx index ebc9c3cb..e04819da 100644 --- a/src/pages/profile.tsx +++ b/src/pages/profile.tsx @@ -83,7 +83,14 @@ export default function ProfilePage() { return (
-

User Profile

+
+

User Profile

+ {user && ( + + {user.isVerified ? 'Verified Account' : 'Verification Pending'} + + )} +

Manage your personal information and profile

diff --git a/src/pages/register.tsx b/src/pages/register.tsx index 2d8ae393..849b8537 100644 --- a/src/pages/register.tsx +++ b/src/pages/register.tsx @@ -10,10 +10,10 @@ export default function RegisterPage() { confirmPassword: '', firstName: '', lastName: '', + phone: '', }); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(''); - const [success, setSuccess] = useState(false); const { register } = useAuth(); const router = useRouter(); @@ -30,6 +30,11 @@ export default function RegisterPage() { setError('Passwords do not match'); return false; } + + if (!/^\+?[1-9]\d{7,14}$/.test(formData.phone.replace(/\s+/g, ''))) { + setError('Enter a valid phone number in international format'); + return false; + } if (formData.password.length < 8) { setError('Password must be at least 8 characters long'); @@ -54,9 +59,10 @@ export default function RegisterPage() { formData.email, formData.password, formData.firstName, - formData.lastName + formData.lastName, + formData.phone, ); - setSuccess(true); + router.push(`/verify-account?email=${encodeURIComponent(formData.email)}`); } catch (err) { setError(err instanceof Error ? err.message : 'Registration failed'); } finally { @@ -64,32 +70,6 @@ export default function RegisterPage() { } }; - if (success) { - return ( -
-
-
-
- - - -
-

Registration Successful!

-

- We've sent a verification email to {formData.email}. - Please check your email and click the verification link to activate your account. -

-
- - Return to login - -
-
-
-
- ); - } - return (
@@ -156,6 +136,26 @@ export default function RegisterPage() { />
+
+ + +

+ Use an SMS-capable number so we can send your verification code. +

+
+
); -} \ No newline at end of file +} diff --git a/src/pages/verify-account.tsx b/src/pages/verify-account.tsx new file mode 100644 index 00000000..5de8c4ff --- /dev/null +++ b/src/pages/verify-account.tsx @@ -0,0 +1,209 @@ +import { FormEvent, useEffect, useMemo, useState } from 'react'; +import Link from 'next/link'; +import { useRouter } from 'next/router'; +import { useAuth } from '../contexts/AuthContext'; + +export default function VerifyAccountPage() { + const router = useRouter(); + const initialEmail = useMemo(() => { + const value = router.query.email; + return typeof value === 'string' ? value : ''; + }, [router.query.email]); + const [email, setEmail] = useState(''); + const [code, setCode] = useState(''); + const [emailVerified, setEmailVerified] = useState(false); + const [phoneVerified, setPhoneVerified] = useState(false); + const [message, setMessage] = useState(''); + const [error, setError] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + const [isResendingEmail, setIsResendingEmail] = useState(false); + const [isResendingPhone, setIsResendingPhone] = useState(false); + const { + verifyPhone, + resendEmailVerification, + resendPhoneVerification, + } = useAuth(); + + useEffect(() => { + setEmail(initialEmail); + }, [initialEmail]); + + useEffect(() => { + if (router.query.emailVerified === '1') { + setEmailVerified(true); + } + }, [router.query.emailVerified]); + + const handleVerifyPhone = async (event: FormEvent) => { + event.preventDefault(); + if (!email) { + setError('We could not determine which account to verify.'); + return; + } + + setIsSubmitting(true); + setError(''); + setMessage(''); + + try { + const result = await verifyPhone(email, code); + setPhoneVerified(result.phoneVerified); + setEmailVerified(result.emailVerified); + setMessage(result.message); + if (result.isVerified) { + router.push('/login?verified=1'); + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Phone verification failed'); + } finally { + setIsSubmitting(false); + } + }; + + const handleResendEmail = async () => { + if (!email) { + setError('Enter registration again to request a new email link.'); + return; + } + + setIsResendingEmail(true); + setError(''); + setMessage(''); + try { + await resendEmailVerification(email); + setMessage('A fresh email verification link has been sent.'); + } catch (err) { + setError(err instanceof Error ? err.message : 'Unable to resend email verification'); + } finally { + setIsResendingEmail(false); + } + }; + + const handleResendPhone = async () => { + if (!email) { + setError('Enter registration again to request a new phone code.'); + return; + } + + setIsResendingPhone(true); + setError(''); + setMessage(''); + try { + await resendPhoneVerification(email); + setMessage('A new SMS verification code has been sent.'); + } catch (err) { + setError(err instanceof Error ? err.message : 'Unable to resend phone verification'); + } finally { + setIsResendingPhone(false); + } + }; + + return ( +
+
+
+

Verify your account

+

+ Finish email and phone verification to unlock your new PetChain account. +

+
+ + setEmail(event.target.value)} + className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-gray-900 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500" + placeholder="you@example.com" + /> +
+
+ +
+
+ Email verification + + {emailVerified ? 'Verified' : 'Pending'} + +
+

+ Use the link sent to your inbox. It expires in 24 hours. +

+ +
+ + +
+ Phone verification + + {phoneVerified ? 'Verified' : 'Pending'} + +
+

+ Enter the 6-digit code from the SMS we sent. Codes expire in 24 hours. +

+
+ + setCode(event.target.value.replace(/\D/g, '').slice(0, 6))} + className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-gray-900 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500" + placeholder="123456" + required + /> +
+ + + + + {message && ( +
+ {message} +
+ )} + + {error && ( +
+ {error} +
+ )} + +
+ + Back to login + +
+
+
+ ); +} diff --git a/src/pages/verify-email.tsx b/src/pages/verify-email.tsx index 27d3d4db..6e83216d 100644 --- a/src/pages/verify-email.tsx +++ b/src/pages/verify-email.tsx @@ -7,7 +7,8 @@ export default function VerifyEmailPage() { const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(''); const [success, setSuccess] = useState(false); - const [token, setToken] = useState(''); + const [verifiedEmail, setVerifiedEmail] = useState(''); + const [phoneVerified, setPhoneVerified] = useState(false); const { verifyEmail } = useAuth(); const router = useRouter(); @@ -15,7 +16,6 @@ export default function VerifyEmailPage() { // Get token from URL query parameters if (router.query.token) { const tokenParam = router.query.token as string; - setToken(tokenParam); handleVerification(tokenParam); } else { setIsLoading(false); @@ -25,7 +25,9 @@ export default function VerifyEmailPage() { const handleVerification = async (verificationToken: string) => { try { - await verifyEmail(verificationToken); + const result = await verifyEmail(verificationToken); + setVerifiedEmail(result.email || ''); + setPhoneVerified(result.phoneVerified); setSuccess(true); } catch (err) { setError(err instanceof Error ? err.message : 'Email verification failed'); @@ -67,12 +69,21 @@ export default function VerifyEmailPage() {

Email Verified!

- Your email address has been successfully verified. You can now log in to your account. + Your email address has been successfully verified.

- - Go to login - + {verifiedEmail && !phoneVerified ? ( + + Verify your phone number + + ) : ( + + Go to login + + )}
@@ -95,12 +106,15 @@ export default function VerifyEmailPage() {

- 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;