Files
hedena_gold/apps/user-app/src/views/user/DashboardView.vue
T
s.esmaeiliandClaude Opus 5 4efe543d62 parity(api,apps): security hotfix stage 2 - real customer sessions, close IDOR
Stage 1 narrowed GET /customers but did not close the leak: the response still
carried every customer id, and /customers/:id, /:id/orders and
/:id/ledger-entries were still public, so the full customer book was still two
requests away. The user caught this. Stage 2 deletes the id-bearing surface.

Customer JWT, separate from the admin one (T1):
  - new src/modules/customer-auth/ with its own passport strategy,
    CustomerJwtGuard, @CustomerAuth() and @CurrentCustomer()
  - signed with JWT_CUSTOMER_SECRET, and every token carries typ:'customer'
    which the strategy verifies; both checks are present deliberately
  - customer state re-read per request, so deactivating takes effect at once
  - mirrors the reference's own split: auth:api vs auth('api-customer')

Real OTP (D6), porting ApiMobileController::userAuth/userVerfiy (:39-64):
  - new CustomerOtp table, additive migration, nothing existing touched
  - parity behaviour: rand(1000,9999), active-customer filter, faTOen digit
    normalisation on both number and code, rejection on mismatch
  - approved improvements over the reference: bcrypt-hashed code, 2-minute
    expiry, 5 attempts then lockout, single use, send throttle (60s per
    number, 5/number and 20/IP per 10 min), and no number enumeration

Routes removed, replaced by token-derived equivalents:
  GET customers, customers/:id, /:id/orders, /:id/ledger-entries
    -> GET customer/me, customer/me/orders, customer/me/ledger-entries
