# Onboarding / Auth — Parity Matrix

Demo (React/Zustand, canonical) vs our Yii2 implementation.

| Demo behavior | Demo ref | Our ref | Status | Note |
|---|---|---|---|---|
| Single `nextOnboardingScreen` gate router | `lib/onboarding.ts:13-26` | — | missing | Gate logic spread across controllers; no single router, order differs |
| Gate: requested/rejected -> inactive | `lib/onboarding.ts:19-20` | `SiteController.php:101-103`, `Shop.php:94-95` | partial | No distinct "rejected" shop state modelled |
| Gate: phone must be verified | `lib/onboarding.ts:21` | `SignInController.php:183-186`; `User.php:70` | done | |
| Gate: password must be set | `lib/onboarding.ts:22` | — | partial | Password always set at signup; admin-created-owner path unmodelled |
| Gate: contract/terms hard gate on first login | `lib/onboarding.ts:24`; `store.ts:1242-1245` | `SiteController.php:899-919` | done | Consent on dashboard, not a standalone screen |
| Sign-up form (store + owner sections) | `SignUp.tsx:167-345` | `frontend/views/sign-in/signup.php` | done | Categories are Select2 dropdown, not chips |
| Sign-up requires terms AND privacy | `SignUp.tsx:120-137` | `SignupForm.php:44` | partial | Privacy checkbox rendered but NOT server-validated |
| Mobile normalized to +966, digit-only | `SignUp.tsx:154,256` | `SignupForm.php:66-67` | done | Ours adds full Saudi pattern + length validation |
| Password = confirm | `SignUp.tsx:126` | `SignupForm.php:78` | done | |
| Email unique | implicit | `SignupForm.php:55-58` | done | |
| Password length policy | none (advisory) | `SignupForm.php:77` (min6/max12) | partial | Ours imposes max-12 not in demo; demo has no min |
| Password strength meter (30/60/80 scoring) | `SignUp.tsx:18-43` | `signup.php:675` kartik widget | partial | Different scoring/labels |
| Sign-up -> pending-review panel, NO auto-login | `SignUp.tsx:163-165` | `SignInController.php:143-150` | missing | Ours auto-logs in + auto-accepts policies + redirects |
| Activate-email screen (faux email + Authenticate) | `ActivateEmail.tsx` | `SignInController.php:419-491` (real email + token link) | partial | Different model: real signed-token email, no on-screen email |
| "Nothing to activate" guard state | `ActivateEmail.tsx:17-33` | `verify-activation-otp.php` (user=null state) | partial | Equivalent empty state on OTP screen |
| Send activation OTP | `store.ts:1211-1221` | `SignInController.php:252-293` | done | Ours: 6-digit SMS via SmsLog; demo: 4-digit in-memory |
| OTP entry screen (4-digit numeric) | `OtpEntry.tsx` | `verify-activation-otp.php` | done | Ours 6-digit; richer (resend, expiry, empty state) |
| OTP demo-hint shows code on screen | `OtpEntry.tsx:55-60` | — | n/a | Intentionally absent (prod security) |
| Invalid OTP -> error, no state change | `store.ts:1225` | `SignInController.php:404-418` | done | Ours adds expiry + rate-limit |
| Valid OTP -> phoneVerified, clear code | `store.ts:1226` | `SignInController.php:431-441` | done | Ours also sets status ACTIVE, deletes token |
| Post-OTP route to set-password or login | `OtpEntry.tsx:35-40` | `SignInController.php:447-464` | partial | Always -> login (no set-password branch) |
| OTP resend with cooldown | — | `SignInController.php:489-595` | n/a | Ours only (no demo equivalent) |
| Set-password screen (admin-created owner) | `SetPassword.tsx` | — | missing | No owner-facing set-password flow |
| Login (email + password) | `Login.tsx`; `store.ts:1233-1240` | `SignInController.php:169`; `LoginForm.php` | done | Ours: username OR email, real hashing, RBAC |
| Login rejects if password not set | `store.ts:1235` | n/a | partial | Not applicable since password always set |
| Login warning toast on bad creds | `Login.tsx:21-24` | `LoginForm.php:70-72` (field error) | done | Ours uses field/flash error not toast |
| Brute-force IP lockout | — | `LoginForm.php:101-173` | n/a | Ours only — strong extra |
| Portal restricted to shop-owner role | `store.ts:1138` | `LoginForm.php:89-90,121-123` | done | |
| Accept terms flips shop to active | `store.ts:1244` | `SiteController.php:912-919` | done | |
| Owner scoped to one shop | `store.ts:1137-1140` | `User.shop_id` / `user->shop` | done | |
| Mobile-API signup + OTP channel | not modelled | `UserController.php:38,103,355` | n/a | App channel; out of demo scope |

## New in the dev demo (absent or different on our side)
- A single pure `nextOnboardingScreen()` router driving all gate ordering.
- An explicit **owner Set-Password screen** for admin-created owners (passwordSet:false).
- A distinct **`activated_pending_auth` shop status** + on-screen faux activation email.
- **Pending-review confirmation panel** after sign-up (no auto-login).
- A distinct **`rejected`** shop verification state in the gate logic.
- Password-strength meter with the specific 30/60/80 weak/fair/good/strong scoring,
  reused identically on SignUp and SetPassword.

## Notable extras on our side (not in demo, keep)
- Token-based emailed activation (signed, expiring).
- 6-digit DB-backed OTP with expiry, resend cooldown, and rate-limit.
- Login IP brute-force lockout with escalating blocks.
- RBAC `shopOwner` gate; username-or-email login.
- Full Saudi mobile pattern validation.

