From 3f7364dbf008059a3be854a0bf5e762880fb446c Mon Sep 17 00:00:00 2001 From: rameen Date: Sat, 4 Jul 2026 00:01:58 +0330 Subject: [PATCH] forgot password in login page and account info implemented, sms.ir service works fine with sandbox key. --- backend/.env.example | 5 + .../migration.sql | 25 ++ backend/prisma/schema.prisma | 18 ++ backend/src/common/utils/mobile.util.ts | 20 ++ backend/src/configs/configurations.ts | 8 + backend/src/modules/auth/auth.controller.ts | 68 ++++++ backend/src/modules/auth/auth.module.ts | 2 + backend/src/modules/auth/auth.service.ts | 214 +++++++++++++++++- .../modules/auth/dto/change-password.dto.ts | 11 + .../modules/auth/dto/forgot-password.dto.ts | 22 ++ backend/src/modules/auth/dto/register.dto.ts | 11 +- backend/src/modules/sms/sms.module.ts | 8 + backend/src/modules/sms/sms.service.ts | 63 ++++++ frontend/messages/en.json | 29 ++- frontend/messages/fa.json | 29 ++- frontend/messages/nl.json | 29 ++- .../(dashboard)/settings/account/page.tsx | 168 +++++++++++++- .../(public)/forgot-password/page.tsx | 206 +++++++++++++++++ .../app/[locale]/(public)/register/page.tsx | 29 ++- .../src/components/ui/shared/Dropdown.tsx | 5 +- frontend/src/components/ui/shared/Input.tsx | 9 +- frontend/src/lib/api/auth.ts | 23 +- frontend/src/lib/api/client.ts | 4 +- frontend/src/lib/hooks/useAuth.tsx | 21 +- frontend/src/types/auth.ts | 10 + frontend/src/types/organization.ts | 1 + 26 files changed, 1000 insertions(+), 38 deletions(-) create mode 100644 backend/prisma/migrations/20260703120000_add_user_mobile_and_phone_verification/migration.sql create mode 100644 backend/src/common/utils/mobile.util.ts create mode 100644 backend/src/modules/auth/dto/change-password.dto.ts create mode 100644 backend/src/modules/auth/dto/forgot-password.dto.ts create mode 100644 backend/src/modules/sms/sms.module.ts create mode 100644 backend/src/modules/sms/sms.service.ts create mode 100644 frontend/src/app/[locale]/(public)/forgot-password/page.tsx diff --git a/backend/.env.example b/backend/.env.example index 77e74a8..c4556b3 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -39,3 +39,8 @@ SMTP_HOST=smtp.gmail.com SMTP_PORT=587 SMTP_USER=your_email@gmail.com SMTP_PASSWORD=your_app_password + +# SMS (sms.ir — use Sandbox API key for development) +# SMS_IR_API_KEY=lwbK7hxmjimNjFS4g5DWahh75EKCgJUfcUIinUQzfQXwXkSp +SMS_IR_API_KEY=4QKMiSU4Kh7tWPLCdRMV0QpDh8WgF33YkWRS18BcG3vf4QHi +SMS_IR_TEMPLATE_ID=123456 diff --git a/backend/prisma/migrations/20260703120000_add_user_mobile_and_phone_verification/migration.sql b/backend/prisma/migrations/20260703120000_add_user_mobile_and_phone_verification/migration.sql new file mode 100644 index 0000000..dcfc09e --- /dev/null +++ b/backend/prisma/migrations/20260703120000_add_user_mobile_and_phone_verification/migration.sql @@ -0,0 +1,25 @@ +-- AlterTable +ALTER TABLE "users" ADD COLUMN "mobile" TEXT; + +-- CreateIndex +CREATE UNIQUE INDEX "users_mobile_key" ON "users"("mobile"); + +-- CreateTable +CREATE TABLE "phone_verification_codes" ( + "id" TEXT NOT NULL, + "userId" TEXT, + "mobile" TEXT NOT NULL, + "codeHash" TEXT NOT NULL, + "purpose" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "verifiedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "phone_verification_codes_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "phone_verification_codes_mobile_purpose_createdAt_idx" ON "phone_verification_codes"("mobile", "purpose", "createdAt"); + +-- AddForeignKey +ALTER TABLE "phone_verification_codes" ADD CONSTRAINT "phone_verification_codes_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index ffe59bc..64a0b78 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -11,6 +11,7 @@ datasource db { model User { id String @id @default(uuid()) email String @unique + mobile String? @unique passwordHash String? googleId String? @unique facebookId String? @unique @@ -23,6 +24,7 @@ model User { sessions Session[] // 👈 ADD THIS - opposite relation for Session sentStaffInvites StaffInvitation[] sentOrganizationInvitations OrganizationInvitation[] + phoneVerificationCodes PhoneVerificationCode[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -30,6 +32,22 @@ model User { @@map("users") } +model PhoneVerificationCode { + id String @id @default(uuid()) + userId String? + mobile String + codeHash String + purpose String + expiresAt DateTime + verifiedAt DateTime? + createdAt DateTime @default(now()) + + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([mobile, purpose, createdAt]) + @@map("phone_verification_codes") +} + model OrganizationType { id String @id @default(uuid()) name String @unique // "CLINIC" or "LAB" diff --git a/backend/src/common/utils/mobile.util.ts b/backend/src/common/utils/mobile.util.ts new file mode 100644 index 0000000..8de4c67 --- /dev/null +++ b/backend/src/common/utils/mobile.util.ts @@ -0,0 +1,20 @@ +const IRAN_MOBILE_PATTERN = /^9\d{9}$/; + +/** Normalize Iranian mobile to sms.ir format (e.g. 9123456789). */ +export function normalizeIranMobile(input: string): string { + let digits = input.replace(/\D/g, ''); + + if (digits.startsWith('98') && digits.length === 12) { + digits = digits.slice(2); + } + + if (digits.startsWith('0') && digits.length === 11) { + digits = digits.slice(1); + } + + return digits; +} + +export function isValidIranMobile(input: string): boolean { + return IRAN_MOBILE_PATTERN.test(normalizeIranMobile(input)); +} diff --git a/backend/src/configs/configurations.ts b/backend/src/configs/configurations.ts index e4d1c38..a1000f5 100644 --- a/backend/src/configs/configurations.ts +++ b/backend/src/configs/configurations.ts @@ -46,6 +46,10 @@ export interface Config { ttl: number; limit: number; }; + sms: { + apiKey: string | null; + templateId: number; + }; } export default (): Config => { @@ -100,5 +104,9 @@ export default (): Config => { ttl: getEnvVarAsNumber('THROTTLE_TTL', 60), limit: getEnvVarAsNumber('THROTTLE_LIMIT', 100), }, + sms: { + apiKey: process.env.SMS_IR_API_KEY?.trim() || null, + templateId: getEnvVarAsNumber('SMS_IR_TEMPLATE_ID', 123456), + }, }; }; \ No newline at end of file diff --git a/backend/src/modules/auth/auth.controller.ts b/backend/src/modules/auth/auth.controller.ts index c3380ca..5045b15 100644 --- a/backend/src/modules/auth/auth.controller.ts +++ b/backend/src/modules/auth/auth.controller.ts @@ -31,11 +31,17 @@ import { CreateOrganizationDto } from './dto/create-organization.dto'; import { JwtAuthGuard } from './guards/jwt-auth.guard'; import { LocalAuthGuard } from './guards/local-auth.guard'; import { UpdateLanguageDto } from './dto/update-language.dto'; +import { + ForgotPasswordSendCodeDto, + ForgotPasswordVerifyDto, +} from './dto/forgot-password.dto'; +import { ChangePasswordDto } from './dto/change-password.dto'; @ApiTags('auth') @Controller('auth') export class AuthController { private static readonly REMEMBER_ME_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; + private static readonly PASSWORD_RESET_MAX_AGE_MS = 15 * 60 * 1000; constructor(private readonly authService: AuthService) {} @@ -170,6 +176,67 @@ export class AuthController { return this.authService.updateLanguage(req.user.id, dto); } + @Patch('profile/password') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Change account password' }) + async changePassword( + @Req() req, + @Body() dto: ChangePasswordDto, + @Res({ passthrough: true }) res: Response, + ) { + const skipCurrentPassword = req?.cookies?.passwordResetVerified === '1'; + + const result = await this.authService.changePassword( + req.user.id, + dto.currentPassword, + dto.newPassword, + skipCurrentPassword, + ); + + this.clearAuthCookies(res); + res.clearCookie('passwordResetVerified', this.baseCookieOptions()); + + return result; + } + + @Post('forgot-password/send-code') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Send forgot-password SMS verification code' }) + async sendForgotPasswordCode(@Body() dto: ForgotPasswordSendCodeDto) { + return this.authService.sendForgotPasswordCode(dto); + } + + @Post('forgot-password/verify') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Verify SMS code and sign in for password reset' }) + async verifyForgotPasswordCode( + @Body() dto: ForgotPasswordVerifyDto, + @Res({ passthrough: true }) res: Response, + ) { + const result = await this.authService.verifyForgotPasswordCode(dto); + + this.setAuthCookies( + res, + result.data.accessToken, + result.data.refreshToken, + ); + + res.cookie('passwordResetVerified', '1', { + ...this.baseCookieOptions(), + maxAge: AuthController.PASSWORD_RESET_MAX_AGE_MS, + }); + + return { + success: true, + data: { + user: result.data.user, + organizations: result.data.organizations, + redirectTo: '/settings/account', + }, + }; + } + @Get('subscription-alert') @UseGuards(JwtAuthGuard) @ApiBearerAuth('JWT-auth') @@ -308,5 +375,6 @@ export class AuthController { res.clearCookie('accessToken', options); res.clearCookie('refreshToken', options); res.clearCookie('authRemember', options); + res.clearCookie('passwordResetVerified', options); } } \ No newline at end of file diff --git a/backend/src/modules/auth/auth.module.ts b/backend/src/modules/auth/auth.module.ts index 6dd2dd8..3bdf0aa 100644 --- a/backend/src/modules/auth/auth.module.ts +++ b/backend/src/modules/auth/auth.module.ts @@ -8,10 +8,12 @@ import { AuthController } from './auth.controller'; import { PrismaService } from '../../../prisma/prisma.service'; import { LocalStrategy } from './strategies/local.strategy'; import { JwtStrategy } from './strategies/jwt.strategy'; +import { SmsModule } from '../sms/sms.module'; @Module({ imports: [ PassportModule, + SmsModule, JwtModule.registerAsync({ imports: [ConfigModule], useFactory: async (configService: ConfigService) => ({ diff --git a/backend/src/modules/auth/auth.service.ts b/backend/src/modules/auth/auth.service.ts index 17fcac4..87538d2 100644 --- a/backend/src/modules/auth/auth.service.ts +++ b/backend/src/modules/auth/auth.service.ts @@ -20,6 +20,18 @@ import { UpdateLanguageDto, } from './dto/update-language.dto'; import { JwtPayload } from './interfaces/jwt-payload.interface'; +import { SmsService } from '../sms/sms.service'; +import { + ForgotPasswordSendCodeDto, + ForgotPasswordVerifyDto, +} from './dto/forgot-password.dto'; +import { normalizeIranMobile } from '../../common/utils/mobile.util'; +import * as crypto from 'crypto'; + +const FORGOT_PASSWORD_PURPOSE = 'forgot_password'; +const VERIFICATION_CODE_TTL_MS = 10 * 60 * 1000; +const SEND_CODE_COOLDOWN_MS = 60 * 1000; +const PASSWORD_RESET_WINDOW_MS = 15 * 60 * 1000; const ALL_PERMISSIONS = [ 'TAB_TODAY_READ', @@ -57,6 +69,7 @@ export class AuthService { private prisma: PrismaService, private jwtService: JwtService, private configService: ConfigService, + private smsService: SmsService, ) { } private accessJwtSignOptions(): JwtSignOptions { @@ -203,13 +216,24 @@ export class AuthService { const { password, name, organizationName, organizationEmail, organizationType } = registerDto; const email = registerDto.email.trim().toLowerCase(); + if (!RegisterDto.isValidMobile(registerDto.mobile)) { + throw new BadRequestException('Please enter a valid mobile number'); + } + + const mobile = normalizeIranMobile(registerDto.mobile); + // 1. Check existing user - const existingUser = await this.prisma.user.findUnique({ - where: { email }, + const existingUser = await this.prisma.user.findFirst({ + where: { + OR: [{ email }, { mobile }], + }, }); if (existingUser) { - throw new ConflictException('User already exists. Please login and create a new organization from your account.'); + if (existingUser.email === email) { + throw new ConflictException('User already exists. Please login and create a new organization from your account.'); + } + throw new ConflictException('This mobile number is already registered.'); } // 2. Hash password @@ -221,6 +245,7 @@ export class AuthService { const user = await tx.user.create({ data: { email, + mobile, passwordHash: hashedPassword, name, trialUsedAt: new Date(), @@ -526,11 +551,17 @@ export class AuthService { /** * Change user password * @param userId - User ID - * @param oldPassword - Current password + * @param oldPassword - Current password (optional when reset verified via SMS) * @param newPassword - New password + * @param skipCurrentPassword - True when user verified mobile via forgot-password flow * @returns Success message */ - async changePassword(userId: string, oldPassword: string, newPassword: string) { + async changePassword( + userId: string, + oldPassword: string | undefined, + newPassword: string, + skipCurrentPassword = false, + ) { try { const user = await this.prisma.user.findUnique({ where: { id: userId }, @@ -540,22 +571,29 @@ export class AuthService { throw new BadRequestException('User not found or invalid password method'); } - // Verify old password - const isPasswordValid = await bcrypt.compare(oldPassword, user.passwordHash); - if (!isPasswordValid) { - throw new UnauthorizedException('Current password is incorrect'); + if (skipCurrentPassword) { + const hasRecentReset = await this.hasRecentPasswordResetVerification(userId); + if (!hasRecentReset) { + throw new UnauthorizedException('Password reset verification expired. Please verify your mobile again.'); + } + } else { + if (!oldPassword) { + throw new BadRequestException('Current password is required'); + } + + const isPasswordValid = await bcrypt.compare(oldPassword, user.passwordHash); + if (!isPasswordValid) { + throw new UnauthorizedException('Current password is incorrect'); + } } - // Hash new password const hashedPassword = await bcrypt.hash(newPassword, 10); - // Update password await this.prisma.user.update({ where: { id: userId }, data: { passwordHash: hashedPassword }, }); - // Invalidate all sessions for this user (force re-login) await this.prisma.session.deleteMany({ where: { userId }, }); @@ -572,6 +610,156 @@ export class AuthService { } } + async sendForgotPasswordCode(dto: ForgotPasswordSendCodeDto) { + if (!ForgotPasswordSendCodeDto.validateMobile(dto.mobile)) { + throw new BadRequestException('Please enter a valid mobile number'); + } + + const mobile = normalizeIranMobile(dto.mobile); + const user = await this.prisma.user.findUnique({ + where: { mobile }, + select: { id: true }, + }); + + if (!user) { + return { + success: true, + message: 'If this mobile number is registered, a verification code has been sent.', + }; + } + + const recentCode = await this.prisma.phoneVerificationCode.findFirst({ + where: { + mobile, + purpose: FORGOT_PASSWORD_PURPOSE, + createdAt: { gt: new Date(Date.now() - SEND_CODE_COOLDOWN_MS) }, + }, + orderBy: { createdAt: 'desc' }, + }); + + if (recentCode) { + throw new BadRequestException('Please wait before requesting another code'); + } + + const code = this.generateVerificationCode(); + const codeHash = this.hashVerificationCode(code); + + await this.prisma.phoneVerificationCode.create({ + data: { + userId: user.id, + mobile, + codeHash, + purpose: FORGOT_PASSWORD_PURPOSE, + expiresAt: new Date(Date.now() + VERIFICATION_CODE_TTL_MS), + }, + }); + + await this.smsService.sendVerificationCode(mobile, code); + + if (this.configService.get('NODE_ENV') === 'development') { + console.log(`[dev] forgot-password code for ${mobile}: ${code}`); + } + + return { + success: true, + message: 'If this mobile number is registered, a verification code has been sent.', + }; + } + + async verifyForgotPasswordCode(dto: ForgotPasswordVerifyDto) { + if (!ForgotPasswordSendCodeDto.validateMobile(dto.mobile)) { + throw new BadRequestException('Please enter a valid mobile number'); + } + + const mobile = normalizeIranMobile(dto.mobile); + const code = dto.code.trim(); + + const verification = await this.prisma.phoneVerificationCode.findFirst({ + where: { + mobile, + purpose: FORGOT_PASSWORD_PURPOSE, + verifiedAt: null, + expiresAt: { gt: new Date() }, + }, + orderBy: { createdAt: 'desc' }, + }); + + if (!verification || !this.isVerificationCodeValid(code, verification.codeHash)) { + throw new UnauthorizedException('Invalid or expired verification code'); + } + + await this.prisma.phoneVerificationCode.update({ + where: { id: verification.id }, + data: { verifiedAt: new Date() }, + }); + + const user = await this.prisma.user.findUnique({ + where: { mobile }, + include: { + memberships: { + include: { + organization: { + include: { + type: true, + plan: true, + }, + }, + permissions: { + include: { + permission: true, + }, + }, + }, + }, + }, + }); + + if (!user) { + throw new UnauthorizedException('Invalid or expired verification code'); + } + + const loginResult = await this.login( + { email: user.email, password: '' } as LoginDto, + user, + ); + + return { + success: true, + data: { + accessToken: loginResult.data.accessToken, + refreshToken: loginResult.data.refreshToken, + user: loginResult.data.user, + organizations: loginResult.data.organizations, + passwordResetVerified: true, + }, + }; + } + + async hasRecentPasswordResetVerification(userId: string): Promise { + const recent = await this.prisma.phoneVerificationCode.findFirst({ + where: { + userId, + purpose: FORGOT_PASSWORD_PURPOSE, + verifiedAt: { gt: new Date(Date.now() - PASSWORD_RESET_WINDOW_MS) }, + }, + orderBy: { verifiedAt: 'desc' }, + }); + + return Boolean(recent); + } + + private generateVerificationCode(): string { + return String(Math.floor(10000 + Math.random() * 90000)); + } + + private hashVerificationCode(code: string): string { + return crypto.createHash('sha256').update(code).digest('hex'); + } + + private isVerificationCodeValid(code: string, codeHash: string): boolean { + return this.hashVerificationCode(code.trim()) === codeHash; + } + /** * Get all active sessions for a user * @param userId - User ID @@ -954,12 +1142,14 @@ export class AuthService { email: string; name: string; language?: string | null; + mobile?: string | null; }) { return { id: user.id, email: user.email, name: user.name, language: user.language ?? 'en', + mobile: user.mobile ?? null, }; } } \ No newline at end of file diff --git a/backend/src/modules/auth/dto/change-password.dto.ts b/backend/src/modules/auth/dto/change-password.dto.ts new file mode 100644 index 0000000..14a9783 --- /dev/null +++ b/backend/src/modules/auth/dto/change-password.dto.ts @@ -0,0 +1,11 @@ +import { IsOptional, IsString, MinLength } from 'class-validator'; + +export class ChangePasswordDto { + @IsOptional() + @IsString() + currentPassword?: string; + + @IsString() + @MinLength(8) + newPassword: string; +} diff --git a/backend/src/modules/auth/dto/forgot-password.dto.ts b/backend/src/modules/auth/dto/forgot-password.dto.ts new file mode 100644 index 0000000..02ce7fe --- /dev/null +++ b/backend/src/modules/auth/dto/forgot-password.dto.ts @@ -0,0 +1,22 @@ +import { IsString, Matches, Length } from 'class-validator'; +import { isValidIranMobile } from '../../../common/utils/mobile.util'; + +export class ForgotPasswordSendCodeDto { + @IsString() + @Matches(/^[\d+\s()-]+$/, { message: 'Mobile number format is invalid' }) + mobile: string; + + static validateMobile(mobile: string): boolean { + return isValidIranMobile(mobile); + } +} + +export class ForgotPasswordVerifyDto { + @IsString() + @Matches(/^[\d+\s()-]+$/, { message: 'Mobile number format is invalid' }) + mobile: string; + + @IsString() + @Length(5, 6) + code: string; +} diff --git a/backend/src/modules/auth/dto/register.dto.ts b/backend/src/modules/auth/dto/register.dto.ts index 3556870..f3a795f 100644 --- a/backend/src/modules/auth/dto/register.dto.ts +++ b/backend/src/modules/auth/dto/register.dto.ts @@ -1,9 +1,14 @@ -import { IsEmail, IsString, MinLength, IsEnum } from 'class-validator'; +import { IsEmail, IsString, MinLength, IsEnum, Matches } from 'class-validator'; +import { isValidIranMobile } from '../../../common/utils/mobile.util'; export class RegisterDto { @IsEmail() email: string; + @IsString() + @Matches(/^[\d+\s()-]+$/, { message: 'Mobile number format is invalid' }) + mobile: string; + @IsString() @MinLength(8) password: string; @@ -19,4 +24,8 @@ export class RegisterDto { @IsEnum(['CLINIC', 'LAB']) organizationType: 'CLINIC' | 'LAB'; + + static isValidMobile(mobile: string): boolean { + return isValidIranMobile(mobile); + } } \ No newline at end of file diff --git a/backend/src/modules/sms/sms.module.ts b/backend/src/modules/sms/sms.module.ts new file mode 100644 index 0000000..cc282dd --- /dev/null +++ b/backend/src/modules/sms/sms.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { SmsService } from './sms.service'; + +@Module({ + providers: [SmsService], + exports: [SmsService], +}) +export class SmsModule {} diff --git a/backend/src/modules/sms/sms.service.ts b/backend/src/modules/sms/sms.service.ts new file mode 100644 index 0000000..aad61b1 --- /dev/null +++ b/backend/src/modules/sms/sms.service.ts @@ -0,0 +1,63 @@ +import { Injectable, InternalServerErrorException, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +interface SmsIrVerifyResponse { + status: number; + message: string; + data?: { + messageId: number; + cost: number; + }; +} + +@Injectable() +export class SmsService { + private readonly logger = new Logger(SmsService.name); + + constructor(private readonly configService: ConfigService) {} + + async sendVerificationCode(mobile: string, code: string): Promise { + const apiKey = this.configService.get('sms.apiKey'); + const templateId = this.configService.get('sms.templateId'); + + if (!apiKey) { + this.logger.warn(`SMS_IR_API_KEY not set — verification code for ${mobile}: ${code}`); + return; + } + + let response: Response; + try { + response = await fetch('https://api.sms.ir/v1/send/verify', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'text/plain', + 'x-api-key': apiKey, + }, + body: JSON.stringify({ + mobile, + templateId, + parameters: [{ name: 'Code', value: code }], + }), + }); + console.log('request', templateId , apiKey,mobile, code); + console.log('response', response); + + } catch (error) { + this.logger.error('sms.ir request failed', error); + throw new InternalServerErrorException('Failed to send verification code'); + } + + let payload: SmsIrVerifyResponse; + try { + payload = (await response.json()) as SmsIrVerifyResponse; + } catch { + throw new InternalServerErrorException('Invalid response from SMS provider'); + } + + if (!response.ok || payload.status !== 1) { + this.logger.error(`sms.ir error: ${payload.message}`); + throw new InternalServerErrorException('Failed to send verification code'); + } + } +} diff --git a/frontend/messages/en.json b/frontend/messages/en.json index f58cb41..1f22da3 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -130,7 +130,19 @@ "organizationEmailPlaceholder": "contact@sunshineclinic.com", "organizationType": "Organization type", "dentalClinic": "Dental Clinic", - "dentalLab": "Dental Lab" + "dentalLab": "Dental Lab", + "mobile": "Mobile number", + "mobilePlaceholder": "0912 345 6789", + "forgotPasswordTitle": "Reset your password", + "forgotPasswordSubtitle": "Enter the mobile number on your account. We will send a verification code.", + "codeSentHint": "Enter the verification code we sent to your mobile.", + "sendCode": "Send verification code", + "verificationCode": "Verification code", + "verificationCodePlaceholder": "12345", + "verifyAndContinue": "Verify and continue", + "backToSignIn": "Back to sign in", + "codeSendFailed": "Could not send verification code. Please try again.", + "verifyFailed": "Invalid or expired verification code." }, "landing": { "heroTitle": "Connect Dental Clinics & Labs", @@ -168,7 +180,10 @@ "organizationNameMinLength": "Organization name must be at least 2 characters", "organizationEmailInvalid": "Please enter a valid organization email", "organizationTypeRequired": "Please select organization type", - "passwordsDoNotMatch": "Passwords don't match" + "passwordsDoNotMatch": "Passwords don't match", + "mobileRequired": "Mobile number is required", + "mobileInvalid": "Please enter a valid Iranian mobile number", + "codeRequired": "Verification code is required" }, "today": { "welcomeBack": "Welcome back!!", @@ -517,6 +532,16 @@ "accountTitle": "Account", "accountSubtitle": "Profile and security settings for your login.", "accountPlaceholder": "Password change and profile editing will be wired here next (e.g. invite flow, reset password).", + "changePasswordTitle": "Change password", + "resetPasswordTitle": "Set a new password", + "resetPasswordSubtitle": "Your mobile was verified. Choose a new password for your account.", + "currentPassword": "Current password", + "newPassword": "New password", + "confirmNewPassword": "Confirm new password", + "updatePassword": "Update password", + "setNewPassword": "Save new password", + "passwordChanged": "Password updated. Please sign in again.", + "passwordChangeFailed": "Could not update password. Please try again.", "subscriptionsTitle": "Subscriptions", "subscriptionsSubtitle": "Your DyoLink workspace plan and seats for {orgName}. Clinic and lab income tracking stays under the sidebar Billing tab.", "noSubscriptionNotice": "This organization has no active subscription. Select a plan below to start the purchase process.", diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json index e87d189..91226af 100644 --- a/frontend/messages/fa.json +++ b/frontend/messages/fa.json @@ -130,7 +130,19 @@ "organizationEmailPlaceholder": "contact@sunshineclinic.com", "organizationType": "نوع سازمان", "dentalClinic": "کلینیک دندانپزشکی", - "dentalLab": "لابراتوار دندانپزشکی" + "dentalLab": "لابراتوار دندانپزشکی", + "mobile": "شماره موبایل", + "mobilePlaceholder": "۰۹۱۲ ۳۴۵ ۶۷۸۹", + "forgotPasswordTitle": "بازیابی رمز عبور", + "forgotPasswordSubtitle": "شماره موبایل ثبت‌شده در حساب خود را وارد کنید. کد تأیید برای شما ارسال می‌شود.", + "codeSentHint": "کد تأیید ارسال‌شده به موبایل خود را وارد کنید.", + "sendCode": "ارسال کد تأیید", + "verificationCode": "کد تأیید", + "verificationCodePlaceholder": "۱۲۳۴۵", + "verifyAndContinue": "تأیید و ادامه", + "backToSignIn": "بازگشت به ورود", + "codeSendFailed": "ارسال کد تأیید انجام نشد. دوباره تلاش کنید.", + "verifyFailed": "کد تأیید نامعتبر یا منقضی شده است." }, "landing": { "heroTitle": "اتصال کلینیک‌ها و لابراتوارهای دندانپزشکی", @@ -168,7 +180,10 @@ "organizationNameMinLength": "نام سازمان باید حداقل ۲ کاراکتر باشد", "organizationEmailInvalid": "لطفاً یک ایمیل سازمانی معتبر وارد کنید", "organizationTypeRequired": "لطفاً نوع سازمان را انتخاب کنید", - "passwordsDoNotMatch": "رمزهای عبور مطابقت ندارند" + "passwordsDoNotMatch": "رمزهای عبور مطابقت ندارند", + "mobileRequired": "شماره موبایل الزامی است", + "mobileInvalid": "لطفاً یک شماره موبایل ایرانی معتبر وارد کنید", + "codeRequired": "کد تأیید الزامی است" }, "today": { "welcomeBack": "خوش آمدید!!", @@ -517,6 +532,16 @@ "accountTitle": "حساب کاربری", "accountSubtitle": "تنظیمات پروفایل و امنیت برای ورود شما.", "accountPlaceholder": "تغییر رمز عبور و ویرایش پروفایل در مرحله بعدی در اینجا قرار می‌گیرند (مثلاً فرآیند دعوت، بازنشانی رمز عبور).", + "changePasswordTitle": "تغییر رمز عبور", + "resetPasswordTitle": "تنظیم رمز عبور جدید", + "resetPasswordSubtitle": "موبایل شما تأیید شد. رمز عبور جدید برای حساب خود انتخاب کنید.", + "currentPassword": "رمز عبور فعلی", + "newPassword": "رمز عبور جدید", + "confirmNewPassword": "تأیید رمز عبور جدید", + "updatePassword": "به‌روزرسانی رمز عبور", + "setNewPassword": "ذخیره رمز عبور جدید", + "passwordChanged": "رمز عبور به‌روزرسانی شد. لطفاً دوباره وارد شوید.", + "passwordChangeFailed": "به‌روزرسانی رمز عبور انجام نشد. دوباره تلاش کنید.", "subscriptionsTitle": "اشتراک‌ها", "subscriptionsSubtitle": "طرح و مجوزهای فضای کاری DyoLink شما برای {orgName}. پیگیری درآمد کلینیک و لابراتوار در برگه صورتحساب در نوار کناری قرار دارد.", "noSubscriptionNotice": "این سازمان اشتراک فعالی ندارد. برای شروع فرآیند خرید، یک طرح زیر را انتخاب کنید.", diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json index 517a742..212f021 100644 --- a/frontend/messages/nl.json +++ b/frontend/messages/nl.json @@ -130,7 +130,19 @@ "organizationEmailPlaceholder": "contact@sunshineclinic.com", "organizationType": "Organisatietype", "dentalClinic": "Tandartspraktijk", - "dentalLab": "Tandtechnisch Laboratorium" + "dentalLab": "Tandtechnisch Laboratorium", + "mobile": "Mobiel nummer", + "mobilePlaceholder": "0612 345 678", + "forgotPasswordTitle": "Wachtwoord herstellen", + "forgotPasswordSubtitle": "Voer het mobiele nummer van uw account in. We sturen een verificatiecode.", + "codeSentHint": "Voer de verificatiecode in die we naar uw mobiel hebben gestuurd.", + "sendCode": "Verificatiecode versturen", + "verificationCode": "Verificatiecode", + "verificationCodePlaceholder": "12345", + "verifyAndContinue": "Verifiëren en doorgaan", + "backToSignIn": "Terug naar inloggen", + "codeSendFailed": "Verificatiecode kon niet worden verstuurd. Probeer het opnieuw.", + "verifyFailed": "Ongeldige of verlopen verificatiecode." }, "landing": { "heroTitle": "Verbind Tandheelkundige Klinieken & Laboratoria", @@ -168,7 +180,10 @@ "organizationNameMinLength": "Organisatienaam moet minimaal 2 tekens bevatten", "organizationEmailInvalid": "Voer een geldig organisatie-e-mailadres in", "organizationTypeRequired": "Selecteer een organisatietype", - "passwordsDoNotMatch": "Wachtwoorden komen niet overeen" + "passwordsDoNotMatch": "Wachtwoorden komen niet overeen", + "mobileRequired": "Mobiel nummer is verplicht", + "mobileInvalid": "Voer een geldig Iraans mobiel nummer in", + "codeRequired": "Verificatiecode is verplicht" }, "today": { "welcomeBack": "Welkom terug!!", @@ -517,6 +532,16 @@ "accountTitle": "Account", "accountSubtitle": "Profiel- en beveiligingsinstellingen voor uw login.", "accountPlaceholder": "Wachtwoordwijziging en profielbewerking worden hierna hier aangesloten (bijv. uitnodigingsflow, wachtwoord herstellen).", + "changePasswordTitle": "Wachtwoord wijzigen", + "resetPasswordTitle": "Nieuw wachtwoord instellen", + "resetPasswordSubtitle": "Uw mobiel is geverifieerd. Kies een nieuw wachtwoord voor uw account.", + "currentPassword": "Huidig wachtwoord", + "newPassword": "Nieuw wachtwoord", + "confirmNewPassword": "Bevestig nieuw wachtwoord", + "updatePassword": "Wachtwoord bijwerken", + "setNewPassword": "Nieuw wachtwoord opslaan", + "passwordChanged": "Wachtwoord bijgewerkt. Log opnieuw in.", + "passwordChangeFailed": "Wachtwoord kon niet worden bijgewerkt. Probeer het opnieuw.", "subscriptionsTitle": "Abonnementen", "subscriptionsSubtitle": "Uw DyoLink-werkruimteplan en plaatsen voor {orgName}. Kliniek- en laboratoriuminkomsten blijven onder het tabblad Facturatie in de zijbalk.", "noSubscriptionNotice": "Deze organisatie heeft geen actief abonnement. Selecteer hieronder een abonnement om het aankoopproces te starten.", diff --git a/frontend/src/app/[locale]/(dashboard)/settings/account/page.tsx b/frontend/src/app/[locale]/(dashboard)/settings/account/page.tsx index e52411a..981e2ca 100644 --- a/frontend/src/app/[locale]/(dashboard)/settings/account/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/settings/account/page.tsx @@ -1,28 +1,180 @@ 'use client'; +import { useEffect, useMemo, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import * as z from 'zod'; import { useTranslations } from 'next-intl'; -import { Link } from '@/i18n/navigation'; +import { Link, useRouter } from '@/i18n/navigation'; +import { useSearchParams } from 'next/navigation'; +import { Lock } from 'lucide-react'; +import { useAuth } from '@/lib/hooks/useAuth'; +import { authApi } from '@/lib/api/auth'; +import { Button } from '@/components/ui/shared/Button'; +import { Input } from '@/components/ui/shared/Input'; +import { Toast } from '@/components/ui/shared/Toast'; + +type PasswordForm = { + currentPassword: string; + newPassword: string; + confirmPassword: string; +}; export default function AccountSettingsPage() { const t = useTranslations('settings'); + const tAuth = useTranslations('auth'); const tCommon = useTranslations('common'); + const tValidation = useTranslations('validation'); + const { user, isAuthReady } = useAuth(); + const router = useRouter(); + const searchParams = useSearchParams(); + const isResetFlow = searchParams.get('reset') === '1'; + const [error, setError] = useState(null); + const [successMessage, setSuccessMessage] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + const passwordSchema = useMemo( + () => + z + .object({ + currentPassword: z.string(), + newPassword: z + .string() + .min(8, tValidation('passwordMinLength')) + .regex(/[A-Z]/, tValidation('passwordUppercase')) + .regex(/[0-9]/, tValidation('passwordNumber')), + confirmPassword: z.string(), + }) + .refine((data) => data.newPassword === data.confirmPassword, { + message: tValidation('passwordsDoNotMatch'), + path: ['confirmPassword'], + }) + .superRefine((data, ctx) => { + if (!isResetFlow && !data.currentPassword.trim()) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: tValidation('passwordRequired'), + path: ['currentPassword'], + }); + } + }), + [isResetFlow, tValidation], + ); + + const { + register, + handleSubmit, + reset, + formState: { errors }, + } = useForm({ + resolver: zodResolver(passwordSchema), + defaultValues: { + currentPassword: '', + newPassword: '', + confirmPassword: '', + }, + }); + + useEffect(() => { + if (isAuthReady && !user) { + router.replace('/login'); + } + }, [isAuthReady, user, router]); + + const onSubmit = async (data: PasswordForm) => { + try { + setError(null); + setSuccessMessage(null); + setIsSubmitting(true); + + await authApi.changePassword({ + ...(isResetFlow ? {} : { currentPassword: data.currentPassword }), + newPassword: data.newPassword, + }); + + reset(); + setSuccessMessage(t('passwordChanged')); + router.replace('/login'); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : t('passwordChangeFailed'); + setError(message || t('passwordChangeFailed')); + } finally { + setIsSubmitting(false); + } + }; + + if (!isAuthReady || !user) { + return ( +

