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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
f71fd5bfe1
commit
5ad1083aee
@@ -281,4 +281,51 @@ export const api = {
|
||||
'/expert/child-orders',
|
||||
{ method: 'POST', body: JSON.stringify({ page, search: search ?? null }) },
|
||||
),
|
||||
|
||||
// ── complete_profile ─────────────────────────────────────────────────
|
||||
// Ports POST /major/complete (henza_server Major/CustomerController::complete)
|
||||
// and the PWA's document uploads, which went through the same file/create the
|
||||
// admin used but behind the customer guard (routes/api.php:95).
|
||||
/**
|
||||
* Uploads one document and resolves its stored id, which is what
|
||||
* completeProfile sends. multipart/form-data, so it bypasses `request()` —
|
||||
* that helper forces a JSON content-type, and setting it here would break
|
||||
* the multipart boundary.
|
||||
*/
|
||||
uploadDocument: async (file: File, purpose: string): Promise<{ id: string; url: string }> => {
|
||||
const body = new FormData();
|
||||
body.append('file', file);
|
||||
body.append('purpose', purpose);
|
||||
|
||||
const token = readToken();
|
||||
const res = await fetch(`${BASE}/customer/me/documents`, {
|
||||
method: 'POST',
|
||||
// No Content-Type: the browser sets it with the boundary.
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
body,
|
||||
});
|
||||
|
||||
if (res.status === 401) {
|
||||
onUnauthorized?.();
|
||||
throw new UnauthorizedError('/customer/me/documents');
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new Error(`آپلود فایل ناموفق بود (${res.status})`);
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
|
||||
/**
|
||||
* The three ids keep the reference's snake_case keys so the payload matches
|
||||
* what henza_pwa sent (completeProfile.vue:136-140).
|
||||
*/
|
||||
completeProfile: (payload: {
|
||||
business_license_image_id: string;
|
||||
shop_image_id: string;
|
||||
ncode_image_id: string;
|
||||
}) =>
|
||||
request<{ ok: boolean }>('/customer/me/complete-profile', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
<!--
|
||||
One document slot for "تکمیل پروفایل".
|
||||
|
||||
Ports henza_pwa's SingleImageUploader (/@/components/SingleImageUploader),
|
||||
which the reference used three times on completeProfile.vue:45-56. Its
|
||||
contract is the reference's: pick one file, upload it immediately, and emit
|
||||
the stored id through `done` — the form only holds ids, never the files.
|
||||
|
||||
This is the user-app styling; user-app-v2 has its own. The SCRIPT is
|
||||
identical in both.
|
||||
-->
|
||||
<template>
|
||||
<div class="siu" :class="{ 'is-done': Boolean(fileId), 'is-error': Boolean(error) }">
|
||||
<input
|
||||
ref="input"
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,application/pdf"
|
||||
class="siu__input"
|
||||
@change="onPick"
|
||||
/>
|
||||
|
||||
<button type="button" class="siu__box" :disabled="busy" @click="input?.click()">
|
||||
<img v-if="preview" :src="preview" class="siu__preview" alt="" />
|
||||
|
||||
<span v-else class="siu__icon">
|
||||
<Icon :icon="busy ? 'vuesax-linear:refresh' : 'vuesax-linear:gallery-add'" size="26" />
|
||||
</span>
|
||||
|
||||
<span class="siu__text">
|
||||
{{ busy ? 'در حال بارگذاری…' : fileId ? 'تغییر تصویر' : uploadText }}
|
||||
</span>
|
||||
|
||||
<span v-if="fileId && !busy" class="siu__badge">
|
||||
<Icon icon="vuesax-linear:tick-circle" size="16" />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<p v-if="error" class="siu__error">{{ error }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import Icon from '../icon/Icon.vue'
|
||||
import { api } from '../../api/client'
|
||||
|
||||
const props = defineProps<{
|
||||
uploadText: string
|
||||
/** Sent through as the stored file's `purpose`, so documents stay labelled. */
|
||||
purpose: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ (e: 'done', id: string): void }>()
|
||||
|
||||
const input = ref<HTMLInputElement | null>(null)
|
||||
const busy = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const fileId = ref<string | null>(null)
|
||||
const preview = ref<string | null>(null)
|
||||
|
||||
/** 8 MB — the same ceiling the API enforces, checked here for a fast message. */
|
||||
const MAX_BYTES = 8 * 1024 * 1024
|
||||
|
||||
async function onPick(event: Event) {
|
||||
const target = event.target as HTMLInputElement
|
||||
const file = target.files?.[0]
|
||||
// Reset immediately so re-picking the same file fires `change` again.
|
||||
target.value = ''
|
||||
if (!file) return
|
||||
|
||||
error.value = null
|
||||
|
||||
if (file.size > MAX_BYTES) {
|
||||
error.value = 'حجم فایل بیش از حد مجاز است (۸ مگابایت)'
|
||||
return
|
||||
}
|
||||
|
||||
busy.value = true
|
||||
try {
|
||||
const stored = await api.uploadDocument(file, props.purpose)
|
||||
fileId.value = stored.id
|
||||
preview.value = file.type.startsWith('image/') ? URL.createObjectURL(file) : null
|
||||
emit('done', stored.id)
|
||||
} catch {
|
||||
error.value = 'بارگذاری انجام نشد. دوباره تلاش کنید.'
|
||||
fileId.value = null
|
||||
preview.value = null
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.siu__input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.siu__box {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-height: 132px;
|
||||
padding: 20px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
border: 1px dashed @hairline;
|
||||
border-radius: 18px;
|
||||
background: @canvasSoft;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.siu__box:disabled {
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
.is-done .siu__box {
|
||||
border-style: solid;
|
||||
border-color: #16a34a;
|
||||
}
|
||||
|
||||
.is-error .siu__box {
|
||||
border-color: #dc2626;
|
||||
}
|
||||
|
||||
.siu__preview {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
opacity: 0.32;
|
||||
}
|
||||
|
||||
.siu__icon,
|
||||
.siu__text {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.siu__text {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: @inkMuted;
|
||||
}
|
||||
|
||||
.siu__badge {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.siu__error {
|
||||
margin: 7px 2px 0;
|
||||
font-size: 12px;
|
||||
color: #dc2626;
|
||||
}
|
||||
</style>
|
||||
@@ -2,6 +2,7 @@ import { createRouter, createWebHistory } from 'vue-router';
|
||||
import { useSessionStore } from '../stores/session';
|
||||
import { useNavStore } from '../stores/navStore';
|
||||
import { useTransitionStore } from '../stores/transitionStore';
|
||||
import { api } from '../api/client';
|
||||
|
||||
// Legacy lazy-loaded every route component (`component: () => import(...)`) so
|
||||
// the initial bundle only carries the entry route. Matched here.
|
||||
@@ -52,6 +53,15 @@ export const router = createRouter({
|
||||
component: () => import('../views/expert/CustomerChoiceView.vue'),
|
||||
meta: { title: 'انتخاب مشتری', layout: 'FullPage', requiresAuth: true, requiresExpert: true },
|
||||
},
|
||||
// Reference path and name kept (henza_pwa router/index.ts:208-215). The
|
||||
// guard below mirrors the reference's: once the documents are in, the page
|
||||
// redirects home (henza_pwa router/index.ts:266).
|
||||
{
|
||||
path: '/complete_profile',
|
||||
name: 'completeProfile',
|
||||
component: () => import('../views/user/CompleteProfileView.vue'),
|
||||
meta: { title: 'تکمیل پروفایل', layout: 'FullPage', requiresAuth: true, requiresIncompleteProfile: true },
|
||||
},
|
||||
{ path: '/notifications', name: 'notifications', component: () => import('../views/user/NotificationsView.vue') },
|
||||
{ path: '/search', name: 'search', component: () => import('../views/search/SearchView.vue') },
|
||||
{ path: '/collection/:id?', name: 'collection', component: () => import('../views/search/CollectionView.vue') },
|
||||
@@ -65,7 +75,7 @@ export const router = createRouter({
|
||||
// user away from /login, and blocks unauthenticated access to routes that
|
||||
// require a session (legacy blocked ordersList/transactionsList/profileData/
|
||||
// favoritesList/completeProfile the same way).
|
||||
router.beforeEach((to) => {
|
||||
router.beforeEach(async (to) => {
|
||||
const session = useSessionStore();
|
||||
const nav = useNavStore();
|
||||
|
||||
@@ -83,6 +93,23 @@ router.beforeEach((to) => {
|
||||
return { name: 'userPage::hasSubRoute' };
|
||||
}
|
||||
|
||||
// Ported from henza_pwa router/index.ts:266 — once the documents are in,
|
||||
// /complete_profile is not reachable any more and redirects home. The
|
||||
// reference read its cached store; this asks the server, so a profile
|
||||
// completed on another device is respected too. A failed lookup lets the
|
||||
// navigation through: the page itself is harmless, and the server is the
|
||||
// one that decides whether the write is allowed.
|
||||
if (to.meta.requiresIncompleteProfile && session.isLoggedIn) {
|
||||
try {
|
||||
const me = await api.getMe();
|
||||
if (me.verificationStep === 'VERIFIED' || me.verificationStep === 'STEP_TWO_SUBMITTED') {
|
||||
return { name: 'home' };
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
|
||||
// legacy router/index.ts:268-270 — the product page slides down into view.
|
||||
if (to.name === 'singleProduct') nav.setPgaTransition('slideDown');
|
||||
else nav.setPgaTransition(undefined);
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
<!--
|
||||
"تکمیل پروفایل" — 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>
|
||||
@@ -60,12 +60,27 @@
|
||||
<Icon icon="vuesax-linear:user" size="26" />
|
||||
<p>اطلاعات حساب در دسترس نیست.</p>
|
||||
</section>
|
||||
<!--
|
||||
"تکمیل پروفایل" — the reference showed this button here, on the profile,
|
||||
and only while sts_verify_step_two was neither 1 nor 2
|
||||
(henza_pwa .../components/profile.vue:96-102). The same condition holds
|
||||
here against the target VerificationStep enum.
|
||||
-->
|
||||
<button
|
||||
v-if="canCompleteProfile"
|
||||
type="button"
|
||||
class="prof__complete"
|
||||
@click="router.push({ name: 'completeProfile' })"
|
||||
>
|
||||
تکمیل پروفایل
|
||||
</button>
|
||||
</container>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import Icon from '../../components/icon/Icon.vue'
|
||||
import PageBar from '../../components/ui/PageBar.vue'
|
||||
import SectionHead from '../../components/home/SectionHead.vue'
|
||||
@@ -85,6 +100,18 @@ const loading = ref(false)
|
||||
|
||||
const isVerified = computed(() => customer.value?.verificationStep === 'VERIFIED')
|
||||
|
||||
/**
|
||||
* The reference gated the button on sts_verify_step_two being neither 1 nor 2,
|
||||
* i.e. show it only while the documents have not been submitted. STEP_TWO_
|
||||
* SUBMITTED and VERIFIED are those two states here.
|
||||
*/
|
||||
const canCompleteProfile = computed(() => {
|
||||
const step = customer.value?.verificationStep
|
||||
return Boolean(step) && step !== 'VERIFIED' && step !== 'STEP_TWO_SUBMITTED'
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
onMounted(async () => {
|
||||
if (!session.isLoggedIn) return
|
||||
loading.value = true
|
||||
@@ -213,4 +240,18 @@ onMounted(async () => {
|
||||
margin: 0;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.prof__complete {
|
||||
margin-top: 20px;
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
border: none;
|
||||
border-radius: 14px;
|
||||
background: @ink;
|
||||
color: #fff;
|
||||
font-family: inherit;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user