Promote Leads to a top-level nav hub and expire login OTP after 90s.

Leads items sit under their own hub after Customers; login OTP shows a countdown and returns to the mobile step when time runs out.
This commit is contained in:
2026-09-27 18:00:59 +03:30
parent c079295ff4
commit ac3ffb8ab2
5 changed files with 4019 additions and 3915 deletions
+70 -6
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { computed, onBeforeUnmount, ref } from 'vue';
import { useRouter, useRoute, RouterLink } from 'vue-router';
import { useAuthStore } from '@/stores/auth';
import AuthBrandPanel from '@/components/auth/AuthBrandPanel.vue';
@@ -10,6 +10,7 @@ import { USER_ROLE_LABELS } from '@/lib/roles';
import { brand } from '@mento/shared';
import BrandName from '@/components/brand/BrandName.vue';
import UiIcon from '@/components/UiIcon.vue';
import { toFaDigits } from '@/lib/jalali';
const auth = useAuthStore();
const router = useRouter();
@@ -29,12 +30,42 @@ const challenge = ref<LoginChallengeResponse | null>(null);
const pendingChoice = ref<LoginOrganizationChoiceResponse | null>(null);
const error = ref('');
const loading = ref(false);
const otpSecondsLeft = ref(0);
let otpTimer: ReturnType<typeof setInterval> | null = null;
const otpHint = computed(() => {
const dest = challenge.value?.maskedDestination ?? '';
return dest ? `کد ارسال‌شده به ${dest} را وارد کنید` : 'کد یکبارمصرف را وارد کنید';
});
const otpTimerLabel = computed(() => {
const s = Math.max(0, otpSecondsLeft.value);
const mm = Math.floor(s / 60);
const ss = s % 60;
return `${toFaDigits(mm)}:${toFaDigits(String(ss).padStart(2, '0'))}`;
});
function clearOtpTimer() {
if (otpTimer) {
clearInterval(otpTimer);
otpTimer = null;
}
otpSecondsLeft.value = 0;
}
function startOtpTimer(seconds: number) {
clearOtpTimer();
otpSecondsLeft.value = Math.max(1, Math.round(seconds) || 90);
otpTimer = setInterval(() => {
if (otpSecondsLeft.value <= 1) {
clearOtpTimer();
backToOtpMobile('زمان کد به پایان رسید. دوباره درخواست دهید.');
return;
}
otpSecondsLeft.value -= 1;
}, 1000);
}
function apiError(e: unknown, fallback: string) {
if (axios.isAxiosError(e)) {
if (!e.response) {
@@ -79,6 +110,7 @@ async function submitOtpMobile() {
challenge.value = await auth.requestLoginOtp(mobile.value.trim());
if (challenge.value.devCode) otpCode.value = challenge.value.devCode;
otpStep.value = 'code';
startOtpTimer(challenge.value.expiresInSeconds ?? 90);
} catch (e) {
error.value = apiError(e, 'ارسال کد ناموفق بود');
} finally {
@@ -88,10 +120,15 @@ async function submitOtpMobile() {
async function submitOtpCode() {
if (!challenge.value) return;
if (otpSecondsLeft.value <= 0) {
backToOtpMobile('زمان کد به پایان رسیده است.');
return;
}
error.value = '';
loading.value = true;
try {
const result = await auth.verifyLoginOtp(challenge.value.challengeId, otpCode.value.trim());
clearOtpTimer();
await afterAuth(result);
} catch (e) {
error.value = apiError(e, 'تأیید کد ناموفق بود');
@@ -102,17 +139,19 @@ async function submitOtpCode() {
function switchMode(next: 'password' | 'otp') {
mode.value = next;
clearOtpTimer();
otpStep.value = 'mobile';
challenge.value = null;
otpCode.value = '';
error.value = '';
}
function backToOtpMobile() {
function backToOtpMobile(message?: string) {
clearOtpTimer();
otpStep.value = 'mobile';
otpCode.value = '';
challenge.value = null;
error.value = '';
error.value = message ?? '';
}
async function pickOrganization(organizationId: string) {
@@ -133,6 +172,10 @@ function cancelOrgChoice() {
pendingChoice.value = null;
error.value = '';
}
onBeforeUnmount(() => {
clearOtpTimer();
});
</script>
<template>
@@ -286,8 +329,11 @@ function cancelOrgChoice() {
</template>
<template v-else>
<button type="button" class="back" @click="backToOtpMobile">بازگشت</button>
<button type="button" class="back" @click="backToOtpMobile()">بازگشت</button>
<p class="otp-lead">{{ otpHint }}</p>
<p class="otp-timer" :class="{ urgent: otpSecondsLeft <= 15 }" aria-live="polite">
اعتبار کد: <strong>{{ otpTimerLabel }}</strong>
</p>
<p v-if="challenge?.devCode" class="dev-hint">
کد توسعه: <strong>{{ challenge.devCode }}</strong>
</p>
@@ -309,7 +355,7 @@ function cancelOrgChoice() {
</div>
</div>
<p v-if="error" class="error">{{ error }}</p>
<button class="btn submit" type="submit" :disabled="loading">
<button class="btn submit" type="submit" :disabled="loading || otpSecondsLeft <= 0">
{{ loading ? 'در حال تأیید…' : 'تأیید و ورود' }}
</button>
</form>
@@ -566,11 +612,29 @@ h1 {
}
.otp-lead {
margin: 0 0 1rem;
margin: 0 0 0.5rem;
color: var(--muted);
line-height: 1.7;
}
.otp-timer {
margin: 0 0 1rem;
color: var(--text);
font-size: 0.92rem;
font-variant-numeric: tabular-nums;
direction: ltr;
text-align: right;
}
.otp-timer strong {
font-weight: 700;
letter-spacing: 0.04em;
}
.otp-timer.urgent {
color: #b91c1c;
}
.dev-hint {
margin: -0.4rem 0 1rem;
padding: 0.65rem 0.8rem;