diff --git a/apps/api/prisma/migrations/20260905010000_auth_mobile_otp/migration.sql b/apps/api/prisma/migrations/20260905010000_auth_mobile_otp/migration.sql new file mode 100644 index 0000000..6536fba --- /dev/null +++ b/apps/api/prisma/migrations/20260905010000_auth_mobile_otp/migration.sql @@ -0,0 +1,33 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "mobile" TEXT; +ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "emailVerifiedAt" TIMESTAMP(3); +ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "mobileVerifiedAt" TIMESTAMP(3); + +-- CreateIndex +CREATE UNIQUE INDEX IF NOT EXISTS "User_mobile_key" ON "User"("mobile"); +CREATE INDEX IF NOT EXISTS "User_email_idx" ON "User"("email"); + +-- CreateTable +CREATE TABLE IF NOT EXISTS "AuthChallenge" ( + "id" TEXT NOT NULL, + "kind" TEXT NOT NULL, + "channel" TEXT, + "email" TEXT, + "mobile" TEXT, + "passwordHash" TEXT, + "payload" JSONB NOT NULL DEFAULT '{}', + "emailCode" TEXT, + "mobileCode" TEXT, + "emailVerified" BOOLEAN NOT NULL DEFAULT false, + "mobileVerified" BOOLEAN NOT NULL DEFAULT false, + "expiresAt" TIMESTAMP(3) NOT NULL, + "consumedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "AuthChallenge_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX IF NOT EXISTS "AuthChallenge_kind_createdAt_idx" ON "AuthChallenge"("kind", "createdAt"); +CREATE INDEX IF NOT EXISTS "AuthChallenge_expiresAt_idx" ON "AuthChallenge"("expiresAt"); +CREATE INDEX IF NOT EXISTS "AuthChallenge_email_idx" ON "AuthChallenge"("email"); +CREATE INDEX IF NOT EXISTS "AuthChallenge_mobile_idx" ON "AuthChallenge"("mobile"); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index baf5a95..d5cf610 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -172,9 +172,13 @@ model Organization { model User { id String @id @default(cuid()) email String + /// موبایل نرمال‌شده (مثلاً 9121234567) — برای ورود با موبایل + mobile String? passwordHash String fullName String role UserRole @default(sales) + emailVerifiedAt DateTime? + mobileVerifiedAt DateTime? organizationId String organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) ownedDeals Deal[] @relation("DealOwner") @@ -197,7 +201,9 @@ model User { updatedAt DateTime @updatedAt @@unique([organizationId, email]) + @@unique([mobile]) @@index([organizationId]) + @@index([email]) } model Company { @@ -934,6 +940,32 @@ model AutomationRule { @@index([organizationId, isActive, trigger]) } +/// چالش OTP برای ثبت‌نام / ورود پنل +model AuthChallenge { + id String @id @default(cuid()) + /// register | login + kind String + /// برای login: email | mobile + channel String? + email String? + mobile String? + passwordHash String? + /// دادهٔ موقت ثبت‌نام (سازمان، نام، زمینه، کد معرف، …) + payload Json @default("{}") + emailCode String? + mobileCode String? + emailVerified Boolean @default(false) + mobileVerified Boolean @default(false) + expiresAt DateTime + consumedAt DateTime? + createdAt DateTime @default(now()) + + @@index([kind, createdAt]) + @@index([expiresAt]) + @@index([email]) + @@index([mobile]) +} + /// اتصال به نرم‌افزار حسابداری خارجی (ته‌حساب) — چند اتصال/DB به‌ازای هر سازمان model AccountingConnection { id String @id @default(cuid()) diff --git a/apps/api/prisma/seed.ts b/apps/api/prisma/seed.ts index 0c07153..da9b0cc 100644 --- a/apps/api/prisma/seed.ts +++ b/apps/api/prisma/seed.ts @@ -38,9 +38,12 @@ async function main() { users: { create: { email, + mobile: '09120000001', fullName: 'مدیر نمونه', passwordHash, role: 'owner', + emailVerifiedAt: new Date(), + mobileVerifiedAt: new Date(), }, }, }, @@ -54,6 +57,17 @@ async function main() { const owner = await prisma.user.findFirst({ where: { organizationId: org.id, email }, }); + if (owner && !owner.mobile) { + await prisma.user.update({ + where: { id: owner.id }, + data: { + mobile: '09120000001', + emailVerifiedAt: owner.emailVerifiedAt ?? new Date(), + mobileVerifiedAt: owner.mobileVerifiedAt ?? new Date(), + }, + }); + console.log('Updated demo owner mobile: 09120000001'); + } // چندارزی نمونه await prisma.organization.update({ @@ -93,9 +107,12 @@ async function main() { data: { organizationId: org.id, email: salesEmail, + mobile: '09120000002', fullName: 'کارشناس فروش نمونه', passwordHash: await bcrypt.hash('demo1234', 10), role: 'sales', + emailVerifiedAt: new Date(), + mobileVerifiedAt: new Date(), }, }); console.log('Seeded sales user:', salesEmail, '/ demo1234'); @@ -110,9 +127,12 @@ async function main() { data: { organizationId: org.id, email: adminEmail, + mobile: '09120000003', fullName: 'مدیر سازمانی نمونه', passwordHash: await bcrypt.hash('demo1234', 10), role: 'admin', + emailVerifiedAt: new Date(), + mobileVerifiedAt: new Date(), }, }); console.log('Seeded admin user:', adminEmail, '/ demo1234'); diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts index f8b4124..0356b7c 100644 --- a/apps/api/src/auth/auth.controller.ts +++ b/apps/api/src/auth/auth.controller.ts @@ -1,6 +1,11 @@ import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common'; import { AuthService } from './auth.service'; -import { LoginDto, RegisterOrganizationDto } from './dto'; +import { + LoginDto, + LoginVerifyOtpDto, + RegisterOrganizationDto, + RegisterVerifyOtpDto, +} from './dto'; import { JwtAuthGuard } from './jwt-auth.guard'; import { CurrentUser } from './current-user.decorator'; @@ -10,12 +15,22 @@ export class AuthController { @Post('register') register(@Body() dto: RegisterOrganizationDto) { - return this.authService.registerOrganization(dto); + return this.authService.startRegister(dto); + } + + @Post('register/verify') + registerVerify(@Body() dto: RegisterVerifyOtpDto) { + return this.authService.verifyRegister(dto); } @Post('login') login(@Body() dto: LoginDto) { - return this.authService.login(dto); + return this.authService.loginLegacy(dto); + } + + @Post('login/verify') + loginVerify(@Body() dto: LoginVerifyOtpDto) { + return this.authService.verifyLogin(dto); } @UseGuards(JwtAuthGuard) diff --git a/apps/api/src/auth/auth.module.ts b/apps/api/src/auth/auth.module.ts index 9f09c12..09ab0bc 100644 --- a/apps/api/src/auth/auth.module.ts +++ b/apps/api/src/auth/auth.module.ts @@ -6,9 +6,11 @@ import { AuthService } from './auth.service'; import { AuthController } from './auth.controller'; import { JwtStrategy } from './jwt.strategy'; import { RolesGuard } from './roles.guard'; +import { SmsService } from '../common/sms.service'; @Module({ imports: [ + ConfigModule, PassportModule.register({ defaultStrategy: 'jwt' }), JwtModule.registerAsync({ imports: [ConfigModule], @@ -22,7 +24,7 @@ import { RolesGuard } from './roles.guard'; }), ], controllers: [AuthController], - providers: [AuthService, JwtStrategy, RolesGuard], + providers: [AuthService, JwtStrategy, RolesGuard, SmsService], exports: [AuthService, RolesGuard, JwtModule], }) export class AuthModule {} diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts index 965e252..cd5f2a8 100644 --- a/apps/api/src/auth/auth.service.ts +++ b/apps/api/src/auth/auth.service.ts @@ -1,83 +1,357 @@ import { + BadRequestException, ConflictException, Injectable, + Logger, UnauthorizedException, } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { JwtService } from '@nestjs/jwt'; import * as bcrypt from 'bcryptjs'; import type { AuthUserDto, LoginResponse } from '@mento/shared'; +import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; -import { LoginDto, RegisterOrganizationDto } from './dto'; +import { SmsService } from '../common/sms.service'; +import { + LoginDto, + LoginVerifyOtpDto, + RegisterOrganizationDto, + RegisterVerifyOtpDto, +} from './dto'; + +const OTP_LENGTH = 6; +const OTP_TTL_SECONDS = 5 * 60; function slugify(input: string): string { - return input - .trim() - .toLowerCase() - .replace(/[^\w\u0600-\u06FF]+/g, '-') - .replace(/^-+|-+$/g, '') - .slice(0, 48) || `org-${Date.now()}`; + return ( + input + .trim() + .toLowerCase() + .replace(/[^\w\u0600-\u06FF]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 48) || `org-${Date.now()}` + ); } +/** نرمال‌سازی موبایل ایران به 09xxxxxxxxx */ +export function normalizeMobile(raw: string): string | null { + let s = raw.replace(/[\s\-()]/g, ''); + if (s.startsWith('+98')) s = `0${s.slice(3)}`; + if (s.startsWith('98') && s.length === 12) s = `0${s.slice(2)}`; + if (/^9\d{9}$/.test(s)) s = `0${s}`; + if (/^09\d{9}$/.test(s)) return s; + return null; +} + +function looksLikeEmail(value: string): boolean { + return value.includes('@'); +} + +function maskEmail(email: string): string { + const [local, domain] = email.split('@'); + if (!domain) return '***'; + const head = local.slice(0, Math.min(2, local.length)); + return `${head}***@${domain}`; +} + +function maskMobile(mobile: string): string { + if (mobile.length < 4) return '****'; + return `${mobile.slice(0, 4)}***${mobile.slice(-2)}`; +} + +function generateOtp(): string { + return String( + Math.floor(10 ** (OTP_LENGTH - 1) + Math.random() * 9 * 10 ** (OTP_LENGTH - 1)), + ); +} + +type RegisterPayload = { + organizationName: string; + fullName: string; + businessField: string; + referralCode?: string; +}; + @Injectable() export class AuthService { + private readonly logger = new Logger(AuthService.name); + constructor( private readonly prisma: PrismaService, private readonly jwt: JwtService, + private readonly config: ConfigService, + private readonly sms: SmsService, ) {} - async registerOrganization(dto: RegisterOrganizationDto): Promise { - const baseSlug = slugify(dto.organizationName); + /** مرحله ۱ ثبت‌نام: اعتبارسنجی + ارسال OTP ایمیل و موبایل */ + async startRegister(dto: RegisterOrganizationDto) { + if (dto.password !== dto.passwordConfirm) { + throw new BadRequestException('تکرار رمز عبور مطابقت ندارد'); + } + if (dto.acceptedTerms === false) { + throw new BadRequestException('پذیرش شرایط و قوانین الزامی است'); + } + + const email = dto.email.trim().toLowerCase(); + const mobile = normalizeMobile(dto.mobile); + if (!mobile) { + throw new BadRequestException('شماره موبایل معتبر نیست'); + } + + const existingEmail = await this.prisma.user.findFirst({ where: { email } }); + if (existingEmail) { + throw new ConflictException('این ایمیل قبلاً ثبت شده است'); + } + const existingMobile = await this.prisma.user.findFirst({ where: { mobile } }); + if (existingMobile) { + throw new ConflictException('این شماره موبایل قبلاً ثبت شده است'); + } + + await this.throttleChallenges('register', email, mobile); + + const emailCode = generateOtp(); + const mobileCode = generateOtp(); + const passwordHash = await bcrypt.hash(dto.password, 10); + const payload: RegisterPayload = { + organizationName: dto.organizationName.trim(), + fullName: dto.fullName.trim(), + businessField: dto.businessField, + ...(dto.referralCode?.trim() + ? { referralCode: dto.referralCode.trim() } + : {}), + }; + + const challenge = await this.prisma.authChallenge.create({ + data: { + kind: 'register', + email, + mobile, + passwordHash, + payload: payload as Prisma.InputJsonValue, + emailCode, + mobileCode, + expiresAt: new Date(Date.now() + OTP_TTL_SECONDS * 1000), + }, + }); + + const returnOtp = this.shouldReturnOtp(); + await this.dispatchEmailOtp(email, emailCode, returnOtp); + await this.dispatchMobileOtp(mobile, mobileCode, returnOtp); + + return { + challengeId: challenge.id, + expiresInSeconds: OTP_TTL_SECONDS, + maskedEmail: maskEmail(email), + maskedMobile: maskMobile(mobile), + ...(returnOtp + ? { devEmailCode: emailCode, devMobileCode: mobileCode } + : {}), + }; + } + + /** مرحله ۲ ثبت‌نام: تأیید هر دو کد و ساخت سازمان */ + async verifyRegister(dto: RegisterVerifyOtpDto): Promise { + const challenge = await this.prisma.authChallenge.findUnique({ + where: { id: dto.challengeId }, + }); + if ( + !challenge || + challenge.kind !== 'register' || + challenge.consumedAt || + challenge.expiresAt <= new Date() + ) { + throw new UnauthorizedException('کد تأیید نامعتبر یا منقضی است'); + } + + const emailOk = challenge.emailCode === dto.emailCode.trim(); + const mobileOk = challenge.mobileCode === dto.mobileCode.trim(); + if (!emailOk || !mobileOk) { + throw new UnauthorizedException('کد تأیید ایمیل یا موبایل نادرست است'); + } + + const email = (challenge.email ?? '').toLowerCase(); + const mobile = challenge.mobile ?? ''; + if (!email || !mobile || !challenge.passwordHash) { + throw new BadRequestException('چالش ثبت‌نام ناقص است'); + } + + const payload = (challenge.payload ?? {}) as RegisterPayload; + if (!payload.organizationName || !payload.fullName || !payload.businessField) { + throw new BadRequestException('اطلاعات ثبت‌نام ناقص است'); + } + + const existingEmail = await this.prisma.user.findFirst({ where: { email } }); + if (existingEmail) { + throw new ConflictException('این ایمیل قبلاً ثبت شده است'); + } + const existingMobile = await this.prisma.user.findFirst({ where: { mobile } }); + if (existingMobile) { + throw new ConflictException('این شماره موبایل قبلاً ثبت شده است'); + } + + const baseSlug = slugify(payload.organizationName); let slug = baseSlug; let i = 1; while (await this.prisma.organization.findUnique({ where: { slug } })) { slug = `${baseSlug}-${i++}`; } - const existing = await this.prisma.user.findFirst({ - where: { email: dto.email.toLowerCase() }, - }); - if (existing) { - throw new ConflictException('این ایمیل قبلاً ثبت شده است'); - } - - const passwordHash = await bcrypt.hash(dto.password, 10); + const now = new Date(); const organization = await this.prisma.organization.create({ data: { - name: dto.organizationName, + name: payload.organizationName, slug, - businessField: dto.businessField, + businessField: payload.businessField, users: { create: { - email: dto.email.toLowerCase(), - fullName: dto.fullName, - passwordHash, + email, + mobile, + fullName: payload.fullName, + passwordHash: challenge.passwordHash, role: 'owner', + emailVerifiedAt: now, + mobileVerifiedAt: now, }, }, }, include: { users: true }, }); - const user = organization.users[0]; - return this.buildLoginResponse(user); + await this.prisma.authChallenge.update({ + where: { id: challenge.id }, + data: { + consumedAt: now, + emailVerified: true, + mobileVerified: true, + }, + }); + + return this.buildLoginResponse(organization.users[0]); } - async login(dto: LoginDto): Promise { + /** مرحله ۱ ورود: ایمیل/موبایل + رمز → ارسال OTP روی همان کانال */ + async startLogin(dto: LoginDto) { + const identifier = (dto.identifier ?? dto.email ?? '').trim(); + const isEmail = looksLikeEmail(identifier); + const email = isEmail ? identifier.toLowerCase() : null; + const mobile = isEmail ? null : normalizeMobile(identifier); + + if (!identifier || (!email && !mobile)) { + throw new BadRequestException('ایمیل یا شماره موبایل معتبر وارد کنید'); + } + const user = await this.prisma.user.findFirst({ - where: { email: dto.email.toLowerCase() }, + where: email ? { email } : { mobile: mobile! }, }); if (!user) { - throw new UnauthorizedException('ایمیل یا رمز عبور نادرست است'); + throw new UnauthorizedException('اطلاعات ورود نادرست است'); } const ok = await bcrypt.compare(dto.password, user.passwordHash); if (!ok) { - throw new UnauthorizedException('ایمیل یا رمز عبور نادرست است'); + throw new UnauthorizedException('اطلاعات ورود نادرست است'); } + const channel = email ? 'email' : 'mobile'; + if (channel === 'mobile' && !user.mobile) { + throw new BadRequestException('موبایل برای این حساب ثبت نشده است'); + } + if (channel === 'email' && !user.email) { + throw new BadRequestException('ایمیل برای این حساب ثبت نشده است'); + } + + await this.throttleChallenges('login', user.email, user.mobile); + + const code = generateOtp(); + const challenge = await this.prisma.authChallenge.create({ + data: { + kind: 'login', + channel, + email: user.email, + mobile: user.mobile, + passwordHash: null, + payload: { userId: user.id } as Prisma.InputJsonValue, + emailCode: channel === 'email' ? code : null, + mobileCode: channel === 'mobile' ? code : null, + expiresAt: new Date(Date.now() + OTP_TTL_SECONDS * 1000), + }, + }); + + const returnOtp = this.shouldReturnOtp(); + if (channel === 'email') { + await this.dispatchEmailOtp(user.email, code, returnOtp); + } else { + await this.dispatchMobileOtp(user.mobile!, code, returnOtp); + } + + return { + challengeId: challenge.id, + channel: channel as 'email' | 'mobile', + expiresInSeconds: OTP_TTL_SECONDS, + maskedDestination: + channel === 'email' ? maskEmail(user.email) : maskMobile(user.mobile!), + ...(returnOtp ? { devCode: code } : {}), + }; + } + + /** مرحله ۲ ورود: تأیید OTP */ + async verifyLogin(dto: LoginVerifyOtpDto): Promise { + const challenge = await this.prisma.authChallenge.findUnique({ + where: { id: dto.challengeId }, + }); + if ( + !challenge || + challenge.kind !== 'login' || + challenge.consumedAt || + challenge.expiresAt <= new Date() + ) { + throw new UnauthorizedException('کد تأیید نامعتبر یا منقضی است'); + } + + const code = dto.code.trim(); + const expected = + challenge.channel === 'email' ? challenge.emailCode : challenge.mobileCode; + if (!expected || expected !== code) { + throw new UnauthorizedException('کد تأیید نادرست است'); + } + + const payload = (challenge.payload ?? {}) as { userId?: string }; + const userId = payload.userId; + if (!userId) { + throw new BadRequestException('چالش ورود ناقص است'); + } + + const user = await this.prisma.user.findUnique({ where: { id: userId } }); + if (!user) { + throw new UnauthorizedException(); + } + + const now = new Date(); + await this.prisma.authChallenge.update({ + where: { id: challenge.id }, + data: { + consumedAt: now, + emailVerified: challenge.channel === 'email', + mobileVerified: challenge.channel === 'mobile', + }, + }); + + await this.prisma.user.update({ + where: { id: user.id }, + data: + challenge.channel === 'email' + ? { emailVerifiedAt: user.emailVerifiedAt ?? now } + : { mobileVerifiedAt: user.mobileVerifiedAt ?? now }, + }); + return this.buildLoginResponse(user); } + /** سازگاری موقت: اگر کلاینت قدیمی email بفرستد */ + async loginLegacy(body: LoginDto) { + return this.startLogin(body); + } + async me(userId: string): Promise { const user = await this.prisma.user.findUnique({ where: { id: userId } }); if (!user) { @@ -86,9 +360,80 @@ export class AuthService { return this.toAuthUser(user); } + private async throttleChallenges( + kind: string, + email: string | null | undefined, + mobile: string | null | undefined, + ) { + const since = new Date(Date.now() - 60 * 60 * 1000); + const count = await this.prisma.authChallenge.count({ + where: { + kind, + createdAt: { gte: since }, + OR: [ + ...(email ? [{ email }] : []), + ...(mobile ? [{ mobile }] : []), + ], + }, + }); + if (count >= 12) { + throw new BadRequestException( + 'درخواست زیاد است؛ کمی بعد دوباره تلاش کنید', + ); + } + } + + private shouldReturnOtp(): boolean { + return ( + this.config.get('AUTH_RETURN_OTP') === 'true' || + this.config.get('PORTAL_SHOW_OTP') === 'true' || + (this.config.get('NODE_ENV') !== 'production' && + this.config.get('AUTH_RETURN_OTP') !== 'false') + ); + } + + private async dispatchEmailOtp( + email: string, + code: string, + returnOtp: boolean, + ) { + // فعلاً سرویس ایمیل پیکربندی نشده؛ در محیط غیرپروداکشن کد لاگ می‌شود + this.logger.log(`[auth-otp-email] ${email} => ${code}`); + if (!returnOtp && this.config.get('NODE_ENV') === 'production') { + // بدون ایمیل واقعی در پروداکشن، ورود/ثبت‌نام با ایمیل ممکن نیست مگر AUTH_RETURN_OTP + throw new BadRequestException( + 'ارسال ایمیل پیکربندی نشده است؛ با پشتیبانی تماس بگیرید', + ); + } + } + + private async dispatchMobileOtp( + mobile: string, + code: string, + returnOtp: boolean, + ) { + if (this.sms.isConfigured()) { + const sent = await this.sms.sendOtpSms(mobile, code); + if (!sent && !returnOtp) { + throw new BadRequestException( + 'ارسال پیامک ناموفق بود؛ کمی بعد دوباره تلاش کنید', + ); + } + if (!sent) { + this.logger.warn(`[auth-otp-sms-fallback] ${mobile} => ${code}`); + } + return; + } + if (!returnOtp) { + throw new BadRequestException('سامانه پیامک پیکربندی نشده است'); + } + this.logger.log(`[auth-otp-sms] ${mobile} => ${code}`); + } + private buildLoginResponse(user: { id: string; email: string; + mobile?: string | null; fullName: string; role: AuthUserDto['role']; organizationId: string; @@ -107,6 +452,7 @@ export class AuthService { private toAuthUser(user: { id: string; email: string; + mobile?: string | null; fullName: string; role: AuthUserDto['role']; organizationId: string; @@ -117,6 +463,7 @@ export class AuthService { fullName: user.fullName, role: user.role, organizationId: user.organizationId, + ...(user.mobile ? { mobile: user.mobile } : {}), }; } } diff --git a/apps/api/src/auth/dto.ts b/apps/api/src/auth/dto.ts index 0a3126b..ee15c92 100644 --- a/apps/api/src/auth/dto.ts +++ b/apps/api/src/auth/dto.ts @@ -1,20 +1,40 @@ import { IsEmail, IsIn, + IsOptional, IsString, + Matches, MinLength, + ValidateIf, } from 'class-validator'; import { BUSINESS_FIELDS } from '@mento/shared'; export class LoginDto { + /** ایمیل یا موبایل — کلاینت جدید */ + @IsOptional() + @IsString() + @MinLength(3) + identifier?: string; + + /** سازگاری با کلاینت قدیمی */ + @IsOptional() @IsEmail() - email!: string; + email?: string; @IsString() @MinLength(6) password!: string; } +export class LoginVerifyOtpDto { + @IsString() + challengeId!: string; + + @IsString() + @MinLength(4) + code!: string; +} + export class RegisterOrganizationDto { @IsString() @MinLength(2) @@ -27,10 +47,41 @@ export class RegisterOrganizationDto { @IsEmail() email!: string; + @IsString() + @Matches(/^09\d{9}$|^\+98\d{10}$|^98\d{10}$|^9\d{9}$/, { + message: 'شماره موبایل معتبر نیست', + }) + mobile!: string; + @IsString() @MinLength(6) password!: string; + @IsString() + @MinLength(6) + passwordConfirm!: string; + @IsIn([...BUSINESS_FIELDS]) businessField!: (typeof BUSINESS_FIELDS)[number]; + + @IsOptional() + @IsString() + referralCode?: string; + + @ValidateIf((_, v) => v !== undefined) + @IsOptional() + acceptedTerms?: boolean; +} + +export class RegisterVerifyOtpDto { + @IsString() + challengeId!: string; + + @IsString() + @MinLength(4) + emailCode!: string; + + @IsString() + @MinLength(4) + mobileCode!: string; } diff --git a/apps/web/src/components/auth/AuthBrandPanel.vue b/apps/web/src/components/auth/AuthBrandPanel.vue new file mode 100644 index 0000000..9119e05 --- /dev/null +++ b/apps/web/src/components/auth/AuthBrandPanel.vue @@ -0,0 +1,243 @@ + + + + + diff --git a/apps/web/src/stores/auth.ts b/apps/web/src/stores/auth.ts index 5bbce01..3d62305 100644 --- a/apps/web/src/stores/auth.ts +++ b/apps/web/src/stores/auth.ts @@ -1,6 +1,12 @@ import { defineStore } from 'pinia'; import { computed, ref } from 'vue'; -import type { AuthUserDto, LoginResponse } from '@mento/shared'; +import type { + AuthUserDto, + LoginChallengeResponse, + LoginResponse, + RegisterChallengeResponse, + RegisterOrganizationRequest, +} from '@mento/shared'; import { api } from '@/lib/api'; const TOKEN_KEY = 'mento_token'; @@ -39,19 +45,40 @@ export const useAuthStore = defineStore('auth', () => { clearLegacy(); } - async function login(email: string, password: string) { - const { data } = await api.post('/auth/login', { email, password }); + async function startLogin(identifier: string, password: string) { + const { data } = await api.post('/auth/login', { + identifier, + password, + }); + return data; + } + + async function verifyLogin(challengeId: string, code: string) { + const { data } = await api.post('/auth/login/verify', { + challengeId, + code, + }); persist(data); } - async function register(payload: { - organizationName: string; - fullName: string; - email: string; - password: string; - businessField: string; - }) { - const { data } = await api.post('/auth/register', payload); + async function startRegister(payload: RegisterOrganizationRequest) { + const { data } = await api.post( + '/auth/register', + payload, + ); + return data; + } + + async function verifyRegister( + challengeId: string, + emailCode: string, + mobileCode: string, + ) { + const { data } = await api.post('/auth/register/verify', { + challengeId, + emailCode, + mobileCode, + }); persist(data); } @@ -67,8 +94,10 @@ export const useAuthStore = defineStore('auth', () => { token, user, isAuthenticated, - login, - register, + startLogin, + verifyLogin, + startRegister, + verifyRegister, logout, }; }); diff --git a/apps/web/src/views/LoginView.vue b/apps/web/src/views/LoginView.vue index 05931fa..fe49dce 100644 --- a/apps/web/src/views/LoginView.vue +++ b/apps/web/src/views/LoginView.vue @@ -1,117 +1,440 @@ diff --git a/apps/web/src/views/RegisterView.vue b/apps/web/src/views/RegisterView.vue index 47de076..0881f71 100644 --- a/apps/web/src/views/RegisterView.vue +++ b/apps/web/src/views/RegisterView.vue @@ -3,133 +3,611 @@ import { ref } from 'vue'; import { useRouter, RouterLink } from 'vue-router'; import { useAuthStore } from '@/stores/auth'; import { BUSINESS_FIELDS, BUSINESS_FIELD_LABELS } from '@/lib/business-fields'; +import AuthBrandPanel from '@/components/auth/AuthBrandPanel.vue'; +import MentoLogo from '@/components/brand/MentoLogo.vue'; import axios from 'axios'; +import type { RegisterChallengeResponse } from '@mento/shared'; const auth = useAuthStore(); const router = useRouter(); +const step = ref<'form' | 'otp'>('form'); const organizationName = ref(''); const businessField = ref(''); const fullName = ref(''); const email = ref(''); +const mobile = ref(''); const password = ref(''); +const passwordConfirm = ref(''); +const referralCode = ref(''); +const acceptedTerms = ref(false); +const showPassword = ref(false); +const showPasswordConfirm = ref(false); +const emailCode = ref(''); +const mobileCode = ref(''); +const challenge = ref(null); const error = ref(''); const loading = ref(false); -async function submit() { +function apiError(e: unknown, fallback: string) { + if (axios.isAxiosError(e)) { + if (!e.response) { + return 'اتصال به سرور برقرار نشد. مطمئن شوید API روی پورت ۳۰۰۰ در حال اجراست.'; + } + const msg = (e.response.data as { message?: string | string[] })?.message; + return Array.isArray(msg) ? msg.join('، ') : (msg ?? fallback); + } + return fallback; +} + +async function submitForm() { error.value = ''; + if (!acceptedTerms.value) { + error.value = 'پذیرش شرایط و قوانین الزامی است'; + return; + } + if (password.value !== passwordConfirm.value) { + error.value = 'تکرار رمز عبور مطابقت ندارد'; + return; + } loading.value = true; try { - await auth.register({ - organizationName: organizationName.value, - businessField: businessField.value, - fullName: fullName.value, - email: email.value, + challenge.value = await auth.startRegister({ + organizationName: organizationName.value.trim(), + businessField: businessField.value as (typeof BUSINESS_FIELDS)[number], + fullName: fullName.value.trim(), + email: email.value.trim(), + mobile: mobile.value.trim(), password: password.value, + passwordConfirm: passwordConfirm.value, + referralCode: referralCode.value.trim() || undefined, + acceptedTerms: true, }); - await router.push('/'); + if (challenge.value.devEmailCode) emailCode.value = challenge.value.devEmailCode; + if (challenge.value.devMobileCode) mobileCode.value = challenge.value.devMobileCode; + step.value = 'otp'; } catch (e) { - if (axios.isAxiosError(e)) { - if (!e.response) { - error.value = 'اتصال به سرور برقرار نشد. مطمئن شوید API روی پورت ۳۰۰۰ در حال اجراست.'; - } else { - const msg = (e.response.data as { message?: string | string[] })?.message; - error.value = Array.isArray(msg) ? msg.join('، ') : (msg ?? 'ثبت‌نام ناموفق بود'); - } - } else { - error.value = 'ثبت‌نام ناموفق بود'; - } + error.value = apiError(e, 'ثبت‌نام ناموفق بود'); } finally { loading.value = false; } } + +async function submitOtp() { + if (!challenge.value) return; + error.value = ''; + loading.value = true; + try { + await auth.verifyRegister( + challenge.value.challengeId, + emailCode.value.trim(), + mobileCode.value.trim(), + ); + await router.push('/'); + } catch (e) { + error.value = apiError(e, 'تأیید کد ناموفق بود'); + } finally { + loading.value = false; + } +} + +function backToForm() { + step.value = 'form'; + emailCode.value = ''; + mobileCode.value = ''; + challenge.value = null; + error.value = ''; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index d62a827..d07e307 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -31,6 +31,7 @@ export function canReassignRecords(role: UserRole | string | null | undefined): export interface AuthUserDto { id: string; email: string; + mobile?: string; fullName: string; role: UserRole; organizationId: string; @@ -64,7 +65,8 @@ export interface ApiErrorBody { } export interface LoginRequest { - email: string; + /** ایمیل یا شماره موبایل */ + identifier: string; password: string; } @@ -72,8 +74,41 @@ export interface RegisterOrganizationRequest { organizationName: string; fullName: string; email: string; + mobile: string; password: string; + passwordConfirm: string; businessField: BusinessField; + referralCode?: string; + acceptedTerms?: boolean; +} + +export interface LoginChallengeResponse { + challengeId: string; + channel: 'email' | 'mobile'; + expiresInSeconds: number; + maskedDestination?: string; + /** فقط در محیط توسعه / AUTH_RETURN_OTP */ + devCode?: string; +} + +export interface RegisterChallengeResponse { + challengeId: string; + expiresInSeconds: number; + maskedEmail?: string; + maskedMobile?: string; + devEmailCode?: string; + devMobileCode?: string; +} + +export interface LoginVerifyRequest { + challengeId: string; + code: string; +} + +export interface RegisterVerifyRequest { + challengeId: string; + emailCode: string; + mobileCode: string; } export interface LoginResponse {