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:
2026-09-05 01:37:15 +03:30
co-authored by Cursor
parent 78511a7d53
commit 55d9b494e6
7 changed files with 232 additions and 138 deletions
+14 -5
View File
@@ -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)
+58 -88
View File
@@ -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,
+11 -4
View File
@@ -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)
+16 -8
View File
@@ -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,
+130 -30
View File
@@ -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);
+2 -2
View File
@@ -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, 'تأیید کد ناموفق بود');
+1 -1
View File
@@ -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;
} }