## Area score: 68%

Core gates, login, sign-up, and OTP verification are functionally present and in several
respects more hardened than the demo. The score is held down by genuine flow gaps:
missing owner Set-Password screen and `passwordSet` gate (-), missing post-signup
pending-review panel / auto-login divergence (-), no on-screen activate-email or
`activated_pending_auth`/`rejected` states (-), privacy-consent not server-validated (-),
and the password strength scoring / 12-char cap divergence (-).

---

## Verified verdict (adversarial)

Re-checked every "done"/high claim against actual source. Three overclaims found and
downgraded; the rest confirmed.

| Feature | Analyst | Verified | Evidence |
|---|---|---|---|
| Gate: contract/terms hard gate on first login | done | **partial** | Accept endpoint + shop→ACTIVE logic exist (`SiteController.php:899-919`), but enforcement UI `PolicyConsentPopup` is registered only in `frontend/views/layouts/base.php:166`. The dashboard renders with the **tailwind** layout (`SiteController.php` `actionIndex`: `$this->layout='tailwind'`), which does NOT include the popup, and `frontend/views/site/index.php` doesn't render it either. A VERIFIED + AWAITING_CONTRACT owner reaches the full dashboard with no terms gate shown. Demo `onboarding.ts:24` is a hard route to `'terms'` before `'portal'`. |
| Mobile normalized to +966, digit-only | done | **partial** | Demo stores `'+966'+digits` (`SignUp.tsx:154`). Ours validates the Saudi pattern but stores the raw input: `SignupForm.php` `$user->mobile = $this->owner_mobile;` (no normalization in `SignupForm` or `User`). Stored value keeps the `05…`/`5…` form, no `+966` prefix. `+966` is only prepended at SMS-send time (`modules/user/.../SignInController.php:254`), not persisted. Formats diverge. |
| Sign-up → pending-review panel, NO auto-login | missing (confirmed) | **missing (confirmed)** | `SignupForm::shouldBeActivated()` is hardcoded `return false` (`SignupForm.php`), so the controller's `else` branch runs: `Yii::$app->getUser()->login($user,...)` + `acceptPoliciesForUser()` (`SignInController.php:143-150`). The module flag `shouldBeActivated=true` set at `:124` is ignored by the form. Demo shows `ConfirmationPanel`, no login (`SignUp.tsx:163-165`). |
| Gate: phone must be verified | done | **confirmed** | `SignInController.php:183-186` redirects unverified phone to `/site/activation-status` which force-logs-out; dashboard re-checks at `actionIndex`. Gate present (user-level vs demo's shop-level). |
| Send activation OTP | done | **confirmed** | 6-digit `SmsLog::create` + send (`modules/user/.../SignInController.php:252-293`). |
| Invalid OTP → error, no state change | done | **confirmed** | Returns render with error, no mutation (`…:404-418`). |
| Valid OTP → phoneVerified, clear code | done | **confirmed** | Sets `phone_verified=VERIFIED`, deletes SmsLog + token (`…:431-441`). |
| Login (email/username + password) | done | **confirmed** | Real hashing, username-or-email, RBAC `shopOwner`, brute-force lockout (`LoginForm.php`). |
| Portal restricted to shop-owner role | done | **confirmed** | `getUser()` rejects `user_type!=2`; `can('shopOwner')` else logout+403 (`LoginForm.php:89-90,121-123`). |
| Accept terms flips shop to active | done | **confirmed** | `SiteController.php:912-924`, gated on VERIFIED + (AWAITING_CONTRACT|NEW). |
| Owner scoped to one shop | done | **confirmed** | `User.shop_id` / `user->shop`. |
| Sign-up requires terms AND privacy | partial | **partial (confirmed)** | View renders `SignupForm[privacy_policy]` (`signup.php:709`) but `SignupForm` has no such property/rule — only `terms_conditions` is required. Privacy not server-validated. |
| Set-password screen (admin-created owner) | missing | **missing (confirmed)** | No `passwordSet`/owner set-password anywhere in `common/`, `frontend/`, `api/`. |
| Gate: password must be set (passwordSet) | partial | **partial (confirmed)** | No `passwordSet` concept; admin-created-owner path unmodelled. |
| Gate: requested/rejected → inactive | partial | **partial (confirmed)** | `verification_status` has only PENDING/VERIFIED (`Shop.php:94-95`); no rejected verification state (a separate `STATUS_REJECTED=3` exists but is a different field). |
| Single nextOnboardingScreen router | missing | **missing (confirmed)** | Gate logic split across `SignInController` + `SiteController`; no single pure router. |
| Post-OTP route to set-password or login | partial | **partial (confirmed)** | Always redirects to `/sign-in/login` (`…:447-464`); no set-password branch. |
| Password length policy | partial | **partial (confirmed)** | `SignupForm.php:77` min6/max12; demo has no min and no 12-cap. |
| Password strength meter (30/60/80) | partial | **partial (confirmed)** | Ours = kartik widget; demo = length(4/char≤50)+class(12.5 ea), thresholds 30/60/80. Divergent. |
| Activate-email screen | partial | **partial (confirmed)** | Ours = real signed-token email + link (`SignInController.php:419-491`); demo = on-screen faux email + Authenticate button. |

### Net adjustment
Two previously-"done" items downgraded to partial (**contract/terms hard gate** — not
enforced on the actual dashboard layout; **mobile +966 normalization** — not persisted).
The terms-gate downgrade is the most material: it was counted as a fully-working hard gate
but the enforcement popup is absent from the layout owners land on. All other claims hold.

**Adjusted area score: 60%** (down from 68%).
