Files
tehran_tala/apps/user-app/src/views/user/LoginView.vue
T
s.esmaeiliandClaude Haiku 5.5 532ceee189 feat: move brand name to one config and redesign the accounting card
اپ: نام برند در همه‌ی صفحه‌ها و پیام‌ها یکسان شده و از یک تنظیم مرکزی خوانده می‌شود.
اپ: کارت حسابداری در صفحه‌ی مشتری بازطراحی شد؛ مانده‌ها و وضعیت لینک واضح‌تر نمایش داده می‌شوند.
پنل: کارت حسابداری در پرونده‌ی مشتری بازطراحی شد؛ مانده‌ها، آخرین ارسال و فرم لینک دستی واضح‌تر شده‌اند.
پنل: ستون «کد» در جدول‌ها به‌طور پیش‌فرض پنهان است و از انتخاب ستون‌ها قابل نمایش است.
پنل: تغییر نمایش ستون‌ها در جدول‌ها بلافاصله اعمال می‌شود.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-14 13:31:20 +03:30

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>