Files
tehran_taj/apps/user-app/src/views/user/CompleteProfileView.vue
T
s.esmaeiliandClaude Opus 5 5ad1083aee parity(apps+api): complete_profile in both apps [ref: henza_server Major/CustomerController::complete]
Ports "تکمیل پروفایل" end to end (D22).

API
- POST customer/me/complete-profile — three required document ids, the
  customer taken from the token, verification step set to the reference's
  LIVE value (approved outright; its `= 1` line is commented out at :83 in
  favour of `= 2` at :85). Both SMS messages byte-identical and non-blocking
  per D20.
- POST customer/me/documents — the reference let the app upload through the
  same file/create the admin used, inside its auth:api-customer group
  (routes/api.php:95); the target's copy is admin-guarded, so the page could
  not have uploaded at all. Reuses AdminUploadsService unchanged, so both
  paths share one size limit, MIME whitelist and naming scheme (UPL-01).
- PROFILE_ADMIN_MOBILES replaces the reference's four hardcoded personal
  numbers; blank means nobody is notified and the upload still succeeds.

Three reference behaviours deliberately NOT ported, all in ISSUES.md:
- SEC-14: `$customer->update($request->all())` mass-assigned the whole request
  body, letting the app write its own credit limit, making charge, gold debt,
  active flag or mobile. Replaced with a three-field whitelist.
- SEC-15: four real personal mobiles hardcoded in the source.
- APP-03: refresh()'s unterminated setInterval page reload.

Apps (both, template+script byte-identical, per-app styling only)
- CompleteProfileView + SingleImageUploader
- route /complete_profile, redirecting home once the documents are in, as the
  reference's guard did (router/index.ts:266)
- the entry point on the profile screen, shown only while they are missing
  (profile.vue:96-102)

Also (D23): JWT_CUSTOMER_SECRET now warns loudly at boot when unset. It falls
back to JWT_SECRET, which signs app and admin tokens with the same key and
leaves the `typ` check as the only barrier — and an expert's app token carries
their AdminUser id in `sub`. Warn rather than refuse to boot (D19, lowest
risk); DELIVERY.md lists it as the first pre-release step (REL-03).

Verified: build 5/5 · jest 45/45 · complete-profile 38/38 (new) ·
expert login 36/36 · apps smoke 43/43 · admin smoke 43/43 ·
B2B scan 34 ok/0 broken · typecheck clean on all four apps.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 01:42:07 +03:30

190 lines
5.5 KiB
Vue

<!--
"تکمیل پروفایل" — the customer uploads their three documents.
Ported from henza_pwa/src/views/user/src/completeProfile.vue (route
/complete_profile, name completeProfile), restyled into this app's design
language. The LOGIC is the reference's and is identical in user-app-v2; only
the styling differs.
Kept from the reference:
- the header title "تکمیل پروفایل" (completeProfile.vue:12) and the back
arrow returning to the dashboard (:11)
- exactly three uploads, in the reference's order and with its texts:
جواز کسب, سردر مغازه, کارت ملی (:45-56)
- the submit button "بارگذاری اطلاعات" (:81)
- the client-side all-three check BEFORE posting, with the reference's own
message "لطفا تمامی عکس های خواسته شده را آپلود کنید" (:191-198)
- POST with the three snake_case ids (:193)
- the success dialog with its text and its "متوجه شدم" button (:94-111),
after which the reference navigated home (:175-181)
Deliberately NOT ported: the reference's `window.setInterval(... reload)` in
refresh() (:178-180), which reloaded the whole page every second FOREVER —
an unterminated timer, not a refresh. The store is re-read once instead.
The page is reachable only while the profile is incomplete; see the router
guard, which mirrors the reference's (henza_pwa router/index.ts:266).
-->
<template>
<div class="cpf">
<PageBar title="تکمیل پروفایل" back-to="userPage::hasSubRoute" />
<Spinner :spinning="submitting" tip="در حال ثبت اطلاعات">
<container class="cpf__body">
<p class="cpf__lead">
برای تکمیل پروفایل، سه مدرک زیر را بارگذاری کنید.
</p>
<form class="cpf__form" @submit.prevent="submit">
<SingleImageUploader
upload-text="بارگذاری عکس جواز کسب"
purpose="business-license"
@done="(id: string) => (form.business_license_image_id = id)"
/>
<SingleImageUploader
upload-text="بارگذاری عکس سردر مغازه"
purpose="shop-image"
@done="(id: string) => (form.shop_image_id = id)"
/>
<SingleImageUploader
upload-text="بارگذاری عکس کارت ملی"
purpose="national-card"
@done="(id: string) => (form.ncode_image_id = id)"
/>
<p v-if="error" class="cpf__error">{{ error }}</p>
<button type="submit" class="cpf__submit" :disabled="submitting">
بارگذاری اطلاعات
</button>
</form>
</container>
</Spinner>
<DialogVantOneButton
v-model:show="done"
:time-out="-1"
:close-on-click-overlay="false"
>
<template #title>
<div class="cpf__dialogText">
تکمیل اطلاعات شما با موفقیت انجام شد.
</div>
</template>
<template #button>
<div class="cpf__dialogBtn" @click="finish">متوجه شدم</div>
</template>
</DialogVantOneButton>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { showNotify } from 'vant'
import PageBar from '../../components/ui/PageBar.vue'
import Spinner from '../../components/Spinner.vue'
import DialogVantOneButton from '../../components/DialogVantOneButton.vue'
import SingleImageUploader from '../../components/upload/SingleImageUploader.vue'
import { api } from '../../api/client'
const router = useRouter()
const form = reactive({
business_license_image_id: null as string | null,
shop_image_id: null as string | null,
ncode_image_id: null as string | null,
})
const submitting = ref(false)
const done = ref(false)
const error = ref<string | null>(null)
async function submit() {
error.value = null
// The reference checked all three client-side before posting
// (completeProfile.vue:191-198); the server requires them too.
if (!form.business_license_image_id || !form.shop_image_id || !form.ncode_image_id) {
showNotify('لطفا تمامی عکس های خواسته شده را آپلود کنید')
return
}
submitting.value = true
try {
await api.completeProfile({
business_license_image_id: form.business_license_image_id,
shop_image_id: form.shop_image_id,
ncode_image_id: form.ncode_image_id,
})
done.value = true
} catch {
error.value = 'ثبت اطلاعات انجام نشد. دوباره تلاش کنید.'
} finally {
submitting.value = false
}
}
/** The reference sent the user home after the dialog (:175-177). */
function finish() {
done.value = false
router.push({ name: 'home' })
}
</script>
<style scoped lang="less">
.cpf__body {
padding-top: 90px;
padding-bottom: 40px;
}
.cpf__lead {
margin: 4px 0 18px;
font-size: 13px;
color: @inkMuted;
}
.cpf__form {
display: flex;
flex-direction: column;
gap: 14px;
}
.cpf__error {
margin: 2px;
font-size: 12.5px;
color: #dc2626;
}
.cpf__submit {
margin-top: 6px;
width: 100%;
padding: 14px;
border: none;
border-radius: 14px;
background: @ink;
color: #fff;
font-family: inherit;
font-size: 15px;
font-weight: 700;
cursor: pointer;
}
.cpf__submit:disabled {
opacity: 0.6;
cursor: progress;
}
.cpf__dialogText {
padding: 20px;
line-height: 1.9;
text-align: justify;
font-size: 14px;
}
.cpf__dialogBtn {
text-align: center;
cursor: pointer;
}
</style>