{tCommon('loadingEllipsis')}

+ ); + } return (
- + {tCommon('backToApp')}

{t('accountTitle')}

-

{t('accountSubtitle')}

+

+ {isResetFlow ? t('resetPasswordSubtitle') : t('accountSubtitle')} +

-
-

{t('accountPlaceholder')}

+
+

+ {isResetFlow ? t('resetPasswordTitle') : t('changePasswordTitle')} +

+

+ {user.email} + {user.mobile ? ` · ${user.mobile}` : ''} +

+ +
+ {!isResetFlow && ( + } + /> + )} + + } + /> + + } + /> + + {error && ( +
+

{error}

+
+ )} + + +
+ + {successMessage && ( + {successMessage} + )}
); } diff --git a/frontend/src/app/[locale]/(public)/forgot-password/page.tsx b/frontend/src/app/[locale]/(public)/forgot-password/page.tsx new file mode 100644 index 0000000..c3a48b0 --- /dev/null +++ b/frontend/src/app/[locale]/(public)/forgot-password/page.tsx @@ -0,0 +1,206 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import * as z from 'zod'; +import { useTranslations } from 'next-intl'; +import { Link, useRouter } from '@/i18n/navigation'; +import { Phone, ShieldCheck } from 'lucide-react'; +import { authApi } from '@/lib/api/auth'; +import { useAuth } from '@/lib/hooks/useAuth'; +import { Button } from '@/components/ui/shared/Button'; +import { Input } from '@/components/ui/shared/Input'; +import { TopBarControls } from '@/components/ui/shared/TopBarControls'; + +type ForgotPasswordForm = { + mobile: string; + code: string; +}; + +function normalizeIranMobile(input: string): string { + let digits = input.replace(/\D/g, ''); + if (digits.startsWith('98') && digits.length === 12) digits = digits.slice(2); + if (digits.startsWith('0') && digits.length === 11) digits = digits.slice(1); + return digits; +} + +export default function ForgotPasswordPage() { + const t = useTranslations('auth'); + const tCommon = useTranslations('common'); + const tValidation = useTranslations('validation'); + const router = useRouter(); + const { refreshSession } = useAuth(); + const [step, setStep] = useState<'mobile' | 'code'>('mobile'); + const [error, setError] = useState(null); + const [isSending, setIsSending] = useState(false); + const [isVerifying, setIsVerifying] = useState(false); + const [sentMobile, setSentMobile] = useState(''); + + const schema = useMemo( + () => + z.object({ + mobile: z + .string() + .min(1, tValidation('mobileRequired')) + .refine((value) => /^9\d{9}$/.test(normalizeIranMobile(value)), { + message: tValidation('mobileInvalid'), + }), + code: z.string(), + }), + [tValidation], + ); + + const { + register, + handleSubmit, + getValues, + formState: { errors }, + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { mobile: '', code: '' }, + }); + + const onSendCode = async () => { + const mobile = getValues('mobile'); + const parsed = schema.safeParse({ mobile, code: '' }); + if (!parsed.success) { + setError(parsed.error.issues[0]?.message ?? tValidation('mobileInvalid')); + return; + } + + try { + setError(null); + setIsSending(true); + await authApi.sendForgotPasswordCode(mobile); + setSentMobile(mobile); + setStep('code'); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : t('codeSendFailed'); + setError(message || t('codeSendFailed')); + } finally { + setIsSending(false); + } + }; + + const onVerify = async (data: ForgotPasswordForm) => { + if (!data.code.trim()) { + setError(tValidation('codeRequired')); + return; + } + + try { + setError(null); + setIsVerifying(true); + const response = await authApi.verifyForgotPasswordCode( + sentMobile || data.mobile, + data.code.trim(), + ); + + const orgs = response.data.organizations; + if (orgs.length === 1) { + await authApi.selectOrganization(orgs[0].id); + localStorage.setItem('currentOrganizationId', orgs[0].id); + await refreshSession(); + router.push('/settings/account?reset=1'); + return; + } + + if (orgs.length > 1) { + sessionStorage.setItem('authRedirect', '/settings/account?reset=1'); + await refreshSession(); + router.push('/select-organization'); + return; + } + + await refreshSession(); + router.push('/settings/account?reset=1'); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : t('verifyFailed'); + setError(message || t('verifyFailed')); + } finally { + setIsVerifying(false); + } + }; + + return ( +
+
+ +
+ +
+ + {tCommon('appName')} + +

+ {t('forgotPasswordTitle')} +

+

+ {step === 'mobile' ? t('forgotPasswordSubtitle') : t('codeSentHint')} +

+
+ +
+
+
undefined)} + > + {step === 'mobile' ? ( + } + /> + ) : ( + } + /> + )} + + {error && ( +
+

{error}

+
+ )} + + {step === 'mobile' ? ( + + ) : ( + + )} + +