No route accepts a customer id any more, so the IDOR is closed structurally
rather than by a check. The admin panel was unaffected (admin/customers-omde/*).

Both apps migrated in the same commit per the v1/v2 parity rule: token-based
session store, Bearer header, 401 -> clear session and route to login, real
two-step OTP login. Each app keeps its own markup and styling; only logic moved.

Tests: auth-smoke.sh extended with the stage-2 assertions, 46 passed 0 failed
against a live server. jest installed dev-only (user-approved) so pnpm test
works for the first time: 7 passed covering faToEn, which moved to its own
digits.ts to be testable without booting Nest. Typecheck clean on api,
user-app and user-app-v2; both apps build.

[ref: henza_server ApiMobileController.php:39-78]

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 01:20:00 +03:30

487 lines
13 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!--
Ported from legacy henza_pwa/src/views/user/src/components/Dashboard.vue,
restyled into the redesigned design language (see components/home/* and
HomeView). The live rate block now reuses components/home/RateStrip.vue
rather than re-implementing the same strip a second time.
Legacy's original (820 lines) is a multi-role workbench mixing
plain-customer, sales-agent ("reagent") and "banker" (type=1, sub-account
switching) views. Kept: the real balance card, the nav grid to
Orders/Documents/Profile/Favorites, and the logout/login-nudge dialogs —
all backed by real data.
DROPPED as architecturally incompatible, not merely deferred: everything
tied to legacy's `Customer.type`/multi-account model — "انتخاب مشتری"
(toggleUser/hasActiveChild), "سفارش مشتریان" (ChildOrders) and
customerChoice. Sales agents are AdminUser records with no customer-facing
session at all, and there is no `type` field on our Customer model, so the
data model this UI assumed no longer exists.
DROPPED as no backend exists: "سفارش ساخت" (custom/pre-order manufacturing)
— PreOrder has a DB table but no controller anywhere in apps/api.
REPLACED, not dropped: legacy's "گزارش حساب" called out to the external
Tahesab accounting service, which the first migration audit retired in
favour of this system's own internal ledger — the balance card below shows
that real internal position directly.
DROPPED: the multi-row world gold/currency ticker (legacy's GET /price, ~14
rows). The new API has one single-karat quote endpoint, shown as one strip
rather than fabricating rows that don't exist.
-->
<template>
<div class="dash">
<header class="dash__bar">
<div class="dash__barInner">
<span class="dash__barSpacer" aria-hidden="true" />
<h1 class="dash__barTitle">میز کار</h1>
<button
v-if="session.isLoggedIn"
type="button"
class="dash__barBtn"
aria-label="خروج"
@click="data.notify = true"
>
<Icon icon="vuesax-linear:logout" size="19" />
</button>
<span v-else class="dash__barSpacer" aria-hidden="true" />
</div>
</header>
<PullRefresh v-model="refreshing" @refresh="onRefresh">
<container class="dash__body">
<!-- Signed in: real position from GET /customers/:id -->
<template v-if="customer">
<section class="dash__balance">
<div class="dash__balanceHead">
<div>
<div class="dash__eyebrow">{{ remainText }}</div>
<div class="dash__figure">
{{ grams }}
<span class="dash__unit">گرم</span>
</div>
</div>
<span class="dash__seal" :class="isDebtor ? 'is-debtor' : 'is-creditor'">
{{ isDebtor ? 'بدهکار' : 'بستانکار' }}
</span>
</div>
<div class="dash__balanceRow">
<span class="dash__rowLabel">بدهی ریالی</span>
<span class="dash__rowValue">{{ price(customer.fiatDebtRial) }} <i>ریال</i></span>
</div>
<div class="dash__identity">
<span class="dash__name">{{ customer.fullName }}</span>
<span class="dash__mobile">{{ session.mobile }}</span>
</div>
</section>
</template>
<!-- Signed out: a quiet invitation, no fabricated numbers -->
<section v-else class="dash__invite">
<div class="dash__inviteIcon" aria-hidden="true">
<Icon icon="vuesax-linear:security-user" size="20" />
</div>
<div class="dash__inviteCopy">
<h3>حساب گرمی شما</h3>
<p>برای دیدن موجودی، سفارش‌ها و اسناد خود وارد شوید.</p>
</div>
<button type="button" class="dash__inviteCta" @click="router.push({ name: 'login' })">
ورود / ثبت‌نام
</button>
</section>
<!-- Nav grid -->
<section class="dash__section">
<SectionHead title="دسترسی‌ها" subtitle="بخش‌های حساب شما" />
<div class="dash__grid">
<button
v-for="item in list"
:key="item.id"
type="button"
class="dash__tile"
:class="{ 'is-disabled': item.disabled }"
@click="goToDetails(item.id)"
>
<span class="dash__tileIcon" aria-hidden="true">
<Icon :icon="item.icon" size="20" />
</span>
<span class="dash__tileTitle">{{ item.title }}</span>
</button>
</div>
</section>
<!-- Live 18k rate — same strip the home screen uses -->
<section class="dash__section">
<SectionHead title="نرخ روز" subtitle="آخرین نرخ اعلام‌شده" />
<RateStrip :quote="goldPrice" />
</section>
</container>
</PullRefresh>
<DialogVant :timeOut="-1" v-model:show="data.notify" :closeOnClickOverlay="true">
<template v-slot:img>
<img :src="exitImage" alt="" class="opacity-70 mb-8" />
</template>
<template v-slot:title>آیا می خواهید از برنامه خارج شوید؟</template>
<template v-slot:button>
<div class="text-center text-[#1C2D56]" @click="logOut">بله</div>
<div class="text-center text-[#6b7280]" @click="data.notify = false">بی خیال</div>
</template>
</DialogVant>
<DialogVant :timeOut="-1" v-model:show="data.loginNotify" :closeOnClickOverlay="true">
<template v-slot:img>
<img :src="loginDialogImage" alt="" class="opacity-80" />
</template>
<template v-slot:title>
<div class="p-8 text-justify">
برای دسترسی به تمامی امکانات نرم افزار منتزر، ابتدا گزینه ورود/ثبت نام را لمس کنید و در
صورت نداشتن حساب کاربری، ثبت نام نمایید.
</div>
</template>
<template v-slot:button>
<div class="text-center text-[#1C2D56]" @click="router.push({ name: 'login' })">ورود/ثبت نام</div>
<div class="text-center text-danger" @click="data.loginNotify = false">بیخیال</div>
</template>
</DialogVant>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { PullRefresh } from 'vant'
import Icon from '../../components/icon/Icon.vue'
import SectionHead from '../../components/home/SectionHead.vue'
import RateStrip from '../../components/home/RateStrip.vue'
import DialogVant from '../../components/DialogVant.vue'
import exitImage from '../../assets/img/empty/dialogEmpty5.svg'
import loginDialogImage from '../../assets/img/empty/dialogEmpty1.svg'
import { price } from '../../utils/filters'
import { useSessionStore } from '../../stores/session'
import { api, type CustomerDetail, type GoldPriceQuote } from '../../api/client'
const router = useRouter()
const session = useSessionStore()
const customer = ref<CustomerDetail | null>(null)
const goldPrice = ref<GoldPriceQuote | null>(null)
const refreshing = ref(false)
// The ledger stores the gram position as a signed figure — positive means the
// customer owes gold. Labelled the same way AccountStrip does on the home
// screen so the two never disagree.
const isDebtor = computed(() => (customer.value ? parseFloat(customer.value.goldDebtGrams) > 0 : false))
const remainText = computed(() => (isDebtor.value ? 'بدهکار گرمی' : 'بستانکار گرمی'))
const grams = computed(() =>
customer.value ? Math.abs(parseFloat(customer.value.goldDebtGrams)) : 0,
)
const data = reactive({ notify: false, loginNotify: false })
const list = [
{ id: 0, icon: 'vuesax-linear:box', title: 'سفارشات', disabled: false },
{ id: 1, icon: 'vuesax-linear:receipt-text', title: 'اسناد', disabled: false },
{ id: 2, icon: 'vuesax-linear:user', title: 'پروفایل', disabled: false },
{ id: 3, icon: 'vuesax-linear:heart', title: 'علاقمندی', disabled: false },
]
function goToDetails(id: number) {
if (!session.isLoggedIn) {
data.loginNotify = true
return
}
if (id === 0) router.push({ name: 'ordersList' })
if (id === 1) router.push({ name: 'transactionsList' })
if (id === 2) router.push({ name: 'profileData' })
if (id === 3) router.push({ name: 'favoritesList' })
}
function logOut() {
session.logout()
data.notify = false
router.push({ name: 'home' })
}
async function loadDashboard() {
if (session.isLoggedIn) {
customer.value = await api.getMe()
}
try {
goldPrice.value = await api.getLatestGoldPrice()
} catch {
goldPrice.value = null
}
}
async function onRefresh() {
await loadDashboard()
refreshing.value = false
}
onMounted(loadDashboard)
</script>
<style scoped lang="less">
.dash {
background: @canvas;
min-height: 100vh;
}
.dash__bar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 20;
background: rgba(255, 255, 255, 0.88);
backdrop-filter: blur(14px);
border-bottom: 1px solid @hairline;
}
.dash__barInner {
max-width: 768px;
margin: 0 auto;
padding: 11px 16px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.dash__barTitle {
margin: 0;
font-size: 14.5px;
font-weight: 800;
color: @ink;
}
.dash__barBtn {
flex: 0 0 auto;
width: 38px;
height: 38px;
border: 0;
cursor: pointer;
border-radius: 12px;
display: grid;
place-items: center;
color: @wine;
background: @wineWash;
}
.dash__barBtn:active {
transform: scale(0.94);
}
.dash__barSpacer {
display: block;
width: 38px;
height: 38px;
}
.dash__body {
padding-top: 76px;
padding-bottom: 90px;
}
.dash__balance {
padding: 16px;
border-radius: 20px;
background: @ink;
color: #fff;
box-shadow: 0 14px 30px -22px rgba(20, 22, 26, 0.95);
}
.dash__balanceHead {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.dash__eyebrow {
font-size: 11.5px;
color: rgba(255, 255, 255, 0.62);
}
.dash__figure {
padding-top: 5px;
font-size: 25px;
font-weight: 800;
letter-spacing: -0.3px;
}
.dash__unit {
font-size: 11.5px;
font-weight: 600;
color: rgba(255, 255, 255, 0.62);
padding-inline-start: 4px;
}
.dash__seal {
flex: 0 0 auto;
padding: 5px 12px;
border-radius: 999px;
font-size: 10.5px;
font-weight: 700;
}
.dash__seal.is-debtor {
color: #fff;
background: @wineSoft;
}
.dash__seal.is-creditor {
color: @ink;
background: rgba(255, 255, 255, 0.9);
}
.dash__balanceRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 16px;
padding-top: 13px;
border-top: 1px solid rgba(255, 255, 255, 0.13);
}
.dash__rowLabel {
font-size: 11.5px;
color: rgba(255, 255, 255, 0.62);
}
.dash__rowValue {
font-size: 13.5px;
font-weight: 700;
}
.dash__rowValue i {
font-style: normal;
font-size: 10.5px;
font-weight: 600;
color: rgba(255, 255, 255, 0.62);
padding-inline-start: 3px;
}
.dash__identity {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 13px;
padding-top: 13px;
border-top: 1px solid rgba(255, 255, 255, 0.13);
}
.dash__name {
font-size: 12.5px;
font-weight: 700;
}
.dash__mobile {
font-size: 11.5px;
color: rgba(255, 255, 255, 0.62);
direction: ltr;
}
.dash__invite {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
border: 1px solid @hairline;
border-radius: 20px;
background: @canvasSoft;
}
.dash__inviteIcon {
flex: 0 0 42px;
width: 42px;
height: 42px;
border-radius: 50%;
display: grid;
place-items: center;
color: @wine;
background: @wineWash;
}
.dash__inviteCopy {
flex: 1;
min-width: 0;
}
.dash__inviteCopy h3 {
margin: 0;
font-size: 13px;
font-weight: 800;
color: @ink;
}
.dash__inviteCopy p {
margin: 4px 0 0;
font-size: 11.5px;
color: @inkFaint;
}
.dash__inviteCta {
flex: 0 0 auto;
padding: 9px 14px;
border: 0;
cursor: pointer;
border-radius: 999px;
font-family: inherit;
font-size: 11.5px;
font-weight: 700;
color: #fff;
background: @ink;
}
.dash__section {
margin-top: 26px;
}
.dash__grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 9px;
}
.dash__tile {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 14px 6px;
border: 1px solid @hairline;
border-radius: 16px;
cursor: pointer;
background: @canvas;
font-family: inherit;
transition: transform 0.18s ease;
}
.dash__tile:active {
transform: scale(0.97);
}
.dash__tile.is-disabled {
opacity: 0.4;
}
.dash__tileIcon {
width: 38px;
height: 38px;
border-radius: 12px;
display: grid;
place-items: center;
color: @inkSoft;
background: @hairlineSoft;
}
.dash__tileTitle {
font-size: 11px;
font-weight: 700;
color: @inkSoft;
}
</style>