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:
co-authored by
Claude Opus 5
parent
89e2d7937e
commit
c518f9995b
@@ -1,5 +1,13 @@
|
||||
import { createHash, createHmac, randomBytes } from 'node:crypto';
|
||||
import { HttpException, HttpStatus, Injectable, Logger, UnauthorizedException } from '@nestjs/common';
|
||||
import {
|
||||
ForbiddenException,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { AdminRole, CustomerKind, MessageAudience, Prisma } from '@prisma/client';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
@@ -16,7 +24,7 @@ import { envList, envOr, envOrNull, pickExactUri } from '../../common/env';
|
||||
import { masterCodeAllowed } from '../../common/production-checks';
|
||||
import { CUSTOMER_TOKEN_TYPE, EXPERT_TOKEN_TYPE, type CustomerJwtPayload } from './customer-jwt.strategy';
|
||||
import { faToEn } from './digits';
|
||||
import { SmsService, smsConfigured } from '../sms/sms.service';
|
||||
import { SmsService } from '../sms/sms.service';
|
||||
|
||||
/** Nest 10 has no TooManyRequestsException; 429 with a Persian message. */
|
||||
class TooManyRequestsException extends HttpException {
|
||||
@@ -126,8 +134,8 @@ export class CustomerAuthService {
|
||||
* Reference: validate `username`, find an ACTIVE customer by mobile, generate
|
||||
* rand(1000,9999), store it, send it by SMS, return `true`.
|
||||
*
|
||||
* Kept: the active-customer filter, the 4-digit range, the opaque `true`
|
||||
* response (it never reveals whether the number exists).
|
||||
* Kept: the active-customer filter, the 4-digit range, and the 404 for a
|
||||
* number that is not registered (reference `firstOrFail()`).
|
||||
* Changed: the code is hashed, it expires, and sends are rate limited.
|
||||
*/
|
||||
async requestOtp(rawMobile: string, ip: string | undefined) {
|
||||
@@ -154,12 +162,23 @@ export class CustomerAuthService {
|
||||
select: { id: true },
|
||||
}));
|
||||
|
||||
// Deliberately opaque: the reference used firstOrFail() and leaked a 404
|
||||
// for unknown numbers, which let anyone enumerate registered customers.
|
||||
// Recorded as a deviation in docs/parity/DECISIONS.md.
|
||||
// Owner ruling 2026-10-12: say so. This used to return `{ sent: true }` for
|
||||
// a number nobody holds, to stop an outsider enumerating customers. The
|
||||
// cost landed on real users and on support: the app moved to the code box
|
||||
// and then nothing arrived, and there was no way to tell apart "the SMS
|
||||
// gateway is down", "the code is late" and "this number was never
|
||||
// registered" — the one case the person could actually act on.
|
||||
//
|
||||
// This is also what the reference did (`firstOrFail()`,
|
||||
// ApiMobileController::userAuth:43 → 404), so the opaque response was the
|
||||
// deviation, not this.
|
||||
//
|
||||
// Enumeration is still bounded by the rate limits above — assertSendAllowed
|
||||
// caps a single IP at MAX_SENDS_PER_IP per window, which it did not do in
|
||||
// the reference.
|
||||
if (!customer) {
|
||||
this.logger.warn(`OTP requested for unknown or inactive mobile ${mobile}`);
|
||||
return { sent: true };
|
||||
throw new NotFoundException('این شماره در سیستم ثبت نشده است. لطفاً با پشتیبانی تماس بگیرید.');
|
||||
}
|
||||
|
||||
const code = String(Math.floor(1000 + Math.random() * 9000));
|
||||
@@ -231,17 +250,22 @@ export class CustomerAuthService {
|
||||
if (existing && existing.isActive && !existing.isArchived) return { id: existing.id };
|
||||
// Deliberately does NOT silently create a second account when the first
|
||||
// was deactivated: an operator disabled it, and that decision stands.
|
||||
//
|
||||
// Told apart from "no such number" on purpose: this expert DOES exist, so
|
||||
// the «ثبت نشده است» message would be false and would send support
|
||||
// looking for a registration problem that is not there.
|
||||
this.logger.warn(
|
||||
`Sales expert ${admin.id} has a trading account that is inactive or archived; refusing to create another.`,
|
||||
);
|
||||
return null;
|
||||
throw new ForbiddenException('حساب شما غیرفعال است. لطفاً با پشتیبانی تماس بگیرید.');
|
||||
}
|
||||
|
||||
if (!admin.salesExpertProfile) {
|
||||
// Every SALES_EXPERT gets a profile on create and on role change (D17),
|
||||
// so this means something bypassed both paths.
|
||||
this.logger.warn(`Sales expert ${admin.id} has no SalesExpertProfile; cannot sign in to the app.`);
|
||||
return null;
|
||||
// so this means something bypassed both paths. A server-side defect, not
|
||||
// a wrong number — so not «ثبت نشده است» either.
|
||||
this.logger.error(`Sales expert ${admin.id} has no SalesExpertProfile; cannot sign in to the app.`);
|
||||
throw new ForbiddenException('حساب شما کامل نیست. لطفاً با پشتیبانی تماس بگیرید.');
|
||||
}
|
||||
|
||||
// The mobile is unique on Customer, so a pre-existing customer row with
|
||||
@@ -313,6 +337,11 @@ export class CustomerAuthService {
|
||||
// One message for every failure mode, so a caller cannot tell "no code was
|
||||
// sent" from "wrong code" from "expired". Declared before the master-code
|
||||
// branch so that path can reject identically.
|
||||
// Deliberately one message for every reason a code can fail: wrong code, no
|
||||
// challenge, expired, unknown number. Unlike requestOtp — which now names an
|
||||
// unregistered number, owner ruling 2026-10-12 — there is nothing for a real
|
||||
// user to act on differently here, and step 1 already told them if the
|
||||
// number was not on file, so they never reach this with an unknown number.
|
||||
const reject = () => new UnauthorizedException('کد وارد شده صحیح نیست');
|
||||
|
||||
// D20 — the delivery master code. Checked BEFORE the stored challenge so it
|
||||
@@ -742,9 +771,11 @@ export class CustomerAuthService {
|
||||
* When no gateway is configured, SmsService does not send and the code is
|
||||
* logged instead — outside production only (D20).
|
||||
*
|
||||
* A failed send does NOT throw. The reference ignored Sms::Send's result, so
|
||||
* a gateway outage never blocked the flow that triggered it; the customer
|
||||
* simply retries, and the throttle in assertSendAllowed still applies.
|
||||
* A failed send DOES throw 503 (owner ruling 2026-10-12). The reference
|
||||
* ignored Sms::Send's result and so did this, which meant a gateway outage
|
||||
* returned success and the app sent the person to the code box to wait for a
|
||||
* message that had never been sent. See the note at the throw: D20 protects
|
||||
* operations from a dead gateway, but here the SMS is the operation.
|
||||
*/
|
||||
private async deliver(mobile: string, code: string) {
|
||||
// The reference's text (ApiMobileController.php:47) with the brand changed
|
||||
@@ -759,14 +790,45 @@ export class CustomerAuthService {
|
||||
// persist:false — the real text carries the live code, and the admin SMS
|
||||
// grid lists and searches `body` (SEC-11). The redacted audit row below
|
||||
// takes its place.
|
||||
await this.sms.send(mobile, smsText, { persist: false });
|
||||
const result = await this.sms.send(mobile, smsText, { persist: false });
|
||||
|
||||
// Only outside production, and only when no gateway is configured, so a
|
||||
// deployment can never print live codes into its logs.
|
||||
if (process.env.NODE_ENV !== 'production' && !smsConfigured()) {
|
||||
//
|
||||
// The condition is SmsService's own verdict (`sms-not-configured`), not a
|
||||
// second guess at the config: with D26's default `relay` driver,
|
||||
// smsConfigured() is true on any machine that has an external IPv4, so
|
||||
// testing the config here would have called a dev box "configured" and then
|
||||
// failed the login below when the relay was unreachable. This is the one
|
||||
// case where a failed send must not be an error — there was no gateway to
|
||||
// fail, and local sign-in works off this log line.
|
||||
const devNoGateway =
|
||||
process.env.NODE_ENV !== 'production' && !result.ok && result.error === 'sms-not-configured';
|
||||
if (devNoGateway) {
|
||||
this.logger.log(`[dev] OTP for ${mobile}: ${code}`);
|
||||
}
|
||||
|
||||
// Owner ruling 2026-10-12, same reasoning as the 404 in requestOtp.
|
||||
//
|
||||
// SmsService NEVER throws — a dead gateway is a log line (D20) — so a failed
|
||||
// send used to return success here and the app walked the person to the code
|
||||
// box to wait for a message that was never sent. From the outside that is
|
||||
// indistinguishable from a late SMS, which is exactly the confusion being
|
||||
// removed.
|
||||
//
|
||||
// D20 is about not letting a failed SMS break an *operation*: an order must
|
||||
// still be placed. In a login the SMS IS the operation, so staying quiet
|
||||
// protects nothing. The code row stays in the table — it costs nothing and
|
||||
// expires on its own — and the person is told to retry instead of being left
|
||||
// watching a countdown.
|
||||
if (!result.ok && !devNoGateway) {
|
||||
this.logger.error(`OTP SMS to ${mobile} failed: ${result.error ?? 'unknown'}`);
|
||||
throw new HttpException(
|
||||
'ارسال پیام کوتاه در حال حاضر ممکن نیست. لطفاً چند لحظه بعد دوباره تلاش کنید.',
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
|
||||
// An audit row, NOT the message body.
|
||||
//
|
||||
// SmsMessage.body is listed and full-text searchable on the admin panel's
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { ForbiddenException, HttpException, NotFoundException } from '@nestjs/common';
|
||||
import { AdminRole } from '@prisma/client';
|
||||
|
||||
// @nestjs/jwt v12 ships ESM only, which this CommonJS jest setup cannot load.
|
||||
// Same stub as master-otp.spec.ts — the service gets a hand-built double.
|
||||
jest.mock('@nestjs/jwt', () => ({ JwtService: class {} }));
|
||||
|
||||
import { CustomerAuthService } from './customer-auth.service';
|
||||
|
||||
/**
|
||||
* Owner ruling 2026-10-12 — step 1 of the app login must say what went wrong.
|
||||
*
|
||||
* Until now an unknown number got `{ sent: true }` (opaque on purpose, to stop
|
||||
* customer enumeration) and a failed SMS send also got `{ sent: true }` (a dead
|
||||
* gateway is only a log line, D20). Both walked the person to the code box to
|
||||
* wait for a message that was never going to arrive, and nothing on screen told
|
||||
* the three real causes apart: the SMS is late, the gateway is down, or the
|
||||
* number was never registered.
|
||||
*
|
||||
* `requestOtp` had NO test coverage at all before this file, so the whole suite
|
||||
* stayed green through that change — these cases exist so it cannot again.
|
||||
*/
|
||||
describe('CustomerAuthService.requestOtp — the caller is told what happened', () => {
|
||||
const saved = { ...process.env };
|
||||
|
||||
const CUSTOMER = { id: 'cus_1', mobile: '09121112233', isActive: true, isArchived: false };
|
||||
|
||||
function build(opts: {
|
||||
customer?: unknown;
|
||||
expert?: unknown;
|
||||
smsResult?: { ok: boolean; error?: string };
|
||||
} = {}) {
|
||||
const prisma = {
|
||||
customer: {
|
||||
findFirst: jest.fn().mockResolvedValue('customer' in opts ? opts.customer : CUSTOMER),
|
||||
findUnique: jest.fn().mockResolvedValue(null),
|
||||
},
|
||||
customerOtp: {
|
||||
findFirst: jest.fn().mockResolvedValue(null),
|
||||
create: jest.fn().mockResolvedValue({}),
|
||||
count: jest.fn().mockResolvedValue(0),
|
||||
},
|
||||
smsMessage: { create: jest.fn().mockResolvedValue({}) },
|
||||
adminUser: { findFirst: jest.fn().mockResolvedValue(opts.expert ?? null) },
|
||||
};
|
||||
const sms = { send: jest.fn().mockResolvedValue(opts.smsResult ?? { ok: true }) };
|
||||
const service = new CustomerAuthService(
|
||||
prisma as never,
|
||||
{ signAsync: jest.fn() } as never,
|
||||
sms as never,
|
||||
{ enabled: false, attestOtpLogin: jest.fn(), browserBootstrapUrl: () => null } as never,
|
||||
{ newTokenId: jest.fn().mockReturnValue(null), record: jest.fn() } as never,
|
||||
);
|
||||
jest.spyOn(service['logger'], 'warn').mockImplementation(() => undefined);
|
||||
jest.spyOn(service['logger'], 'error').mockImplementation(() => undefined);
|
||||
return { service, prisma, sms };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.NODE_ENV = 'test';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...saved };
|
||||
});
|
||||
|
||||
it('sends the code for a registered, active customer', async () => {
|
||||
const { service, sms, prisma } = build();
|
||||
await expect(service.requestOtp('09121112233', '127.0.0.1')).resolves.toEqual({ sent: true });
|
||||
expect(sms.send).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.customerOtp.create).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('refuses an unregistered number with «ثبت نشده»، not a silent success', async () => {
|
||||
const { service, sms } = build({ customer: null });
|
||||
await expect(service.requestOtp('09129999999', '127.0.0.1')).rejects.toThrow(NotFoundException);
|
||||
await expect(service.requestOtp('09129999999', '127.0.0.1')).rejects.toThrow('ثبت نشده');
|
||||
// Nothing was sent and no challenge was stored for a number nobody holds.
|
||||
expect(sms.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('normalises Persian digits before deciding the number is unknown', async () => {
|
||||
// Guards against a false «ثبت نشده» for someone typing on a Persian keypad.
|
||||
const { service, prisma } = build();
|
||||
await service.requestOtp('۰۹۱۲۱۱۱۲۲۳۳', '127.0.0.1');
|
||||
expect(prisma.customer.findFirst).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: expect.objectContaining({ mobile: '09121112233' }) }),
|
||||
);
|
||||
});
|
||||
|
||||
it('reports a failed SMS send as 503 instead of claiming it was sent', async () => {
|
||||
const { service } = build({ smsResult: { ok: false, error: 'gateway refused' } });
|
||||
await expect(service.requestOtp('09121112233', '127.0.0.1')).rejects.toThrow(HttpException);
|
||||
await expect(service.requestOtp('09121112233', '127.0.0.1')).rejects.toThrow('ارسال پیام کوتاه');
|
||||
});
|
||||
|
||||
it('still completes the login in development when there is no gateway at all', async () => {
|
||||
// The local-dev path: SmsService sends nothing, returns its own
|
||||
// `sms-not-configured`, and the code is logged instead — so this one failure
|
||||
// must NOT become an error or nobody could sign in locally.
|
||||
process.env.NODE_ENV = 'development';
|
||||
const { service } = build({ smsResult: { ok: false, error: 'sms-not-configured' } });
|
||||
await expect(service.requestOtp('09121112233', '127.0.0.1')).resolves.toEqual({ sent: true });
|
||||
});
|
||||
|
||||
it('still fails in development when a configured gateway refuses', async () => {
|
||||
// D26's default `relay` driver counts as configured on any machine with an
|
||||
// external IPv4, so a dev box that cannot reach the relay gets a real
|
||||
// failure — which must be reported, not swallowed as "no gateway".
|
||||
process.env.NODE_ENV = 'development';
|
||||
const { service } = build({ smsResult: { ok: false, error: 'relay unreachable' } });
|
||||
await expect(service.requestOtp('09121112233', '127.0.0.1')).rejects.toThrow('ارسال پیام کوتاه');
|
||||
});
|
||||
|
||||
it('refuses in production even when nothing is configured', async () => {
|
||||
// The dev fallback must never let a production deployment report success
|
||||
// while silently sending nothing.
|
||||
process.env.NODE_ENV = 'production';
|
||||
const { service } = build({ smsResult: { ok: false, error: 'sms-not-configured' } });
|
||||
await expect(service.requestOtp('09121112233', '127.0.0.1')).rejects.toThrow('ارسال پیام کوتاه');
|
||||
});
|
||||
|
||||
/**
|
||||
* A sales expert signs in through this same flow (D21). Three states return
|
||||
* no trading account for an expert who DOES exist, and «ثبت نشده است» would
|
||||
* be a lie in each — it would send support hunting a registration problem.
|
||||
*/
|
||||
describe('a sales expert whose trading account cannot be used', () => {
|
||||
it('says the account is disabled, not that the number is unknown', async () => {
|
||||
const { service } = build({
|
||||
customer: null,
|
||||
expert: {
|
||||
id: 'adm_1',
|
||||
mobile: '09121112233',
|
||||
role: AdminRole.SALES_EXPERT,
|
||||
fullName: 'کارشناس',
|
||||
firstName: 'کار',
|
||||
lastName: 'شناس',
|
||||
wagePercent: '0',
|
||||
salesExpertProfile: { id: 'sep_1', gramLevel: '0', nationalCode: null, tradingCustomerId: 'cus_dead' },
|
||||
},
|
||||
});
|
||||
// findUnique returns null → the linked customer is gone or unusable.
|
||||
await expect(service.requestOtp('09121112233', '127.0.0.1')).rejects.toThrow(ForbiddenException);
|
||||
await expect(service.requestOtp('09121112233', '127.0.0.1')).rejects.toThrow('غیرفعال');
|
||||
});
|
||||
|
||||
it('says the account is incomplete when the expert has no profile', async () => {
|
||||
const { service } = build({
|
||||
customer: null,
|
||||
expert: {
|
||||
id: 'adm_2',
|
||||
mobile: '09121112233',
|
||||
role: AdminRole.SALES_EXPERT,
|
||||
fullName: 'کارشناس',
|
||||
firstName: 'کار',
|
||||
lastName: 'شناس',
|
||||
wagePercent: '0',
|
||||
salesExpertProfile: null,
|
||||
},
|
||||
});
|
||||
await expect(service.requestOtp('09121112233', '127.0.0.1')).rejects.toThrow('کامل نیست');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,14 @@ 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.
|
||||
const text =
|
||||
e instanceof Error && /\((?:429|404)\)/.test(e.message) ? extractServerMessage(e) : null
|
||||
toast.error(text ?? 'خطا در ارسال کد. لطفاً دوباره تلاش کنید.')
|
||||
} finally {
|
||||
body.loading = false
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -929,3 +929,65 @@ Noted in the code: with the IAM enabled, silent SSO leaves the page before this
|
||||
guard runs, so the toast is a no-op there by design, not a defect.
|
||||
|
||||
_No data was deleted in this entry._
|
||||
|
||||
---
|
||||
|
||||
## 2026-10-12 — App login — say what went wrong (AUTH-01/02/03, DOC-01)
|
||||
|
||||
- **What:** Step 1 of the app login now reports an unregistered number (404) and
|
||||
a failed SMS send (503) instead of answering success in both cases. A sales
|
||||
expert whose trading account cannot be used gets its own 403 rather than the
|
||||
unknown-number message. Both apps surface the server's Persian message for a
|
||||
404 as they already did for a 429.
|
||||
- **Why:** owner report — a number that was not in the system produced no error:
|
||||
the app moved to the code box and «واقعا نمیشد فهمید خطا از پروژه هست یا نه
|
||||
پیامک نمیاد یا نه واقعا نیست». Three causes looked identical on screen, and
|
||||
only one of them was something the person could act on.
|
||||
- **Reference:** `ApiMobileController::userAuth:43` — `firstOrFail()`, i.e. 404
|
||||
for a number with no active customer. **The opaque answer was the deviation;
|
||||
this restores the reference.**
|
||||
- **Target:** `apps/api/src/modules/customer-auth/customer-auth.service.ts`
|
||||
(`requestOtp`, `deliver`, `resolveExpertTradingAccount`), both apps'
|
||||
`src/views/user/LoginView.vue`.
|
||||
- **Matrix row:** AUTH-01, AUTH-02, AUTH-03, DOC-01 in `ISSUES.md`.
|
||||
- **Commit:** see below.
|
||||
|
||||
**The security trade-off, stated plainly.** The opaque response existed to stop
|
||||
an outsider enumerating which mobile numbers are registered. That protection is
|
||||
now reduced at step 1, deliberately, on the owner's instruction: the cost was
|
||||
being paid by real users and by support on every login problem. What still
|
||||
stands — the per-mobile and per-IP send throttles (`assertSendAllowed`), which
|
||||
the reference did not have, so enumeration is rate-bounded rather than free; and
|
||||
`verifyOtp`, which keeps one message for every failure reason. `ISSUES.md`
|
||||
SEC-03's "no enumeration" claim was corrected rather than left standing.
|
||||
|
||||
**Two further problems found while fixing the reported one.** Both were the same
|
||||
shape — an operation reporting success while doing nothing:
|
||||
|
||||
1. A failed SMS send also returned `{ sent: true }`, because `SmsService` never
|
||||
throws (D20). A dead gateway therefore produced the exact same dead end. D20
|
||||
is about not letting a failed SMS break an *operation*; in a login the SMS is
|
||||
the operation, so it now fails loudly. The local-dev path is preserved by
|
||||
keying 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 test caught
|
||||
exactly this**, on the first run.
|
||||
2. Three states return no trading account for a sales expert who *does* exist.
|
||||
Under the new 404 they would each have claimed the number was not registered
|
||||
— a false message that sends support looking for the wrong thing.
|
||||
|
||||
**A stale comment that actively misled.** Both apps' `LoginView.vue` header still
|
||||
described the login as stubbed: no SMS provider, any 4-digit code accepted, and
|
||||
step 1 matching against the customer list in the client (which was the login
|
||||
leak, D8). All three had been untrue for weeks, and it misled a reader during
|
||||
this very session. Corrected in place, with that noted.
|
||||
|
||||
**Verification.** `requestOtp` had **no tests at all** — the 28-test customer-auth
|
||||
suite stayed green through a change to its response for every unregistered
|
||||
number, which is why the suite's greenness was not treated as evidence.
|
||||
`request-otp.spec.ts` adds 9 cases, including that the dev fallback does *not*
|
||||
apply in production. Full api suite **295/295, 27 suites**; both apps `vue-tsc`
|
||||
clean.
|
||||
|
||||
_No data was deleted in this entry._
|
||||
|
||||
@@ -1418,3 +1418,36 @@ and product lists and a live balance on the app profile, all working whenever
|
||||
a provider is connected regardless of the feature switches; and the order
|
||||
status that posts the temporary document (پیشفاکتور) is an admin-chosen rule
|
||||
(`documents.triggerStatus`, default «تحویل مسئول فروش»).
|
||||
|
||||
---
|
||||
|
||||
## D28 — ورود اپ باید خطای واقعی را بگوید (۲۰۲۶/۱۰/۱۲)
|
||||
|
||||
**گزارش مالک:** «اگر کاربری در اپ نبود خطا نمیدادی کاربر یافت نشد میبردیش توی
|
||||
صفحه ارسال کد و واقعا نمیشد فهمید خطا از پروژه هست یا نه پیامک نمیاد یا نه واقعا
|
||||
نیست میخوام خطا بدی کاربر یافت نشد»
|
||||
|
||||
**تصمیم:** مرحله اول ورود باید علت را بگوید.
|
||||
|
||||
| حالت | قبل | بعد |
|
||||
|---|---|---|
|
||||
| شماره ثبت نشده | `{ sent: true }` — کاربر به صفحه کد میرفت | **۴۰۴** «این شماره در سیستم ثبت نشده است…» |
|
||||
| پیامک فرستاده نشد (درگاه خراب) | `{ sent: true }` | **۵۰۳** «ارسال پیام کوتاه در حال حاضر ممکن نیست…» |
|
||||
| کارشناس فروش با حساب معاملاتی غیرفعال | `{ sent: true }` | **۴۰۳** «حساب شما غیرفعال است…» |
|
||||
| کارشناس فروش بدون پروفایل | `{ sent: true }` | **۴۰۳** «حساب شما کامل نیست…» |
|
||||
|
||||
**نسبت با مرجع:** این همان کاری است که مرجع میکرد (`firstOrFail()` در
|
||||
`ApiMobileController::userAuth:43` ⇒ ۴۰۴). پاسخ مبهم، انحراف ما بود نه این.
|
||||
|
||||
**معاوضه امنیتی، صریح:** پاسخ مبهم برای این بود که کسی از بیرون نتواند بفهمد چه
|
||||
شمارههایی در سیستم ثبتاند. این محافظت در مرحله اول کاهش یافت، **عمداً و به
|
||||
دستور مالک**، چون هزینهاش را کاربران واقعی و پشتیبانی میدادند. چیزی که سر جایش
|
||||
ماند: سقف تعداد ارسال برای هر شماره و هر IP (`assertSendAllowed`) که مرجع
|
||||
نداشت — پس شمارشکردن شمارهها محدود است نه آزاد؛ و مرحله دوم (`verifyOtp`) که
|
||||
برای همه علتهای شکست یک پیام واحد میدهد.
|
||||
|
||||
**چرا D20 اینجا نقض نمیشود:** D20 میگوید پیامک ناموفق نباید یک *عملیات* را
|
||||
خراب کند — سفارش باید ثبت شود حتی اگر پیامکش نرود. در ورود، پیامک **خودِ
|
||||
عملیات** است، پس سکوت از چیزی محافظت نمیکند.
|
||||
|
||||
ثبت شده در `ISSUES.md` بهعنوان AUTH-01/02/03 و DOC-01.
|
||||
|
||||
+21
-1
@@ -17,7 +17,7 @@ Severity: `Critical` (money loss / security / data loss) · `High` · `Medium`
|
||||
|---|---|---|---|---|---|
|
||||
| SEC-01 | Critical | REF `henza_server/routes/admin.php:12,272` | The whole admin route file is **unauthenticated** — the `Route::middleware('auth:api')->group(...)` wrapper is commented out at both ends, leaving all ~215 admin routes public (incl. `wallet/charge`, `users`, `roles`). | Verified by reading both lines. | n/a in reference (read-only). Drives TGT decision below. |
|
||||
| SEC-02 | ~~Critical~~ **FIXED — hotfix stage 3** | TGT `apps/api/src/modules/accounting/accounting.controller.ts:7`, `orders/orders.controller.ts:7`, `customers/customers.controller.ts:5`, `catalog/catalog.controller.ts:5`, `pricing/pricing.controller.ts:5` | 15 routes carry `@Public()`, including **ledger writes** (`POST accounting/repayments/*`, `entries/:id/confirm`) and `POST /api/orders` + `PATCH /api/orders/:id/status`. The public status DTO accepts a client-supplied `actorAdminUserId`, so a caller can attribute an action to any admin. | `grep -rn "@Public()"`; source comments say "pre-existing unauthenticated endpoint". | ✅ All closed across stages 1-3. Ledger + order-status behind admin rids; customer routes behind the customer JWT; `POST /orders` takes the customer from the token and ignores the body. Only `catalog/products`, `pricing/latest`, `home/slider` and `uploads/:filename` remain public, all by design. |
|
||||
| SEC-03 | ~~Critical~~ **FIXED — hotfix stage 2** | TGT `apps/user-app/src/views/user/LoginView.vue`, `apps/user-app-v2/` same | OTP is **not verified** — any 4-digit code logs the user in. This is a **parity gap**: the reference does verify it (`ApiMobileController.php:51-57`, mismatch → HTTP 422). | `DECISIONS.md` D6 | ✅ Real server-side OTP: bcrypt-hashed code, 2-min expiry, 5 attempts, single use, send throttle, no enumeration. Verified end-to-end by `auth-smoke.sh`. |
|
||||
| SEC-03 | ~~Critical~~ **FIXED — hotfix stage 2** | TGT `apps/user-app/src/views/user/LoginView.vue`, `apps/user-app-v2/` same | OTP is **not verified** — any 4-digit code logs the user in. This is a **parity gap**: the reference does verify it (`ApiMobileController.php:51-57`, mismatch → HTTP 422). | `DECISIONS.md` D6 | ✅ Real server-side OTP: bcrypt-hashed code, 2-min expiry, 5 attempts, single use, send throttle. Verified end-to-end by `auth-smoke.sh`. ⚠️ **"no enumeration" no longer holds at step 1** — owner ruling 2026-10-12 (see AUTH-01 below) makes `requestOtp` answer an unregistered number with 404, because the opaque answer was unusable in support. Step 2 (`verifyOtp`) is still single-message for every failure reason, and the per-IP send throttle still bounds any enumeration attempt. |
|
||||
| SEC-08 | ~~Critical~~ **FIXED — hotfix stage 2** | TGT `apps/api/src/modules/customers/customers.service.ts:6-23,44-50` + `customers.controller.ts:10` (`@Public()`) | **Full customer book exposed to anonymous callers.** Login downloads `GET /customers` and matches the phone client-side, so the endpoint returns **every** active customer with `fullName`, `mobile`, **`nationalCode`**, `goldDebtGrams`, `fiatDebtRial`, `goldCreditLimitGrams`, and the URLs of **national-ID / business-licence / shop scans** (and `GET uploads/:filename` is public too, so those URLs resolve). `passwordHash` is correctly excluded. Not possible in the reference, where `userVerfiy` returned **one** customer after the code matched. | Read `customers.service.ts:5-23`; `DECISIONS.md` D8 | ✅ Closed. The whole controller is deleted — no route accepts a customer id any more. Reads moved to token-derived `/customer/me*`. Stage 1 had only narrowed the fields, which left the ids and the `:id` routes exposed. |
|
||||
| SEC-09 | ~~Critical~~ **FIXED — hotfix stage 1** | TGT `apps/api/src/modules/accounting/accounting.controller.ts:7` | `@Public()` on the whole accounting controller exposes **5 routes, 4 of them writes**: reconcile a balance, record a gold repayment, record a rial repayment, confirm a ledger entry. Faithful to the reference (whose admin routes were all unauthenticated) but contradicts the confirmed decision D1. | `DECISIONS.md` §"@Public audit" group A | ✅ Done: rids `wallet.list`, `wallet.charge`, `document.create` ×2, `document.list.confirm`. Verified 401/403/pass-through by `apps/api/scripts/auth-smoke.sh`. |
|
||||
| SEC-10 | ~~High~~ **FIXED — hotfix stage 1** | TGT `apps/api/src/modules/orders/orders.controller.ts:22` | `PATCH orders/:id/status` is public **and** its DTO accepts a client-supplied `actorAdminUserId`, so an anonymous caller can move an order's status and attribute it to any admin. In the reference this was an admin operation. | `DECISIONS.md` §"@Public audit" B3 | ✅ Done: `order.list.sts.submit`. Had no caller — the admin panel uses `admin/orders/status`. |
|
||||
@@ -223,3 +223,23 @@ apps, as do the `sts_verify_step_two` guard and v2's cart stock ceiling
|
||||
| **APP-03** | Medium (UX) | REF `henza_pwa/src/router/index.ts:253-261` vs TGT `apps/user-app/src/router/index.ts`, `user-app-v2` same | 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 saying why. Legacy showed «ابتدا وارد شوید». | Read both guards; legacy calls `showNotify('ابتدا وارد شوید')` then `next({name:'home'})`. | ✅ **FIXED 2026-10-12** — `toast.info('ابتدا وارد شوید')` in both apps' `beforeEach`. **«عمداً تکرار نشد — D3/الف»** for the destination: legacy sent the guest to `home` and lost where they were going; the owner chose to keep the target's `query.redirect`, so signing in returns them to the page they asked for. Also applied via `meta.requiresAuth` rather than legacy's hand-kept list of five names, which additionally covers the two sales-expert pages. Only reached when the IAM is off (the default) or unreachable — silent SSO leaves the page before this runs, and a toast on a document being replaced could not be read. |
|
||||
| **APP-04** | Low | REF `henza_pwa/src/router/index.ts:263-266` vs TGT both apps `stores/configStore.ts` | Legacy's `isLoaded` router guard sent a signed-in user to `home` from any route without `meta.skipLoadedCheck` until its one big bootstrap request had landed. | Read both; the target's store header already said `isLoaded` was not ported. | ⏹️ **«عمداً تکرار نشد — D3/ب»** (owner, 2026-10-12). The target has no bootstrap request — every page fetches its own data — so the flag would guard nothing and only add a bounce: opening a shared product link would land on home instead of the product. Porting it would port the breakage, not the behaviour (D4). Recorded in both apps' `configStore.ts` so a later reader finds the reasoning where they would look for the flag. |
|
||||
| **ANLY-01** | Low | REF `henza_server/app/Http/Controllers/Api/LogController.php`, `henza_pwa/src/router/customAnalytics.ts`; routes `api.php:57-58` | Per-page dwell-time analytics: `POST /log/create/{title}` opened a row (`start_at`, `ip`, `title`, `customer_id`, optional `product_id`), `GET /log/update/{id}` closed it; four admin report endpoints aggregated it. Entirely absent from the target — no table, no routes, no calls. | Read the controller, the composable and the routes. | ⏸️ **DEFERRED — lowest priority, may never be built** (owner, 2026-10-12: «ج اصلا اولیوت نداره جزو اخرین چیز هایی هست که شاید درستش کنیم»). The full design is parked in `docs/PHASE2-D3-PLAN.md` §"شکاف ۳", including two reference defects **not** to copy if it is ever built: `log/create` required no auth and accepted any title (anyone could fill the table from outside), and `log/update` was a `GET` that wrote. Open owner questions there too: retention (suggested 90 days — legacy never deleted) and whether to store the IP at all (suggested no). |
|
||||
|
||||
## App login — owner ruling 2026-10-12 (says what went wrong)
|
||||
|
||||
Reported by the owner: «اگر کاربری در اپ نبود خطا نمیدادی کاربر یافت نشد
|
||||
میبردیش توی صفحه ارسال کد و واقعا نمیشد فهمید خطا از پروژه هست یا نه پیامک نمیاد
|
||||
یا نه واقعا نیست».
|
||||
|
||||
| ID | Severity | Where | Description | Evidence | Status |
|
||||
|---|---|---|---|---|---|
|
||||
| **AUTH-01** | **High (support/UX)** | TGT `apps/api/src/modules/customer-auth/customer-auth.service.ts` `requestOtp`, both apps' `LoginView.vue` | Step 1 returned `{ sent: true }` for a number no customer holds, so the app advanced to the code box and the person waited for an SMS that was never sent. Three very different causes were indistinguishable on screen: the SMS is late, the gateway is down, or the number was never registered — the only one the person can act on. | Read the guard (the opaque `return { sent: true }` with the enumeration note) and the apps' catch, which showed a generic «خطا در ارسال کد» for anything that was not a 429. | ✅ **FIXED.** 404 + «این شماره در سیستم ثبت نشده است…»; both apps now surface the server's Persian message for 404 as they already did for 429. **This restores the reference's behaviour** (`firstOrFail()`, `ApiMobileController::userAuth:43` → 404) — the opaque response was the deviation, not this. Enumeration stays bounded by `assertSendAllowed` (per-mobile and per-IP throttles), which the reference did not have, and step 2 is still a single message for every failure reason. |
|
||||
| **AUTH-02** | **High (support/UX)** | TGT same file, `deliver` | A failed SMS send **also** returned success. `SmsService` never throws — a dead gateway is a log line (D20) — so an outage produced the identical "waiting at the code box" dead end, with nothing visible server-side either unless someone read the logs. | `SmsService.deliver` returns `{ ok: false, … }` and `requestOtp` ignored the result. | ✅ **FIXED.** A failed send is now 503 + «ارسال پیام کوتاه در حال حاضر ممکن نیست…». D20 protects an *operation* from a dead gateway (an order must still be placed); in a login the SMS **is** the operation, so there is nothing to protect by staying quiet. The code row is left in place (harmless, expires on its own). The local-dev path is preserved: when `SmsService` reports its own `sms-not-configured` outside production the code is logged and the login completes. |
|
||||
| **AUTH-03** | Medium (wrong message) | TGT same file, `resolveExpertTradingAccount` | Found while fixing AUTH-01. Three states return no trading account for a sales expert who **does** exist (linked customer missing/deactivated/archived, or no `SalesExpertProfile`). Previously all three fell into the opaque success; with AUTH-01 they would have reported «این شماره ثبت نشده است» — a false statement that would send support hunting a registration problem that is not there. | Read the method: two `return null` paths plus the archived/inactive one. | ✅ **FIXED** in the same pass: 403 «حساب شما غیرفعال است…» and «حساب شما کامل نیست…», each distinct from the unknown-number message. The missing-profile case is now logged at `error`, not `warn` — it means something bypassed both paths that create a profile (D17), i.e. a server-side defect. |
|
||||
| **DOC-01** | Low (misleading) | TGT both apps `src/views/user/LoginView.vue` header comment | The file header still said the OTP flow was **stubbed** — "the SMS-send step is stubbed — no SMS provider exists", "Step 2 (OTP code) is NOT verified — any 4-digit code is accepted" — and that step 1 matched against `GET /api/customers` **in the client**, which was the login leak (D8). All three had been untrue for weeks. | Read the comment against the code beside it. | ✅ **FIXED 2026-10-12** — corrected in both apps rather than deleted, with a note that the stale text actually misled a reader during this session. |
|
||||
|
||||
**Test gap this exposed.** `requestOtp` had **no test coverage at all**: the whole
|
||||
28-test customer-auth suite stayed green through a change that altered its
|
||||
response for every unregistered number. `request-otp.spec.ts` now covers 9 cases
|
||||
— the happy path, the unknown number, Persian-digit input, a refused gateway, the
|
||||
dev no-gateway fallback, that the fallback does **not** apply in production, and
|
||||
the two sales-expert states.
|
||||
|
||||
@@ -21,7 +21,7 @@ The "Current state" table below is the parity project's and was last updated 202
|
||||
|---|---|
|
||||
| **Phase** | **Phase 2 — apps parity — items #1 and #2 done** (Phase 1 closed, `83e5d28`) |
|
||||
| **Branch** | `main` (the parity branches were merged; see git log 2026-10-08..10-12) |
|
||||
| **Status** | Phase 1: all in-scope sections closed (§2 §4 §5 §6 §7 §8 §9 ✅ · §3 ⏸️ by D16). Phase 2: **#1 MON-08 ✅**, **#2 D3 ✅**. **286/286** jest tests; both apps `vue-tsc` clean. |
|
||||
| **Status** | Phase 1: all in-scope sections closed (§2 §4 §5 §6 §7 §8 §9 ✅ · §3 ⏸️ by D16). Phase 2: **#1 MON-08 ✅**, **#2 D3 ✅**, plus the app-login error reporting the owner asked for (**AUTH-01/02/03**, **DOC-01**). **295/295** jest tests; both apps `vue-tsc` clean. |
|
||||
| **Next step** | Build **`02-APPS-MATRIX.md`** — the field-by-field apps matrix (D12), which Phase 2 has been running without. Then the remaining app rows it turns up. |
|
||||
| **Blocked on** | nothing. Awaiting an owner ruling when convenient: **PERM-02**. Deferred by the owner: **ANLY-01** (route analytics, lowest priority). |
|
||||
| **Last updated** | 2026-10-12 |
|
||||
@@ -143,6 +143,7 @@ All user rulings live there (D0–D17). Highlights that change how work is done:
|
||||
| D15 | **`_goldbank_migrations` is KEPT** — it is the tracking table of the app's own migration runner (`src/prisma/migrate.ts`), needed because the Docker image has no Prisma CLI. Declared `@@ignore`d; drift is zero. **Persian test input must come from a FILE, never a shell argument.** |
|
||||
| D16 | **PRICE-01 STOPPED** — price display is retail-storefront (⏸️ D13); the B2B app works in making charge only. `variant-price.ts` + tests kept but unused. **Making charge stays in scope.** New work order: Persons → Products/variants/order-detail → the rest of the admin → gate → apps. **Per section: implement gaps, then field-by-field test, and show a gap summary BEFORE fixing.** |
|
||||
| D17 | **SalesExpertProfile** (cityId, gramLevel, phone, nationalCode) + **firstName/lastName on all AdminUsers**. **`reagent_id` deliberately NOT ported** — in the reference it was a side effect of the shared customers form and filling it removed the expert from the selectable-agent list (queryReagent:420); the referrer logic lives on `Customer.referredByAgentId`. |
|
||||
| D28 | **App login step 1 must say what went wrong** (2026-10-12): an unregistered number → 404, a failed SMS send → 503, an unusable sales-expert account → 403, each with its own Persian message. Restores the reference's `firstOrFail()`. **Trades away part of the anti-enumeration protection, deliberately** — the per-mobile/per-IP send throttles still bound it and `verifyOtp` stays single-message. See D28 for the full reasoning and why D20 is not violated. |
|
||||
|
||||
## Open — awaiting user approval
|
||||
|
||||
|
||||
Reference in New Issue
Block a user