Use password login without OTP; keep OTP for signup and optional mobile login.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -2,7 +2,8 @@ import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
|
|||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import {
|
import {
|
||||||
LoginDto,
|
LoginDto,
|
||||||
LoginVerifyOtpDto,
|
LoginOtpRequestDto,
|
||||||
|
LoginOtpVerifyDto,
|
||||||
RegisterOrganizationDto,
|
RegisterOrganizationDto,
|
||||||
RegisterVerifyOtpDto,
|
RegisterVerifyOtpDto,
|
||||||
} from './dto';
|
} from './dto';
|
||||||
@@ -23,14 +24,22 @@ export class AuthController {
|
|||||||
return this.authService.verifyRegister(dto);
|
return this.authService.verifyRegister(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** ورود با رمز (بدون OTP) */
|
||||||
@Post('login')
|
@Post('login')
|
||||||
login(@Body() dto: LoginDto) {
|
login(@Body() dto: LoginDto) {
|
||||||
return this.authService.loginLegacy(dto);
|
return this.authService.loginWithPassword(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('login/verify')
|
/** درخواست کد ورود با موبایل */
|
||||||
loginVerify(@Body() dto: LoginVerifyOtpDto) {
|
@Post('login/otp')
|
||||||
return this.authService.verifyLogin(dto);
|
loginOtpRequest(@Body() dto: LoginOtpRequestDto) {
|
||||||
|
return this.authService.requestLoginOtp(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** تأیید کد ورود با موبایل */
|
||||||
|
@Post('login/otp/verify')
|
||||||
|
loginOtpVerify(@Body() dto: LoginOtpVerifyDto) {
|
||||||
|
return this.authService.verifyLoginOtp(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ import { PrismaService } from '../prisma/prisma.service';
|
|||||||
import { SmsService } from '../common/sms.service';
|
import { SmsService } from '../common/sms.service';
|
||||||
import {
|
import {
|
||||||
LoginDto,
|
LoginDto,
|
||||||
LoginVerifyOtpDto,
|
LoginOtpRequestDto,
|
||||||
|
LoginOtpVerifyDto,
|
||||||
RegisterOrganizationDto,
|
RegisterOrganizationDto,
|
||||||
RegisterVerifyOtpDto,
|
RegisterVerifyOtpDto,
|
||||||
} from './dto';
|
} from './dto';
|
||||||
@@ -83,7 +84,11 @@ export class AuthService {
|
|||||||
private readonly sms: SmsService,
|
private readonly sms: SmsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** مرحله ۱ ثبتنام: اعتبارسنجی + ارسال OTP ایمیل و موبایل */
|
/**
|
||||||
|
* ثبتنام مرحله ۱:
|
||||||
|
* - همیشه OTP موبایل
|
||||||
|
* - OTP ایمیل فقط وقتی AUTH_EMAIL_OTP_ENABLED=true
|
||||||
|
*/
|
||||||
async startRegister(dto: RegisterOrganizationDto) {
|
async startRegister(dto: RegisterOrganizationDto) {
|
||||||
if (dto.password !== dto.passwordConfirm) {
|
if (dto.password !== dto.passwordConfirm) {
|
||||||
throw new BadRequestException('تکرار رمز عبور مطابقت ندارد');
|
throw new BadRequestException('تکرار رمز عبور مطابقت ندارد');
|
||||||
@@ -112,8 +117,7 @@ export class AuthService {
|
|||||||
const returnOtp = this.shouldReturnOtp();
|
const returnOtp = this.shouldReturnOtp();
|
||||||
const emailOtpEnabled = this.isEmailOtpConfigured();
|
const emailOtpEnabled = this.isEmailOtpConfigured();
|
||||||
const mobileCode = generateOtp();
|
const mobileCode = generateOtp();
|
||||||
// تا وقتی سرویس ایمیل نداریم، همان کد پیامک برای تأیید ایمیل هم پذیرفته میشود
|
const emailCode = emailOtpEnabled ? generateOtp() : null;
|
||||||
const emailCode = emailOtpEnabled ? generateOtp() : mobileCode;
|
|
||||||
const passwordHash = await bcrypt.hash(dto.password, 10);
|
const passwordHash = await bcrypt.hash(dto.password, 10);
|
||||||
const payload: RegisterPayload = {
|
const payload: RegisterPayload = {
|
||||||
organizationName: dto.organizationName.trim(),
|
organizationName: dto.organizationName.trim(),
|
||||||
@@ -137,12 +141,8 @@ export class AuthService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (emailOtpEnabled) {
|
if (emailOtpEnabled && emailCode) {
|
||||||
await this.dispatchEmailOtp(email, emailCode, returnOtp);
|
await this.dispatchEmailOtp(email, emailCode, returnOtp);
|
||||||
} else {
|
|
||||||
this.logger.warn(
|
|
||||||
`[auth-otp-email-skipped] ${email} — mailer disabled; SMS code covers both`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
await this.dispatchMobileOtp(mobile, mobileCode, returnOtp);
|
await this.dispatchMobileOtp(mobile, mobileCode, returnOtp);
|
||||||
|
|
||||||
@@ -154,14 +154,14 @@ export class AuthService {
|
|||||||
emailOtpRequired: emailOtpEnabled,
|
emailOtpRequired: emailOtpEnabled,
|
||||||
...(returnOtp
|
...(returnOtp
|
||||||
? {
|
? {
|
||||||
devEmailCode: emailCode,
|
...(emailCode ? { devEmailCode: emailCode } : {}),
|
||||||
devMobileCode: mobileCode,
|
devMobileCode: mobileCode,
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** مرحله ۲ ثبتنام: تأیید هر دو کد و ساخت سازمان */
|
/** ثبتنام مرحله ۲: تأیید OTP و ساخت سازمان */
|
||||||
async verifyRegister(dto: RegisterVerifyOtpDto): Promise<LoginResponse> {
|
async verifyRegister(dto: RegisterVerifyOtpDto): Promise<LoginResponse> {
|
||||||
const challenge = await this.prisma.authChallenge.findUnique({
|
const challenge = await this.prisma.authChallenge.findUnique({
|
||||||
where: { id: dto.challengeId },
|
where: { id: dto.challengeId },
|
||||||
@@ -175,10 +175,17 @@ export class AuthService {
|
|||||||
throw new UnauthorizedException('کد تأیید نامعتبر یا منقضی است');
|
throw new UnauthorizedException('کد تأیید نامعتبر یا منقضی است');
|
||||||
}
|
}
|
||||||
|
|
||||||
const emailOk = challenge.emailCode === dto.emailCode.trim();
|
|
||||||
const mobileOk = challenge.mobileCode === dto.mobileCode.trim();
|
const mobileOk = challenge.mobileCode === dto.mobileCode.trim();
|
||||||
if (!emailOk || !mobileOk) {
|
if (!mobileOk) {
|
||||||
throw new UnauthorizedException('کد تأیید ایمیل یا موبایل نادرست است');
|
throw new UnauthorizedException('کد تأیید موبایل نادرست است');
|
||||||
|
}
|
||||||
|
|
||||||
|
const emailRequired = Boolean(challenge.emailCode);
|
||||||
|
if (emailRequired) {
|
||||||
|
const emailOk = challenge.emailCode === (dto.emailCode ?? '').trim();
|
||||||
|
if (!emailOk) {
|
||||||
|
throw new UnauthorizedException('کد تأیید ایمیل نادرست است');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const email = (challenge.email ?? '').toLowerCase();
|
const email = (challenge.email ?? '').toLowerCase();
|
||||||
@@ -221,7 +228,7 @@ export class AuthService {
|
|||||||
fullName: payload.fullName,
|
fullName: payload.fullName,
|
||||||
passwordHash: challenge.passwordHash,
|
passwordHash: challenge.passwordHash,
|
||||||
role: 'owner',
|
role: 'owner',
|
||||||
emailVerifiedAt: now,
|
emailVerifiedAt: emailRequired ? now : null,
|
||||||
mobileVerifiedAt: now,
|
mobileVerifiedAt: now,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -233,7 +240,7 @@ export class AuthService {
|
|||||||
where: { id: challenge.id },
|
where: { id: challenge.id },
|
||||||
data: {
|
data: {
|
||||||
consumedAt: now,
|
consumedAt: now,
|
||||||
emailVerified: true,
|
emailVerified: emailRequired,
|
||||||
mobileVerified: true,
|
mobileVerified: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -241,8 +248,8 @@ export class AuthService {
|
|||||||
return this.buildLoginResponse(organization.users[0]);
|
return this.buildLoginResponse(organization.users[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** مرحله ۱ ورود: ایمیل/موبایل + رمز → OTP ترجیحاً با پیامک */
|
/** ورود با رمز: ایمیل یا موبایل + رمز → JWT (بدون OTP) */
|
||||||
async startLogin(dto: LoginDto) {
|
async loginWithPassword(dto: LoginDto): Promise<LoginResponse> {
|
||||||
const identifier = (dto.identifier ?? dto.email ?? '').trim();
|
const identifier = (dto.identifier ?? dto.email ?? '').trim();
|
||||||
const isEmail = looksLikeEmail(identifier);
|
const isEmail = looksLikeEmail(identifier);
|
||||||
const email = isEmail ? identifier.toLowerCase() : null;
|
const email = isEmail ? identifier.toLowerCase() : null;
|
||||||
@@ -264,44 +271,49 @@ export class AuthService {
|
|||||||
throw new UnauthorizedException('اطلاعات ورود نادرست است');
|
throw new UnauthorizedException('اطلاعات ورود نادرست است');
|
||||||
}
|
}
|
||||||
|
|
||||||
const returnOtp = this.shouldReturnOtp();
|
return this.buildLoginResponse(user);
|
||||||
const channel = this.resolveLoginOtpChannel(user, returnOtp);
|
}
|
||||||
|
|
||||||
await this.throttleChallenges('login', user.email, user.mobile);
|
/** ورود اختیاری با موبایل + OTP (بدون رمز) — فقط کاربران ثبتشده */
|
||||||
|
async requestLoginOtp(dto: LoginOtpRequestDto) {
|
||||||
|
const mobile = normalizeMobile(dto.mobile);
|
||||||
|
if (!mobile) {
|
||||||
|
throw new BadRequestException('شماره موبایل معتبر نیست');
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await this.prisma.user.findFirst({ where: { mobile } });
|
||||||
|
if (!user) {
|
||||||
|
throw new UnauthorizedException('حسابی با این موبایل یافت نشد');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.throttleChallenges('login', user.email, mobile);
|
||||||
|
|
||||||
const code = generateOtp();
|
const code = generateOtp();
|
||||||
|
const returnOtp = this.shouldReturnOtp();
|
||||||
const challenge = await this.prisma.authChallenge.create({
|
const challenge = await this.prisma.authChallenge.create({
|
||||||
data: {
|
data: {
|
||||||
kind: 'login',
|
kind: 'login',
|
||||||
channel,
|
channel: 'mobile',
|
||||||
email: user.email,
|
email: user.email,
|
||||||
mobile: user.mobile,
|
mobile,
|
||||||
passwordHash: null,
|
|
||||||
payload: { userId: user.id } as Prisma.InputJsonValue,
|
payload: { userId: user.id } as Prisma.InputJsonValue,
|
||||||
emailCode: channel === 'email' ? code : null,
|
mobileCode: code,
|
||||||
mobileCode: channel === 'mobile' ? code : null,
|
|
||||||
expiresAt: new Date(Date.now() + OTP_TTL_SECONDS * 1000),
|
expiresAt: new Date(Date.now() + OTP_TTL_SECONDS * 1000),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (channel === 'email') {
|
await this.dispatchMobileOtp(mobile, code, returnOtp);
|
||||||
await this.dispatchEmailOtp(user.email, code, returnOtp);
|
|
||||||
} else {
|
|
||||||
await this.dispatchMobileOtp(user.mobile!, code, returnOtp);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
challengeId: challenge.id,
|
challengeId: challenge.id,
|
||||||
channel,
|
channel: 'mobile' as const,
|
||||||
expiresInSeconds: OTP_TTL_SECONDS,
|
expiresInSeconds: OTP_TTL_SECONDS,
|
||||||
maskedDestination:
|
maskedDestination: maskMobile(mobile),
|
||||||
channel === 'email' ? maskEmail(user.email) : maskMobile(user.mobile!),
|
|
||||||
...(returnOtp ? { devCode: code } : {}),
|
...(returnOtp ? { devCode: code } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** مرحله ۲ ورود: تأیید OTP */
|
async verifyLoginOtp(dto: LoginOtpVerifyDto): Promise<LoginResponse> {
|
||||||
async verifyLogin(dto: LoginVerifyOtpDto): Promise<LoginResponse> {
|
|
||||||
const challenge = await this.prisma.authChallenge.findUnique({
|
const challenge = await this.prisma.authChallenge.findUnique({
|
||||||
where: { id: dto.challengeId },
|
where: { id: dto.challengeId },
|
||||||
});
|
});
|
||||||
@@ -314,20 +326,16 @@ export class AuthService {
|
|||||||
throw new UnauthorizedException('کد تأیید نامعتبر یا منقضی است');
|
throw new UnauthorizedException('کد تأیید نامعتبر یا منقضی است');
|
||||||
}
|
}
|
||||||
|
|
||||||
const code = dto.code.trim();
|
if (!challenge.mobileCode || challenge.mobileCode !== dto.code.trim()) {
|
||||||
const expected =
|
|
||||||
challenge.channel === 'email' ? challenge.emailCode : challenge.mobileCode;
|
|
||||||
if (!expected || expected !== code) {
|
|
||||||
throw new UnauthorizedException('کد تأیید نادرست است');
|
throw new UnauthorizedException('کد تأیید نادرست است');
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = (challenge.payload ?? {}) as { userId?: string };
|
const payload = (challenge.payload ?? {}) as { userId?: string };
|
||||||
const userId = payload.userId;
|
if (!payload.userId) {
|
||||||
if (!userId) {
|
|
||||||
throw new BadRequestException('چالش ورود ناقص است');
|
throw new BadRequestException('چالش ورود ناقص است');
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
const user = await this.prisma.user.findUnique({ where: { id: payload.userId } });
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new UnauthorizedException();
|
throw new UnauthorizedException();
|
||||||
}
|
}
|
||||||
@@ -335,29 +343,18 @@ export class AuthService {
|
|||||||
const now = new Date();
|
const now = new Date();
|
||||||
await this.prisma.authChallenge.update({
|
await this.prisma.authChallenge.update({
|
||||||
where: { id: challenge.id },
|
where: { id: challenge.id },
|
||||||
data: {
|
data: { consumedAt: now, mobileVerified: true },
|
||||||
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 },
|
|
||||||
});
|
});
|
||||||
|
if (!user.mobileVerifiedAt) {
|
||||||
|
await this.prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: { mobileVerifiedAt: now },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return this.buildLoginResponse(user);
|
return this.buildLoginResponse(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** سازگاری موقت: اگر کلاینت قدیمی email بفرستد */
|
|
||||||
async loginLegacy(body: LoginDto) {
|
|
||||||
return this.startLogin(body);
|
|
||||||
}
|
|
||||||
|
|
||||||
async me(userId: string): Promise<AuthUserDto> {
|
async me(userId: string): Promise<AuthUserDto> {
|
||||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -398,37 +395,10 @@ export class AuthService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** تا وقتی SMTP/سرویس ایمیل واقعی نداریم خاموش است */
|
|
||||||
private isEmailOtpConfigured(): boolean {
|
private isEmailOtpConfigured(): boolean {
|
||||||
return this.config.get<string>('AUTH_EMAIL_OTP_ENABLED') === 'true';
|
return this.config.get<string>('AUTH_EMAIL_OTP_ENABLED') === 'true';
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveLoginOtpChannel(
|
|
||||||
user: { email: string; mobile: string | null },
|
|
||||||
returnOtp: boolean,
|
|
||||||
): 'email' | 'mobile' {
|
|
||||||
// ترجیح: پیامک (سرویس آماده است)
|
|
||||||
if (user.mobile) {
|
|
||||||
if (this.sms.isConfigured() || returnOtp) {
|
|
||||||
return 'mobile';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.isEmailOtpConfigured() || returnOtp) {
|
|
||||||
return 'email';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user.mobile) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
'برای ورود باید موبایل روی حساب ثبت باشد (ارسال ایمیل فعال نیست)',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new BadRequestException(
|
|
||||||
'سامانه پیامک پیکربندی نشده است؛ با پشتیبانی تماس بگیرید',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async dispatchEmailOtp(
|
private async dispatchEmailOtp(
|
||||||
email: string,
|
email: string,
|
||||||
code: string,
|
code: string,
|
||||||
|
|||||||
@@ -10,13 +10,11 @@ import {
|
|||||||
import { BUSINESS_FIELDS } from '@mento/shared';
|
import { BUSINESS_FIELDS } from '@mento/shared';
|
||||||
|
|
||||||
export class LoginDto {
|
export class LoginDto {
|
||||||
/** ایمیل یا موبایل — کلاینت جدید */
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(3)
|
@MinLength(3)
|
||||||
identifier?: string;
|
identifier?: string;
|
||||||
|
|
||||||
/** سازگاری با کلاینت قدیمی */
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsEmail()
|
@IsEmail()
|
||||||
email?: string;
|
email?: string;
|
||||||
@@ -26,7 +24,15 @@ export class LoginDto {
|
|||||||
password!: string;
|
password!: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class LoginVerifyOtpDto {
|
export class LoginOtpRequestDto {
|
||||||
|
@IsString()
|
||||||
|
@Matches(/^09\d{9}$|^\+98\d{10}$|^98\d{10}$|^9\d{9}$/, {
|
||||||
|
message: 'شماره موبایل معتبر نیست',
|
||||||
|
})
|
||||||
|
mobile!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LoginOtpVerifyDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
challengeId!: string;
|
challengeId!: string;
|
||||||
|
|
||||||
@@ -77,9 +83,10 @@ export class RegisterVerifyOtpDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
challengeId!: string;
|
challengeId!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(4)
|
@MinLength(4)
|
||||||
emailCode!: string;
|
emailCode?: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(4)
|
@MinLength(4)
|
||||||
|
|||||||
@@ -45,16 +45,23 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
clearLegacy();
|
clearLegacy();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startLogin(identifier: string, password: string) {
|
async function login(identifier: string, password: string) {
|
||||||
const { data } = await api.post<LoginChallengeResponse>('/auth/login', {
|
const { data } = await api.post<LoginResponse>('/auth/login', {
|
||||||
identifier,
|
identifier,
|
||||||
password,
|
password,
|
||||||
});
|
});
|
||||||
|
persist(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestLoginOtp(mobile: string) {
|
||||||
|
const { data } = await api.post<LoginChallengeResponse>('/auth/login/otp', {
|
||||||
|
mobile,
|
||||||
|
});
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function verifyLogin(challengeId: string, code: string) {
|
async function verifyLoginOtp(challengeId: string, code: string) {
|
||||||
const { data } = await api.post<LoginResponse>('/auth/login/verify', {
|
const { data } = await api.post<LoginResponse>('/auth/login/otp/verify', {
|
||||||
challengeId,
|
challengeId,
|
||||||
code,
|
code,
|
||||||
});
|
});
|
||||||
@@ -71,13 +78,13 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
|
|
||||||
async function verifyRegister(
|
async function verifyRegister(
|
||||||
challengeId: string,
|
challengeId: string,
|
||||||
emailCode: string,
|
|
||||||
mobileCode: string,
|
mobileCode: string,
|
||||||
|
emailCode?: string,
|
||||||
) {
|
) {
|
||||||
const { data } = await api.post<LoginResponse>('/auth/register/verify', {
|
const { data } = await api.post<LoginResponse>('/auth/register/verify', {
|
||||||
challengeId,
|
challengeId,
|
||||||
emailCode,
|
|
||||||
mobileCode,
|
mobileCode,
|
||||||
|
...(emailCode ? { emailCode } : {}),
|
||||||
});
|
});
|
||||||
persist(data);
|
persist(data);
|
||||||
}
|
}
|
||||||
@@ -94,8 +101,9 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
token,
|
token,
|
||||||
user,
|
user,
|
||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
startLogin,
|
login,
|
||||||
verifyLogin,
|
requestLoginOtp,
|
||||||
|
verifyLoginOtp,
|
||||||
startRegister,
|
startRegister,
|
||||||
verifyRegister,
|
verifyRegister,
|
||||||
logout,
|
logout,
|
||||||
|
|||||||
@@ -11,9 +11,13 @@ const auth = useAuthStore();
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
|
||||||
const step = ref<'credentials' | 'otp'>('credentials');
|
/** password = ورود با رمز | otp = ورود با کد موبایل */
|
||||||
|
const mode = ref<'password' | 'otp'>('password');
|
||||||
|
const otpStep = ref<'mobile' | 'code'>('mobile');
|
||||||
|
|
||||||
const identifier = ref('owner@demo.local');
|
const identifier = ref('owner@demo.local');
|
||||||
const password = ref('demo1234');
|
const password = ref('demo1234');
|
||||||
|
const mobile = ref('');
|
||||||
const showPassword = ref(false);
|
const showPassword = ref(false);
|
||||||
const remember = ref(true);
|
const remember = ref(true);
|
||||||
const otpCode = ref('');
|
const otpCode = ref('');
|
||||||
@@ -22,11 +26,8 @@ const error = ref('');
|
|||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
|
||||||
const otpHint = computed(() => {
|
const otpHint = computed(() => {
|
||||||
if (!challenge.value) return '';
|
const dest = challenge.value?.maskedDestination ?? '';
|
||||||
const dest = challenge.value.maskedDestination ?? '';
|
return dest ? `کد ارسالشده به ${dest} را وارد کنید` : 'کد یکبارمصرف را وارد کنید';
|
||||||
return challenge.value.channel === 'email'
|
|
||||||
? `کد ارسالشده به ایمیل ${dest} را وارد کنید`
|
|
||||||
: `کد ارسالشده به موبایل ${dest} را وارد کنید`;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function apiError(e: unknown, fallback: string) {
|
function apiError(e: unknown, fallback: string) {
|
||||||
@@ -40,15 +41,17 @@ function apiError(e: unknown, fallback: string) {
|
|||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitCredentials() {
|
async function goHome() {
|
||||||
|
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/';
|
||||||
|
await router.push(redirect);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitPasswordLogin() {
|
||||||
error.value = '';
|
error.value = '';
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
challenge.value = await auth.startLogin(identifier.value.trim(), password.value);
|
await auth.login(identifier.value.trim(), password.value);
|
||||||
if (challenge.value.devCode) {
|
await goHome();
|
||||||
otpCode.value = challenge.value.devCode;
|
|
||||||
}
|
|
||||||
step.value = 'otp';
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = apiError(e, 'ورود ناموفق بود');
|
error.value = apiError(e, 'ورود ناموفق بود');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -56,14 +59,27 @@ async function submitCredentials() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitOtp() {
|
async function submitOtpMobile() {
|
||||||
|
error.value = '';
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
challenge.value = await auth.requestLoginOtp(mobile.value.trim());
|
||||||
|
if (challenge.value.devCode) otpCode.value = challenge.value.devCode;
|
||||||
|
otpStep.value = 'code';
|
||||||
|
} catch (e) {
|
||||||
|
error.value = apiError(e, 'ارسال کد ناموفق بود');
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitOtpCode() {
|
||||||
if (!challenge.value) return;
|
if (!challenge.value) return;
|
||||||
error.value = '';
|
error.value = '';
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
await auth.verifyLogin(challenge.value.challengeId, otpCode.value.trim());
|
await auth.verifyLoginOtp(challenge.value.challengeId, otpCode.value.trim());
|
||||||
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/';
|
await goHome();
|
||||||
await router.push(redirect);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = apiError(e, 'تأیید کد ناموفق بود');
|
error.value = apiError(e, 'تأیید کد ناموفق بود');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -71,8 +87,16 @@ async function submitOtp() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function backToCredentials() {
|
function switchMode(next: 'password' | 'otp') {
|
||||||
step.value = 'credentials';
|
mode.value = next;
|
||||||
|
otpStep.value = 'mobile';
|
||||||
|
challenge.value = null;
|
||||||
|
otpCode.value = '';
|
||||||
|
error.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function backToOtpMobile() {
|
||||||
|
otpStep.value = 'mobile';
|
||||||
otpCode.value = '';
|
otpCode.value = '';
|
||||||
challenge.value = null;
|
challenge.value = null;
|
||||||
error.value = '';
|
error.value = '';
|
||||||
@@ -92,11 +116,32 @@ function backToCredentials() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="form-inner">
|
<div class="form-inner">
|
||||||
<template v-if="step === 'credentials'">
|
<h1>خوش آمدید 👋</h1>
|
||||||
<h1>خوش آمدید 👋</h1>
|
<p class="lead">برای ورود به حساب کاربری خود، اطلاعاتتان را وارد کنید</p>
|
||||||
<p class="lead">برای ورود به حساب کاربری خود، اطلاعاتتان را وارد کنید</p>
|
|
||||||
|
|
||||||
<form class="form" @submit.prevent="submitCredentials">
|
<div class="mode-tabs" role="tablist">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
:aria-selected="mode === 'password'"
|
||||||
|
:class="{ on: mode === 'password' }"
|
||||||
|
@click="switchMode('password')"
|
||||||
|
>
|
||||||
|
ورود با رمز
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
:aria-selected="mode === 'otp'"
|
||||||
|
:class="{ on: mode === 'otp' }"
|
||||||
|
@click="switchMode('otp')"
|
||||||
|
>
|
||||||
|
ورود با کد یکبارمصرف
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-if="mode === 'password'">
|
||||||
|
<form class="form" @submit.prevent="submitPasswordLogin">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="identifier">ایمیل یا شماره موبایل</label>
|
<label for="identifier">ایمیل یا شماره موبایل</label>
|
||||||
<div class="input-wrap">
|
<div class="input-wrap">
|
||||||
@@ -147,20 +192,42 @@ function backToCredentials() {
|
|||||||
|
|
||||||
<p v-if="error" class="error">{{ error }}</p>
|
<p v-if="error" class="error">{{ error }}</p>
|
||||||
<button class="btn submit" type="submit" :disabled="loading">
|
<button class="btn submit" type="submit" :disabled="loading">
|
||||||
{{ loading ? 'در حال ارسال کد…' : 'ورود' }}
|
{{ loading ? 'در حال ورود…' : 'ورود' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="otpStep === 'mobile'">
|
||||||
|
<form class="form" @submit.prevent="submitOtpMobile">
|
||||||
|
<div class="field">
|
||||||
|
<label for="mobile">شماره موبایل</label>
|
||||||
|
<div class="input-wrap">
|
||||||
|
<input
|
||||||
|
id="mobile"
|
||||||
|
v-model="mobile"
|
||||||
|
type="tel"
|
||||||
|
required
|
||||||
|
autocomplete="tel"
|
||||||
|
placeholder="۰۹۱۲۱۲۳۴۵۶۷"
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
<span class="ico user" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-if="error" class="error">{{ error }}</p>
|
||||||
|
<button class="btn submit" type="submit" :disabled="loading">
|
||||||
|
{{ loading ? 'در حال ارسال کد…' : 'ارسال کد' }}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<button type="button" class="back" @click="backToCredentials">بازگشت</button>
|
<button type="button" class="back" @click="backToOtpMobile">بازگشت</button>
|
||||||
<h1>تأیید هویت</h1>
|
<p class="otp-lead">{{ otpHint }}</p>
|
||||||
<p class="lead">{{ otpHint }}</p>
|
|
||||||
<p v-if="challenge?.devCode" class="dev-hint">
|
<p v-if="challenge?.devCode" class="dev-hint">
|
||||||
کد توسعه: <strong>{{ challenge.devCode }}</strong>
|
کد توسعه: <strong>{{ challenge.devCode }}</strong>
|
||||||
</p>
|
</p>
|
||||||
|
<form class="form" @submit.prevent="submitOtpCode">
|
||||||
<form class="form" @submit.prevent="submitOtp">
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="otp">کد یکبارمصرف</label>
|
<label for="otp">کد یکبارمصرف</label>
|
||||||
<div class="input-wrap">
|
<div class="input-wrap">
|
||||||
@@ -272,11 +339,38 @@ h1 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.lead {
|
.lead {
|
||||||
margin: 0 0 1.4rem;
|
margin: 0 0 1rem;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mode-tabs {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0.35rem;
|
||||||
|
margin-bottom: 1.1rem;
|
||||||
|
padding: 0.3rem;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: color-mix(in srgb, var(--line) 55%, white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-tabs button {
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 11px;
|
||||||
|
padding: 0.65rem 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-tabs button.on {
|
||||||
|
background: #fff;
|
||||||
|
color: var(--ink);
|
||||||
|
box-shadow: 0 1px 4px rgba(15, 23, 42, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
.form {
|
.form {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.95rem;
|
gap: 0.95rem;
|
||||||
@@ -392,8 +486,14 @@ h1 {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.otp-lead {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
.dev-hint {
|
.dev-hint {
|
||||||
margin: -0.6rem 0 1rem;
|
margin: -0.4rem 0 1rem;
|
||||||
padding: 0.65rem 0.8rem;
|
padding: 0.65rem 0.8rem;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
background: var(--accent-soft);
|
background: var(--accent-soft);
|
||||||
|
|||||||
@@ -81,9 +81,9 @@ async function submitOtp() {
|
|||||||
const mobile = mobileCode.value.trim();
|
const mobile = mobileCode.value.trim();
|
||||||
const email =
|
const email =
|
||||||
challenge.value.emailOtpRequired === false
|
challenge.value.emailOtpRequired === false
|
||||||
? mobile
|
? undefined
|
||||||
: emailCode.value.trim();
|
: emailCode.value.trim();
|
||||||
await auth.verifyRegister(challenge.value.challengeId, email, mobile);
|
await auth.verifyRegister(challenge.value.challengeId, mobile, email);
|
||||||
await router.push('/');
|
await router.push('/');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = apiError(e, 'تأیید کد ناموفق بود');
|
error.value = apiError(e, 'تأیید کد ناموفق بود');
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ export interface LoginVerifyRequest {
|
|||||||
|
|
||||||
export interface RegisterVerifyRequest {
|
export interface RegisterVerifyRequest {
|
||||||
challengeId: string;
|
challengeId: string;
|
||||||
emailCode: string;
|
emailCode?: string;
|
||||||
mobileCode: string;
|
mobileCode: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user