fix(auth): tell the user when the number is not registered (D28)

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>
This commit is contained in:
s.esmaeili
2026-10-12 10:59:48 +03:30
co-authored by Claude Opus 5
parent 89e2d7937e
commit c518f9995b
8 changed files with 403 additions and 49 deletions
+21 -15
View File
@@ -3,18 +3,18 @@
restyled into the redesigned design language (see components/home/* and
HomeView).
Per the earlier decision: OTP-based flow ported structurally, but the
SMS-send step is stubbed — no SMS provider exists. What's real vs stubbed,
all carried over unchanged from the previous version:
- Step 1 (mobile number) is checked against the REAL, already-existing
GET /api/customers endpoint (client-side match on `mobile`) — no fake
endpoint invented. A non-match shows legacy's own
"اطلاعات کاربری در سیستم وجود ندارد" text, not a new message.
- Step 2 (OTP code) is NOT verified — any 4-digit code is accepted, since
there is no real code to check against. On "verify", the session store
logs in as the matched real customer, so downstream features (balance,
ledger, favourites-as-logged-in) work against real seeded data.
- `token.fireBaseToken` doesn't exist in this port (Firebase deferred).
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>
@@ -140,9 +140,15 @@ const sendPhone = async () => {
body.defaultFocus = true
})
} catch (e: unknown) {
// 429 = the send throttle (too soon, or too many in the window). Its message
// is already Persian and user-facing, so show it rather than a generic one.
const text = e instanceof Error && /\(429\)/.test(e.message) ? extractServerMessage(e) : null
// 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)\)/.test(e.message) ? extractServerMessage(e) : null
toast.error(text ?? 'خطا در ارسال کد. لطفاً دوباره تلاش کنید.')
} finally {
body.loading = false