اپ: نام برند در همهی صفحهها و پیامها یکسان شده و از یک تنظیم مرکزی خوانده میشود. اپ: کارت حسابداری در صفحهی مشتری بازطراحی شد؛ ماندهها و وضعیت لینک واضحتر نمایش داده میشوند. پنل: کارت حسابداری در پروندهی مشتری بازطراحی شد؛ ماندهها، آخرین ارسال و فرم لینک دستی واضحتر شدهاند. پنل: ستون «کد» در جدولها بهطور پیشفرض پنهان است و از انتخاب ستونها قابل نمایش است. پنل: تغییر نمایش ستونها در جدولها بلافاصله اعمال میشود. Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
365 lines
11 KiB
Vue
365 lines
11 KiB
Vue
<!--
|
|
Ported from legacy henza_pwa/src/views/user/LoginRegister/components/Login.vue,
|
|
restyled into the redesigned design language (see components/home/* and
|
|
HomeView).
|
|
|
|
The flow is REAL. These notes used to say it was stubbed, and that stale text
|
|
misled a reader as recently as 2026-10-12, so it is corrected rather than
|
|
deleted:
|
|
- Step 1 posts the number to the server, which looks it up itself. It no
|
|
longer pulls the customer list into the client to match on `mobile` — that
|
|
was the login leak (D8). An unregistered number is answered with 404 and
|
|
the server's Persian message is shown; owner ruling 2026-10-12, because
|
|
the earlier opaque answer walked the person to the code box to wait for an
|
|
SMS that was never going to arrive.
|
|
- Step 2 IS verified by the server: hashed code, two-minute expiry, attempt
|
|
limit (D6). Accepting any 4-digit code is long gone.
|
|
- `token.fireBaseToken` still does not exist in this port (PUSH-01).
|
|
- `useOtpToken()` (WebOTP autofill) was already dead in legacy itself.
|
|
-->
|
|
<template>
|
|
<div class="auth">
|
|
<PageBar title="ورود" back-to="home" />
|
|
|
|
<container class="auth__body">
|
|
<section class="auth__intro">
|
|
<div class="auth__mark" aria-hidden="true">
|
|
<Icon icon="vuesax-linear:security-user" size="28" />
|
|
</div>
|
|
<h2 class="auth__title">ورود به {{ brand.name }}</h2>
|
|
<p class="auth__sub">
|
|
{{ body.step === 1 ? 'شماره موبایل خود را وارد کنید' : 'رمز یکبارمصرف را وارد کنید' }}
|
|
</p>
|
|
</section>
|
|
|
|
<section class="auth__card">
|
|
<!-- Step 2: OTP -->
|
|
<template v-if="body.step === 2">
|
|
<OtpInput
|
|
class="auth__otp"
|
|
input-classes="otp-input"
|
|
inputType="tel"
|
|
:num-inputs="4"
|
|
:should-auto-focus="true"
|
|
:defaultFocus="body.defaultFocus"
|
|
@on-complete="sendToken"
|
|
/>
|
|
|
|
<p v-if="timer > 0" class="auth__timer">
|
|
امکان دریافت مجدد رمز در {{ counterZeroShow(timer) }} ثانیه دیگر
|
|
</p>
|
|
<p v-else class="auth__timer">برای دریافت رمز عبور مجدد تلاش کنید</p>
|
|
</template>
|
|
|
|
<Form :model="formModel" layout="vertical" @finish="sendPhone" class="auth__form">
|
|
<FormItem
|
|
v-show="body.step === 1"
|
|
name="username"
|
|
label="تلفن همراه"
|
|
:rules="[required('تلفن همراه'), mobileRule('تلفن همراه')]"
|
|
>
|
|
<Input type="number" v-model:value="formModel.username" />
|
|
</FormItem>
|
|
|
|
<button
|
|
type="submit"
|
|
class="auth__cta"
|
|
:class="{ 'is-loading': body.loading }"
|
|
:disabled="body.loading || (timer > 0 && body.step === 2)"
|
|
>
|
|
{{ btnText }}
|
|
</button>
|
|
</Form>
|
|
</section>
|
|
|
|
<p class="auth__switch">
|
|
اگر ثبت نام نکردهاید
|
|
<button type="button" @click="emit('register', true)">ثبت نام کنید</button>
|
|
</p>
|
|
</container>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { brand } from '../../brand'
|
|
import { computed, nextTick, reactive, ref } from 'vue'
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
import { Form, FormItem, Input } from 'ant-design-vue'
|
|
import Icon from '../../components/icon/Icon.vue'
|
|
import PageBar from '../../components/ui/PageBar.vue'
|
|
import { toast } from '../../composables/useToast'
|
|
import OtpInput from '../../components/input/OtpInput.vue'
|
|
import { required, mobile as mobileRule } from '../../utils/validator'
|
|
import { useSessionStore } from '../../stores/session'
|
|
import { api } from '../../api/client'
|
|
|
|
const emit = defineEmits<{ (e: 'register', value: boolean): void }>()
|
|
const router = useRouter()
|
|
const route = useRoute()
|
|
const session = useSessionStore()
|
|
|
|
const formModel = reactive({ username: undefined as string | undefined })
|
|
const body = reactive({
|
|
step: 1,
|
|
loading: false,
|
|
defaultFocus: false,
|
|
})
|
|
|
|
const timer = ref(0)
|
|
const countDownTimer = () => {
|
|
if (timer.value > 0) {
|
|
setTimeout(() => {
|
|
if (timer.value > 0) {
|
|
timer.value -= 1
|
|
countDownTimer()
|
|
}
|
|
}, 1000)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Step 1 — ask the server to send an SMS code.
|
|
*
|
|
* This used to download every customer via GET /customers and match the phone
|
|
* number here in the browser, which handed any visitor the whole customer book
|
|
* (SEC-08). The lookup now happens server-side, exactly as the reference did it
|
|
* (henza_server ApiMobileController::userAuth, :39-49).
|
|
*
|
|
* The response is deliberately the same whether or not the number is
|
|
* registered, so an unknown number can no longer be told apart from a known
|
|
* one. That means we advance to step 2 either way; a number that does not exist
|
|
* simply never receives a code, and the verify call then rejects it.
|
|
*/
|
|
const sendPhone = async () => {
|
|
try {
|
|
body.loading = true
|
|
await api.requestOtp(String(formModel.username ?? ''))
|
|
body.step = 2
|
|
timer.value = 120
|
|
countDownTimer()
|
|
await nextTick(() => {
|
|
body.defaultFocus = true
|
|
})
|
|
} catch (e: unknown) {
|
|
// Both of these carry a Persian, user-facing `message` from the server, so
|
|
// show it instead of a generic one:
|
|
// 429 — the send throttle (too soon, or too many in the window);
|
|
// 404 — the number is not registered (owner ruling 2026-10-12). Without
|
|
// this branch the person is told "خطا در ارسال کد"، which is the
|
|
// very confusion the 404 was added to remove: they would still not
|
|
// know whether the SMS was late or the number was never on file.
|
|
const text =
|
|
e instanceof Error && /\((?:429|404|403)\)/.test(e.message) ? extractServerMessage(e) : null
|
|
toast.error(text ?? 'خطا در ارسال کد. لطفاً دوباره تلاش کنید.')
|
|
} finally {
|
|
body.loading = false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Step 2 — submit the code.
|
|
*
|
|
* The server compares it (reference: ApiMobileController::userVerfiy, :51-57,
|
|
* which aborts 422 on a mismatch) and returns a real token plus that one
|
|
* customer. Previously any 4-digit code was accepted client-side.
|
|
*/
|
|
const sendToken = async (otpCode: string) => {
|
|
try {
|
|
body.loading = true
|
|
const verified = await api.verifyOtp(String(formModel.username ?? ''), otpCode)
|
|
const { token, customer } = verified
|
|
session.login(token, customer.id, customer.mobile)
|
|
if (verified.iamBootstrapUrl) {
|
|
window.location.assign(verified.iamBootstrapUrl)
|
|
return
|
|
}
|
|
// D21 — ask the server which kind of principal this token is, so the
|
|
// expert-only entries appear for a sales expert. Failure is non-fatal:
|
|
// the entries simply stay hidden, and the server refuses those routes
|
|
// for anyone who is not an expert anyway.
|
|
try {
|
|
const who = await api.getExpertIdentity()
|
|
session.setSalesExpert(who.isSalesExpert)
|
|
} catch {
|
|
session.setSalesExpert(false)
|
|
}
|
|
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : undefined
|
|
// `replace`, not `push`: the login screen must not stay in history. Pushed,
|
|
// going back from the page they just reached returns them to the login box
|
|
// they have already used, which the guard above then bounces to home — so
|
|
// one tap of "back" loses their place entirely.
|
|
await router.replace(redirect ?? { name: 'home' })
|
|
toast.success('با موفقیت وارد شدید')
|
|
} catch (e: unknown) {
|
|
toast.error(
|
|
e instanceof Error && /\(429\)/.test(e.message)
|
|
? (extractServerMessage(e) ?? 'تعداد تلاشها بیش از حد مجاز است.')
|
|
: 'کد وارد شده صحیح نیست',
|
|
)
|
|
} finally {
|
|
body.loading = false
|
|
}
|
|
}
|
|
|
|
/** Pulls the API's Persian `message` out of the thrown error's body text. */
|
|
function extractServerMessage(e: Error): string | null {
|
|
const match = e.message.match(/\{.*\}/)
|
|
if (!match) return null
|
|
try {
|
|
const parsed = JSON.parse(match[0]) as { message?: string }
|
|
return typeof parsed.message === 'string' ? parsed.message : null
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
const counterZeroShow = (value: number) => (value < 10 ? '0' + value : value)
|
|
|
|
const btnText = computed(() => (body.step === 1 ? 'دریافت رمز عبور' : 'دریافت مجدد رمز عبور'))
|
|
</script>
|
|
|
|
<style scoped lang="less">
|
|
.auth {
|
|
background: @page;
|
|
min-height: 100vh;
|
|
}
|
|
|
|
.auth__body {
|
|
padding-top: 86px;
|
|
padding-bottom: 90px;
|
|
}
|
|
|
|
.auth__intro {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
text-align: center;
|
|
padding-bottom: 24px;
|
|
}
|
|
|
|
.auth__mark {
|
|
width: 64px;
|
|
height: 64px;
|
|
border-radius: 50%;
|
|
display: grid;
|
|
place-items: center;
|
|
color: @wine;
|
|
background: @wineWash;
|
|
}
|
|
|
|
.auth__title {
|
|
margin: 14px 0 0;
|
|
font-size: 16px;
|
|
font-weight: 800;
|
|
color: @ink;
|
|
}
|
|
|
|
.auth__sub {
|
|
margin: 6px 0 0;
|
|
font-size: 12.5px;
|
|
color: @inkFaint;
|
|
}
|
|
|
|
.auth__card {
|
|
padding: 18px 16px;
|
|
border: 1px solid @hairline;
|
|
border-radius: 20px;
|
|
background: @canvasSoft;
|
|
}
|
|
|
|
.auth__otp {
|
|
display: flex;
|
|
justify-content: center;
|
|
gap: 10px;
|
|
direction: ltr;
|
|
padding-bottom: 6px;
|
|
}
|
|
|
|
.auth__timer {
|
|
margin: 12px 0 16px;
|
|
text-align: center;
|
|
font-size: 11.5px;
|
|
color: @inkMuted;
|
|
}
|
|
|
|
.auth__form :deep(.ant-form-item-label > label) {
|
|
font-size: 12px;
|
|
color: @inkMuted;
|
|
}
|
|
|
|
.auth__cta {
|
|
width: 100%;
|
|
padding: 13px 18px;
|
|
border: 0;
|
|
cursor: pointer;
|
|
border-radius: 14px;
|
|
font-family: inherit;
|
|
font-size: 13px;
|
|
font-weight: 700;
|
|
color: #fff;
|
|
background: @primary;
|
|
transition: transform 0.15s ease, opacity 0.15s ease;
|
|
}
|
|
|
|
.auth__cta:active {
|
|
transform: scale(0.99);
|
|
}
|
|
|
|
.auth__cta:disabled {
|
|
opacity: 0.45;
|
|
cursor: default;
|
|
}
|
|
|
|
.auth__switch {
|
|
margin: 20px 0 0;
|
|
text-align: center;
|
|
font-size: 12px;
|
|
color: @inkMuted;
|
|
}
|
|
|
|
.auth__switch button {
|
|
border: 0;
|
|
background: none;
|
|
cursor: pointer;
|
|
padding: 0 3px;
|
|
font-family: inherit;
|
|
font-size: 12px;
|
|
font-weight: 700;
|
|
color: @wine;
|
|
}
|
|
</style>
|
|
|
|
<style lang="less">
|
|
/* OtpInput renders its inputs with this class from a prop, so the rule cannot
|
|
be scoped to this component. */
|
|
.otp-input {
|
|
width: 48px;
|
|
height: 52px;
|
|
padding: 5px;
|
|
margin: 0;
|
|
font-size: 19px;
|
|
font-weight: 700;
|
|
color: @ink;
|
|
background: @canvas;
|
|
border: 1px solid @hairline;
|
|
border-radius: 13px;
|
|
text-align: center;
|
|
transition: border-color 0.18s ease;
|
|
}
|
|
|
|
.otp-input:focus {
|
|
outline: none;
|
|
border-color: @primary;
|
|
}
|
|
|
|
.otp-input.error {
|
|
border-color: @danger !important;
|
|
}
|
|
|
|
.otp-input::-webkit-inner-spin-button,
|
|
.otp-input::-webkit-outer-spin-button {
|
|
-webkit-appearance: none;
|
|
margin: 0;
|
|
}
|
|
</style>
|