Reported missing by the owner: in the reference an expert could open any
of their referred customers and use the app AS them — browse, see
balance and debt, place orders — «یکجورایی انگار با اکانت اون ها لاگین
کرده». D21 had deliberately left it out as a session-hijack primitive.
The owner asked for the capability back with the implementation left to
judgement, so the scope is the reference's and the mechanism is not.
Scope: everything, as the reference had it. Its axios interceptor
swapped the bearer token, so every request went as the customer,
document upload included. No route is closed in act-as mode.
Mechanism: the reference minted and handed over the customer's own
permanent api_token. Here the expert keeps their own token and sends
X-Act-As-Customer, resolved in one place — CustomerJwtStrategy
.validateExpert. That single point is why all 17 customer routes work
unchanged: validate() already returns a principal whose `id` is a
Customer id and every customer route reads it. It also fixes four real
defects of the original:
- ownership is re-checked on EVERY request, with referredByAgentId in
the same where clause as the id, so another expert's customer and a
non-existent id both answer 403, and a customer moved to another
expert loses access at once rather than at token expiry;
- nothing durable is handed out — the reference's token sat in
localStorage with no expiry and kept working after the expert left;
- adminUserId survives, so an order placed this way is attributable;
in the reference the request was literally the customer's;
- 403 rather than 401, so the app clears the act-as state instead of
logging a perfectly valid session out.
Performance: one findFirst on the primary key, only when the header is
present, and it replaces the trading-account lookup rather than adding
to it.
Three things the reference never needed, because it swapped the whole
session. queryCache's `me:` keys, favouritesStore and wageQuoteStore all
dropped their data on a TOKEN change, which act-as does not cause — so
an expert would have seen their own profile, orders, balance, ledger and
favourites inside the customer's account. The wage-quote one is the
money case: making charge is per customer, so the figure on screen would
not have been the one the order was priced at. And uploadDocument builds
its own headers, so an expert's upload would have been filed against
their own account, silently.
Added with no reference equivalent: a permanent «در حال مشاهده بهعنوان
…» bar. There, impersonation was invisible once the token had been
swapped, and placing an order on the wrong customer's gold debt was one
tap away. It is also the exit — the reference's only way out was a
logout that ended the expert's own session too. The cart is cleared in
both directions.
CustomerJwtStrategy had no test coverage at all and is now where this
feature's whole authorisation boundary lives: act-as-customer.spec.ts
covers 11 cases, including the header being ignored on a customer token,
the no-header path asserting the extra query does not run, and a revoked
session short-circuiting before the header is read.
api: 308/308 tests, 28 suites; both apps vue-tsc clean.
اپ: کارشناس فروش میتواند وارد حساب مشتریان خود شود، وضعیتشان را ببیند و از طرفشان سفارش ثبت کند.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
A guest tapping a personal page — orders, transactions, profile,
favourites, complete-profile — was redirected in silence, so they landed
on the login box with nothing explaining what happened. Legacy showed
«ابتدا وارد شوید» (henza_pwa/src/router/index.ts:259).
The message is now shown in both apps. The destination deliberately
stays the target's /login?redirect=…, which legacy did not have: legacy
sent the guest to home and lost where they were going, while here
signing in returns them to the page they asked for. The owner chose that
trade-off, so it is logged as «عمداً تکرار نشد — D3/الف» rather than
quietly left as a divergence. The guard also keys off meta.requiresAuth
instead of legacy's hand-kept list of five route names, so the two
sales-expert pages are covered too.
Legacy's isLoaded router guard is NOT ported, and the reasoning is
written into both apps' configStore beside the line that says so: it
guarded legacy's one big bootstrap request, which this target does not
have, so it would guard nothing and only add a bounce — a shared product
link would open home instead of the product. That is porting the
breakage, not the behaviour (D4).
Route analytics is deferred at the owner's request; its design is parked
in docs/PHASE2-D3-PLAN.md so it need not be re-derived, along with the
two reference defects not to copy (log/create was unauthenticated and
accepted any title; log/update was a GET that wrote) and the open
retention and IP questions.
Seven of D3's ten items turned out to be built already — all five
screens in both apps, the sts_verify_step_two guard, v2's cart ceiling —
verified in the code rather than from the checkmarks.
Neither app has a test runner: verified with vue-tsc -b on both (clean)
plus confirming the toast can actually appear, not merely compile —
toast.info exists and <ToastHost /> is mounted in each App.vue.
اپ: وقتی بدون ورود صفحه شخصی را باز کنید، پیام «ابتدا وارد شوید» نشان داده میشود.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Rebuilds apps/admin-panel/dist (first build with the montzar rename), apps/user-app/dist and apps/user-app-v2/dist from 8faf4a7. The api release was rebuilt in 8faf4a7.
Co-authored-by: Cursor <cursoragent@cursor.com>
Rebuilds apps/api/release, apps/user-app/dist and apps/user-app-v2/dist
from b3dae57 (login code without the ~15s wait, no wage-quote loop).
The admin panel is unchanged and not rebuilt.
Co-authored-by: Cursor <cursoragent@cursor.com>
SMS: with SMS_RELAY_HOST unset inside Docker, every send first looked up the
server's public IP from ipify / ifconfig.me / icanhazip, one after another
with a 5s timeout each. They are unreachable from the server, and a failed
lookup was not remembered, so EVERY SMS, the login code included, waited
~15s. The three are now tried in parallel (3s), concurrent sends share one
lookup, and a failure is not retried for 10 minutes.
Apps: the server answers /customer/me/wage-quote only for variants that
exist. The store re-asked for every unanswered id from a computed, and each
answer replaced `quotes`, re-running every card, so one missing variant kept
the app posting wage-quote in a loop. Unanswered ids are now remembered, an
empty answer does not touch `quotes`, and a failed call waits 30s.
Tests: relay-host.spec (failed lookup not repeated, one shared lookup).
اپ: کد ورود بدون معطلی ارسال میشود.
اپ: صفحهها بعد از ورود دیگر چند بار پشت سر هم بارگذاری نمیشوند.
Co-authored-by: Cursor <cursoragent@cursor.com>
- The build number is the commit count, so every build raises it with no
file to bump. scripts/app-version.mjs bakes it into each front-end
(__APP_BUILD__) and writes dist/version.json with that app's notes.
- Notes come from commit messages: only Persian lines that start with
one of the four app tags are shown, each to its own app
(docs/RELEASE-NOTES.md).
- Both customer apps and the admin panel poll version.json (every 5
minutes and on return to the tab, bypassing caches). A newer build opens
a modal that cannot be dismissed. «بهروزرسانی» unregisters the service
worker, empties Cache Storage and reloads; localStorage (sign-in, cart,
settings) is kept.
- Replaces legacy's version dialog, which compared against a
package.json version that was never bumped and so never opened, and
useRefresh, which swapped in a new service worker silently.
- nginx (all three apps and nginx-spa.conf): index.html, sw.js and
version.json are never cached. sw.js used to fall under the 6-month rule,
which could pin a browser to an old release.
Checked: served with the real nginx config, a newer version.json opens
the modal with its notes; the same build does not.
اپ: اجرت هر کالا حالا همان است که در فاکتور حساب میشود؛ درصد خود شما و تخفیفها هم در آن آمده است.
اپ: علاقهمندیهای شما ذخیره میشوند و در هر دستگاهی که وارد شوید دیده میشوند.
اپ: از این به بعد هر نسخهی تازه با فهرست تغییراتش اعلام میشود.
پنل: بخش تازهی «اجرت»: فرمول اجرت را با یک نمونه امتحان و بعد تأیید کنید.
پنل: شهرها مثل قبل بهصورت درختاند (کشور، استان، شهر، محله)؛ شهری که درصد ندارد درصد شهر بالاتر را میگیرد.
پنل: «تاریخ تولد» به فرم مشتری عمده و تکی برگشت.
پنل: در گزارش تجمیعی سفارشها، دکمهی «دریافت فایل اکسل» اضافه شد.
پنل: رد احراز هویت مشتری حالا دلیل هم میگیرد.
پنل: با تأیید یا رد سند مالی، به مشتری پیامک میرود؛ در پیامک گروهی، %name با نام مشتری عوض میشود.
پنل: از این به بعد هر نسخهی تازهی پنل با فهرست تغییراتش اعلام میشود.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Fresh release output after merging dev: apps/api/release (server
bundle, migrations and the database client) and the three front-end
dist folders. All three front-ends type-check clean.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings the four release/delivery commits (IAM SSO for the admin panel,
both apps and the API; IAM env in docker-compose) onto fix/issues-triage.
Conflicts:
- customer-auth.service.ts imports: both kept.
- both apps' api/client.ts: both kept (AccountingBalance/WageQuote, and
iamAppUrl).
- all build output (admin-panel, user-app, user-app-v2 dist, and the api
release bundle): rebuilt from the merged source instead of picking a
side.
Fixed while verifying:
- The IAM change calls iam.browserBootstrapUrl after every login, but
the identity-service stand-ins in three test files lacked it. 15 tests
failed on release/delivery itself.
- SMS relay: SMS_RELAY_PLATFORM was read but never sent, so the gateway
never got `platform` (documented body: phone, host, text, platform).
relayConfig() now returns it ("default" → null) and the request carries
it. sms.service.spec now passes.
All 249 API tests pass; the three front-ends type-check and build.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Owner approved the deletions. Found by walking imports from each app's
main.ts; sw.ts stays (the PWA plugin's entry).
- user-app: 8 unreachable components, loadingStore.
- user-app-v2: 11 unreachable components, loadingStore.
- both: the unused validators and price helpers, and the gsap and
@ant-design/icons-vue packages.
- the stray root user-app-v2/ folder (an old package.json and
vite.config.ts that nothing referenced).
Both apps type-check clean.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Legacy: final % = variant % + customer % − the largest active promotion
on the product, its category or its brand; rial charge 0. The apps sent
the variant's own % only, so the new app undercharged by the customer's
percent (usually 3) and ignored promotions, and the server trusted
whatever the app sent.
- pricing/wage-formula.ts: one formula, legacy by default, with
coefficients, promotion pick, constant, floor/cap and rounding so the
admin screen can change it; validated
- an app order is priced by it; the app's wage, discount and rial charge
are ignored; an operator's manual numbers are kept
- POST customer/me/wage-quote; both apps show that figure
- admin/pricing/wage-formula (config.wage): read, preview, approve with
the sample it was checked on, last 20 versions kept; the setting is
kept off the generic const routes
Owner approval: 2026-10-08.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both apps kept favourites in memory only, and the API had no route, so
the 874 favourites imported from legacy major_favorites never showed.
- GET customer/me/favorites, POST customer/me/favorites/toggle
- apps load them on sign-in, clear on sign-out, toggle optimistically
and roll back on failure
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
verifyStepOne() sent REJECTED to `default`, which returns 2
("accepted"). The admin list unlocks the order/account switches when
step one is 2, so a rejected customer would have had them unlocked.
REJECTED now maps to 3, matching legacy, where a step-one reject also
set active = -1.
Both switches are now exhaustive with no `default`, so a future enum
value fails the build instead of falling through silently.
Both user apps' ProfileView label it «رد شده». Before this they showed
the raw English value.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Production build of both customer apps for f441ca8 (appearance settings,
dark mode, reduced motion, product morph, filter sheet). The API,
admin-panel and shared-types builds are unchanged: none of their sources
changed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Applies to both user-app and user-app-v2. Logic and structure are shared;
each app keeps its own look.
Appearance settings
- New "تنظیمات ظاهری" page (/settings/appearance), opened from the
dashboard and available without login. It offers a light/dark/system
theme and a "fast & light" motion switch.
- appearanceStore saves the choice to localStorage. A small inline boot
script in index.html applies it before first paint so there is no
white flash. The "system" option follows OS changes live.
- New ToggleSwitch component.
Dark mode
- Theme LESS tokens now compile to CSS custom properties. Light/dark
values come from a generated virtual:palette.css (themeConfig/
appearance.ts, with colors.json + new colors.dark.json).
- New LESS alpha() plugin replaces fade() on theme tokens.
- New semantic tokens: page, onInk, heroSurface, hairlineStrong,
photoMat, photoStage.
- The dark palette is layered (page < card < panel < border) so
surfaces separate without shadows.
- Hard-coded colors were replaced by tokens according to their role.
- antd inputs/text get dark overrides; vant uses its own
.van-theme-dark.
- Light mode looks the same as before.
Reduced motion
- With html.motion-reduced, transitions are capped at 120ms, keyframe
loops stop, and backdrop blurs and permanent will-change are dropped.
- Spinners and the toast progress bar (which closes the toast) are
exempt via .keep-motion.
- Carousel autoplay, the v1 Lottie and the product morph honor the
setting.
Product detail
- Opening a product is now a container-transform morph from the tapped
card (ProductMorph). It replaces the bottom Drawer and uses composited
transform/opacity only.
- The detail view is seeded with the card's data, so it no longer
opens blank and jumps.
Filters and sheets
- Filters are now a full-width bottom sheet. Selections stay a draft
until applied, so the result list no longer re-renders while
dragging the slider.
- DropDown collapse animates grid-template-rows instead of a capped
max-height.
- v2 IslandSheet is restyled to the v2 design language (API unchanged).
Fixes
- filtersStore.reset() shallow-copied shared arrays, so clearing the
filters never cleared the selected brands.
Remove DialogVant/NotifyVant and add Toast and Sheet components with
useToast, useReveal, and usePageTransition composables. Rework
Navigation, RegisterView, and styles across both apps; rebuild dist assets.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Ports "گزارش" end to end (D24), the last missing app page.
In the reference this feature computes nothing: Customer\TahesabController
proxies an external accounting system (hesab.henzagold.com and a bare IP at
31.7.68.147:3030) with TLS verification disabled, a bearer token hardcoded in
the source and a URL-embedded secret, keyed on Customer.mcode. The target has
no such system and no mcode, so per the owner's option الف this is built on our
own ledger with no external connection.
API — customer/me/report{,/detail,/balances,/:id}
- one row per CONFIRMED ledger entry with the running gold and rial balance
after it, newest first as the reference displayed it
- only confirmed entries count, the same rule the cached
Customer.goldDebtGrams/fiatDebtRial follow, so the statement's closing
balance agrees with the figure shown everywhere else in the app
- fields the target has no source for (foreign currency, نام کارساخته,
جنس فلز, the external system's internal ids) are null/zero and the UI OMITS
the row rather than rendering a fabricated value (REP-01)
SEC-16, NOT ported: the reference's detail($id) took the factor code from the
URL and performed no ownership check at all — it never consults auth() — so any
signed-in customer could read any other customer's document by guessing a code.
Here the id is only a filter on top of a customerId scope from the token.
Tested in both directions.
Apps (both, template+script byte-identical, per-app styling only)
- ReportView with the reference's two tabs, its card rows and its «جزئیات»
modal; ReportDocument for one document
- routes /report and /report/detail, the latter opening the second tab
- dashboard entry (v1 tile, v2 row), shown to every signed-in customer since
the reference's hasMCode() gate has no target equivalent
Verified: build 5/5 · jest 45/45 · report 40/40 (new) ·
complete-profile 38/38 · apps smoke 43/43 · typecheck clean on all four apps.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
Both expert screens are built in user-app and user-app-v2, with byte-identical
template and script and a per-app style block only — verified by diffing
everything above <style>:
ChildOrdersView "سفارش مشتریان" — legacy ChildOrders.vue. Keeps the
reference's title, search, infinite paging, and per-card item count, total
weight, customer name and the fixed «تحویل مسئول فروش» chip (the endpoint
only returns that status, ApiMobileController:766).
CustomerChoiceView "لیست مشتریان" — legacy customerChoice.vue. Keeps its
title, per-search re-query and the reference's choice to LIST inactive
customers (its active() scope is commented out at :187).
Reachable from inside the app, not just by URL: routes carry the reference's own
paths and names, the dashboard grows the two entries only for an expert (v1 as
tiles, v2 as its own rows), and a router guard sends an ordinary customer back
to their dashboard. None of that is relied on for safety — the server scopes
both endpoints on the AdminUser id in the token and answers 403 otherwise.
Not ported, both recorded in D21: the act-as api_token minting (:197-201) and
the child-order edit drawer, which recomputed an order's money from client-sent
values. The card opens a read-only summary instead.
APP-02 fixed: user-app-v2 gained getHomeSlider and now prefers admin-configured
slides, keeping its category-derived hero as the fallback and its own design.
Session stores stay byte-identical across the apps.
build 5/5, jest 45/45, expert login 36/36, apps smoke 43/43, typecheck clean.
[ref: henza_pwa/src/views/user/src/components/ChildOrders.vue, customerChoice.vue]
[ref: henza_pwa/src/router/index.ts:171-188]
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
DEPLOY-01: docker-compose.yml passed only DATABASE_URL and PORT to the api
service, so JWT_SECRET, JWT_CUSTOMER_SECRET, OTP_MASTER_CODE and every SMS_TREZ_*
variable were silently dropped in Docker. A deployed build would have used the
development JWT fallback and would not have had the delivery master code at all,
so the demo login would have failed with no obvious cause. All are now forwarded,
defaulting to empty (which the code treats as unset); verified with
`docker compose config`.
TEST-01: test-sales-expert-roundtrip.cjs had localhost:3077 hardcoded and could
only connect by accident. Now defaults to the repo's API_PORT. 33/33.
DELIVERY.md documents env vars (names only), run/deploy commands, how migrations
run inside the container, the verification results and the known issues.
release/delivery contains both parity branches with no conflicts (fast-forward).
build 5/5, jest 45/45, admin smoke 43/43, apps smoke 43/43, B2B scan 34/34,
B2B writes 25/25, auth-smoke 32/32, typecheck clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
D20: OTP_MASTER_CODE login bypass read only from the environment, disabled when
unset, audited on every use, with a boot banner while armed. SMS no longer
aborts the boot in production — it warns and declines silently, so no message
can stop an operation.
D19: port Utility::faTOen onto admin-entered mobiles (before the uniqueness
lookup, which could previously be bypassed with Persian digits), and the
create-only welcome SMS, byte-identical to the reference and fire-and-forget.
user-app-v2 was missing the cart stock ceiling entirely: capQty/maxQty, the
un-orderable guard and the qty clamp are ported, leaving v2's own design intact.
The two cart stores are now byte-identical. isVariantOrderable and the
orderable-first default selection ported too.
jest 45/45; typecheck clean on api, admin-panel, user-app, user-app-v2.
[ref: henza_server/app/Http/Controllers/Adm/AdminController.php:333,348,364,378-387]
[ref: henza_server/app/Models/Utility.php:26]
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The user declined to defer this to Phase 2, correctly: a public POST /orders
with customerId in the body let anyone place an order on any customer's
account, and an order lands on that customer's gold debt and ledger.
POST /orders is now behind CustomerAuth. OrdersService.createOrder takes the
customer id as a separate argument that the controller fills from the token;
the body's customerId is ignored and a mismatch is logged. The field stays
optional in the DTO and in shared-types only so both apps keep validating
while they still send it -- they drop it in Phase 2. The admin builder is
unaffected: it legitimately places orders for a chosen customer and passes
that id explicitly, behind the admin guard.
GET /orders/:id is scoped to the owner. A foreign or nonexistent id both
return 404, never 403, so ids stay unenumerable. Operators read through the
new GET /orders/admin/:id behind rid order.list, declared before the :id
route because Nest matches in declaration order.
Both apps' CartView now gates on session.isLoggedIn rather than a remembered
customerId, which is no longer a credential.
OTP audit (requested): no master code, no bypass, no hardcoded code anywhere;
one code path issues a token and only after bcrypt.compare. No SMS provider is
wired up -- in development the code is logged once, guarded by
NODE_ENV !== 'production'.
Fixed while auditing (SEC-11): deliver() persisted the real message text, code
included, into SmsMessage.body, which the admin SMS grid lists and full-text
searches. Any operator with the sms rid could read live codes, defeating the
hashing of codeHash. The stored row is now a redacted audit line.
Also logged MON-08: wagePercent/discountPercent/flatRialCharge are still taken
from the client on order creation. The reference is worse -- it takes
rash_weight from the client too -- so this is already a partial improvement;
logged rather than changed, per D4.
Tests: auth-smoke.sh now 53 passed, 0 failed, including a spoofed body
customerId being ignored in favour of the token. Typecheck clean on api,
user-app, user-app-v2 and shared-types.
[ref: henza_server ApiMobileController.php:533,536-545,615-632]
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
Authorised by rulings D7 and T1-T3 in docs/parity/DECISIONS.md. This is the
CLAUDE.md section 3 carve-out: matching the reference here would keep a money
write path open to anonymous callers.
Closed, each behind the rid the legacy vocabulary already had:
GET accounting/customers/:id/balance -> wallet.list
POST accounting/customers/:id/reconcile -> wallet.charge
POST accounting/repayments/gold -> document.create
POST accounting/repayments/rial -> document.create
POST accounting/entries/:id/confirm -> document.list.confirm
PATCH orders/:id/status -> order.list.sts.submit
Four of the accounting routes WRITE to the ledger. The order-status route was
worse in kind: its DTO takes a client-supplied actorAdminUserId, so an
anonymous caller could move any order and pin it on any admin. It had no
caller - the admin panel posts to admin/orders/status.
GET /customers kept its public status (both apps' login still matches the
phone number in the browser) but now selects only id/mobile/isActive/
isArchived. It previously returned every active customer's full name, national
code, both debts, credit limit and the URLs of their identity-document scans.
CustomerSummary narrowed in BOTH user apps in the same commit, per the
v1/v2 parity rule.
Not changed: /uploads stays public per ruling T2. The filename check requested
alongside it passed unchanged - md5(time + name + uuid), no directory listing.
Tests: apps/api/scripts/auth-smoke.sh asserts 401 without a token, 403 with a
low-privilege token (ut 88, 5 unrelated rids) and pass-through for a super
admin on every closed route, plus that the public routes still answer 200 and
that GET /customers exposes only the four login fields. 23 passed, 0 failed
against a live server. Typecheck clean on api, user-app and user-app-v2.
[ref: henza_server/routes/admin.php:12,272; ApiMobileController.php:39-78]
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Extract product image gallery into a dedicated GallerySlider component
with swipe/tap support
- Add a teleported fullscreen image preview (tap to open, thumbnail strip,
body scroll lock) to SingleProduct
- Restore the Trust section that was accidentally commented out
- Swap back/favorite button positions and recenter the image counter
- Fix Icon.vue: tempIcon was a static ref set once at setup, so it never
updated on prop change when Vue reused the component instance (e.g. cart
qty steppers toggling trash/minus icons); switched to computed + watch
- Rename brand text from "منتزر" to "هنزاگلد"
- Fix a CSS typo in user-app-v2 SingleProduct (rgba alpha value)
- Rebuild dist assets
DashboardView ("میز کار"): Rebuilt from scratch with a new structure, distinct from the old card+grid layout but visually aligned with HomeView (same blurred bar, @ink/@wine palette, staged reveal animation on mount):
Single hero card (ink→wine gradient) showing balance, gold/fiat debt, and identity — no quick-action icons inside it anymore.
Live gold rate folded into a slim inline row at the bottom instead of a titled section.
Iterated shadow/spacing on the hero card per feedback (softer, less clipped).
DialogVant / DialogVantOneButton (shared modal components, used in 7+ call sites: Dashboard, Cart, SingleProduct, Register, App): Replaced Vant's default dialog chrome with the app's own rounded-card language — circular icon badge for the illustration, pill-shaped primary/secondary buttons. No call-site markup changed.
LoginView: Rebuilt from scratch — full-bleed dark hero (brand + step copy) overlapped by a floating white form sheet, animated crossfade transition between the mobile-number and OTP steps, same staged entrance animation as Home/Dashboard. All auth logic (API check, OTP stub, session login) unchanged.
# Please enter a commit message to explain why this merge is necessary,
# especially if it merges an updated upstream into a topic branch.
#
# Lines starting with '#' will be ignored, and an empty message aborts
# the commit.