Reported by the owner: a number that is not in the system produced no error at all. The app advanced to the code box and then nothing happened, and there was no way to tell apart "the SMS is late", "the gateway is down" and "this number was never registered" — the only one of the three the person can actually do something about. Step 1 now says which it is: - unregistered number -> 404 «این شماره در سیستم ثبت نشده است…» - SMS send failed -> 503 «ارسال پیام کوتاه … ممکن نیست…» - sales expert, account unusable -> 403, its own message and both apps surface the server's Persian message for a 404 exactly as they already did for a 429. Without that second half the person would still have seen the generic «خطا در ارسال کد», which is the confusion being removed. This restores the reference's behaviour (firstOrFail() in ApiMobileController::userAuth:43 returns 404); the opaque answer was our deviation, not this. The anti-enumeration protection it was there for is deliberately reduced at step 1 on the owner's instruction — the cost was being paid by real users and by support. Still standing: the per-mobile and per-IP send throttles, which the reference never had, so enumeration is rate-bounded rather than free, and verifyOtp keeps one message for every failure reason. ISSUES.md SEC-03's "no enumeration" claim is corrected rather than left to go stale. Two more instances of the same shape turned up while fixing it: A failed SMS send also returned success, because SmsService never throws (D20), so a dead gateway produced the identical dead end. D20 exists so a failed SMS cannot break an operation — an order must still be placed — but in a login the SMS IS the operation, so it now fails loudly. The local-dev path keys off SmsService's own `sms-not-configured` result rather than re-testing the config: with D26's default relay driver, smsConfigured() is true on any machine with an external IPv4, so a config test would have called a dev box configured and then failed its login. The new test caught precisely that on its first run. Three states return no trading account for a sales expert who does exist. Under the new 404 each would have claimed the number was not registered — false, and it would send support hunting a registration problem that is not there. Also corrects both apps' LoginView header comment, which still described this flow as stubbed: no SMS provider, any 4-digit code accepted, step 1 matching the customer list in the client (that was the login leak, D8). All three had been untrue for weeks and the stale text misled a reader during this session. requestOtp had NO test coverage: the 28-test customer-auth suite stayed green through a change to its response for every unregistered number, so its greenness was not treated as evidence. request-otp.spec.ts adds 9 cases, including that the dev fallback does not apply in production. api: 295/295 tests, 27 suites, typecheck clean; both apps vue-tsc clean. اپ: اگر شماره شما در سیستم ثبت نشده باشد، همان را میگوید و دیگر بیدلیل به صفحه کد نمیرود. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
360 lines
10 KiB
Vue
360 lines
10 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">ورود به منتزر</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 { 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)\)/.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
|
|
await router.push(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>
|