+ + {t('backToSignIn')} + +

+
+
+
+
+ ); +} diff --git a/frontend/src/app/[locale]/(public)/register/page.tsx b/frontend/src/app/[locale]/(public)/register/page.tsx index 6523dd2..abfdaaf 100644 --- a/frontend/src/app/[locale]/(public)/register/page.tsx +++ b/frontend/src/app/[locale]/(public)/register/page.tsx @@ -6,7 +6,7 @@ import { zodResolver } from '@hookform/resolvers/zod'; import * as z from 'zod'; import { useTranslations } from 'next-intl'; import { Link } from '@/i18n/navigation'; -import { Mail, Lock, User } from 'lucide-react'; +import { Mail, Lock, User, Phone } from 'lucide-react'; import { useAuth } from '@/lib/hooks/useAuth'; import { OrganizationDetailsFields } from '@/components/ui/auth/OrganizationDetailsFields'; import { RegistrationProgressSteps } from '@/components/ui/auth/RegistrationProgressSteps'; @@ -17,6 +17,7 @@ import { TopBarControls } from '@/components/ui/shared/TopBarControls'; type RegisterForm = { name: string; email: string; + mobile: string; password: string; confirmPassword: string; organizationName: string; @@ -24,6 +25,13 @@ type RegisterForm = { organizationType: 'CLINIC' | 'LAB'; }; +function normalizeIranMobile(input: string): string { + let digits = input.replace(/\D/g, ''); + if (digits.startsWith('98') && digits.length === 12) digits = digits.slice(2); + if (digits.startsWith('0') && digits.length === 11) digits = digits.slice(1); + return digits; +} + export default function RegisterPage() { const t = useTranslations('auth'); const tCommon = useTranslations('common'); @@ -38,6 +46,12 @@ export default function RegisterPage() { .object({ name: z.string().min(2, tValidation('nameMinLength')), email: z.string().email(tValidation('emailInvalid')), + mobile: z + .string() + .min(1, tValidation('mobileRequired')) + .refine((value) => /^9\d{9}$/.test(normalizeIranMobile(value)), { + message: tValidation('mobileInvalid'), + }), password: z .string() .min(8, tValidation('passwordMinLength')) @@ -74,7 +88,7 @@ export default function RegisterPage() { const handleNext = async () => { const fieldsToValidate = step === 1 - ? (['name', 'email', 'password', 'confirmPassword'] as const) + ? (['name', 'email', 'mobile', 'password', 'confirmPassword'] as const) : (['organizationName', 'organizationEmail', 'organizationType'] as const); const isValid = await trigger([...fieldsToValidate]); @@ -90,6 +104,7 @@ export default function RegisterPage() { data.email, data.password, data.name, + data.mobile, data.organizationName, data.organizationEmail, data.organizationType, @@ -157,6 +172,16 @@ export default function RegisterPage() { error={errors.email?.message} icon={} /> + } + /> { label?: string; @@ -10,7 +10,8 @@ interface DropdownProps extends React.SelectHTMLAttributes { export const Dropdown = forwardRef( ({ label, error, className = '', id, children, ...props }, ref) => { - const selectId = id || `dropdown-${Math.random().toString(36).slice(2, 9)}`; + const genId = useId(); + const selectId = id ?? genId; return (
diff --git a/frontend/src/components/ui/shared/Input.tsx b/frontend/src/components/ui/shared/Input.tsx index 940bcd5..0a14efa 100644 --- a/frontend/src/components/ui/shared/Input.tsx +++ b/frontend/src/components/ui/shared/Input.tsx @@ -1,5 +1,6 @@ -// src/components/ui/Input.tsx -import React, { forwardRef } from 'react'; +'use client'; + +import React, { forwardRef, useId } from 'react'; interface InputProps extends React.InputHTMLAttributes { label?: string; @@ -9,8 +10,8 @@ interface InputProps extends React.InputHTMLAttributes { export const Input = forwardRef( ({ label, error, icon, className = '', id, ...props }, ref) => { - const inputId = - id || `input-${Math.random().toString(36).slice(2, 9)}`; + const genId = useId(); + const inputId = id ?? genId; return (
diff --git a/frontend/src/lib/api/auth.ts b/frontend/src/lib/api/auth.ts index fa15c5e..e861584 100644 --- a/frontend/src/lib/api/auth.ts +++ b/frontend/src/lib/api/auth.ts @@ -1,6 +1,6 @@ // src/lib/api/auth.ts import { apiClient } from './client'; -import type { AuthResponse, TrialRegistrationData, LoginData } from '@/types/auth'; +import type { AuthResponse, TrialRegistrationData, LoginData, ForgotPasswordVerifyResponse } from '@/types/auth'; import type { SubscriptionAlertData } from '@/types/subscription'; export const authApi = { @@ -65,4 +65,25 @@ export const authApi = { const response = await apiClient.post('/auth/refresh', { refreshToken }); return response.data; }, + + sendForgotPasswordCode: async (mobile: string): Promise<{ success: boolean; message: string }> => { + const response = await apiClient.post('/auth/forgot-password/send-code', { mobile }); + return response.data; + }, + + verifyForgotPasswordCode: async ( + mobile: string, + code: string, + ): Promise => { + const response = await apiClient.post('/auth/forgot-password/verify', { mobile, code }); + return response.data; + }, + + changePassword: async (data: { + currentPassword?: string; + newPassword: string; + }): Promise<{ success: boolean; message: string }> => { + const response = await apiClient.patch('/auth/profile/password', data); + return response.data; + }, }; \ No newline at end of file diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts index 47d3639..6dd64e5 100644 --- a/frontend/src/lib/api/client.ts +++ b/frontend/src/lib/api/client.ts @@ -34,7 +34,9 @@ function shouldSkipRefreshRetry(url: string | undefined): boolean { url.includes('/auth/refresh') || url.includes('/auth/login') || url.includes('/auth/register') || - url.includes('/auth/logout') + url.includes('/auth/logout') || + url.includes('/auth/forgot-password') || + url.includes('/auth/profile/password') ); } diff --git a/frontend/src/lib/hooks/useAuth.tsx b/frontend/src/lib/hooks/useAuth.tsx index 38d3dc2..f037fc5 100644 --- a/frontend/src/lib/hooks/useAuth.tsx +++ b/frontend/src/lib/hooks/useAuth.tsx @@ -23,6 +23,7 @@ interface AuthContextType { email: string, password: string, name: string, + mobile: string, organizationName: string, organizationEmail: string, organizationType: 'CLINIC' | 'LAB' @@ -37,6 +38,7 @@ interface AuthContextType { planName?: string, ) => Promise; setUserLanguage: (language: string) => void; + refreshSession: () => Promise; clearError: () => void; } @@ -158,6 +160,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { email: string, password: string, name: string, + mobile: string, organizationName: string, organizationEmail: string, organizationType: 'CLINIC' | 'LAB' @@ -168,6 +171,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const response = await authApi.registerTrial({ email, + mobile, password, name, organizationName, @@ -284,7 +288,16 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { plan: (organization as { plan?: Organization['plan'] }).plan, }); - router.push('/today'); + const redirectPath = + typeof window !== 'undefined' + ? sessionStorage.getItem('authRedirect') + : null; + if (redirectPath) { + sessionStorage.removeItem('authRedirect'); + router.push(redirectPath); + } else { + router.push('/today'); + } } catch (err: any) { setError(err.message); @@ -327,6 +340,10 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { } }, [normalizeProfilePayload, t]); + const refreshSession = useCallback(async () => { + await checkAuth(); + }, [checkAuth]); + const clearError = useCallback(() => setError(null), []); const setUserLanguage = useCallback((language: string) => { @@ -348,6 +365,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { selectOrganization, createOrganization, setUserLanguage, + refreshSession, clearError, }), [ @@ -363,6 +381,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { selectOrganization, createOrganization, setUserLanguage, + refreshSession, clearError, ], ); diff --git a/frontend/src/types/auth.ts b/frontend/src/types/auth.ts index 7222d29..7ef75b3 100644 --- a/frontend/src/types/auth.ts +++ b/frontend/src/types/auth.ts @@ -12,6 +12,7 @@ export interface AuthResponse { export interface TrialRegistrationData { email: string; + mobile: string; password: string; name: string; organizationName: string; @@ -24,3 +25,12 @@ export interface LoginData { password: string; rememberMe?: boolean; } + +export interface ForgotPasswordVerifyResponse { + success: boolean; + data: { + user: AuthResponse['data']['user']; + organizations: AuthResponse['data']['organizations']; + redirectTo: string; + }; +} diff --git a/frontend/src/types/organization.ts b/frontend/src/types/organization.ts index 1a6a1e9..7de44fb 100644 --- a/frontend/src/types/organization.ts +++ b/frontend/src/types/organization.ts @@ -3,6 +3,7 @@ export interface User { email: string; name: string; language?: string; + mobile?: string | null; } export interface OrganizationPlan {