create mvp path
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
# Flow — account-and-settings
|
||||
|
||||
> Last verified: 2026-08-02 against commit `c841bde`
|
||||
|
||||
**Actor(s):** every signed-in actor (customer · nurse · admin · partner) · **Status:** partial
|
||||
**Client:** partial · **Server:** real
|
||||
**Business source:** [product/business/01-actors-and-onboarding.md](../../product/business/01-actors-and-onboarding.md)
|
||||
(§(a) as-built auth/session rules; the account-hub UI itself is a UI-phase-9 decision with no product-doc source
|
||||
— see the coverage note in the business-area map)
|
||||
**Integration:** [docs/integration/domains/profiles.md](../integration/domains/profiles.md) ·
|
||||
[docs/integration/domains/auth.md](../integration/domains/auth.md)
|
||||
|
||||
## What it does
|
||||
|
||||
Each of the four shells ends in a settings hub: who you are signed in as, the handful of preferences the app
|
||||
keeps (appearance, language, and — for a customer — name and emergency contact), the links out to
|
||||
notifications and support, and the way out of the app. Since UI phase 9 the customer's `/fa/profile` is an
|
||||
**account hub**, not a profile form: each section opens its own bottom sheet. Sign-out is the one action here
|
||||
that reaches the server, and it revokes the session rather than just clearing cookies.
|
||||
|
||||
## Screens
|
||||
|
||||
| Step | Route | Component / notes |
|
||||
| --- | --- | --- |
|
||||
| Customer hub | `/fa/profile` | [`(customer)/profile/page.tsx`](../../client/src/app/%5Blocale%5D/%28private-routes%29/%28customer%29/profile/page.tsx) — `ProfileSummary` + rows; three `FormDialogShell` sheets (اطلاعات شخصی / زبان / مخاطب اضطراری) over **one** `react-hook-form`, because the wire upsert has no PATCH semantics (`:76-89`) |
|
||||
| Nurse hub | `/fa/nurse/more` | [`NurseMoreScreen.tsx`](../../client/src/app/%5Blocale%5D/%28private-routes%29/nurse/more/NurseMoreScreen.tsx) — `ProfileSummary` (+ `TrustBadge`), support/notification hub rows, `SettingsPanel`, `SignOutRow` |
|
||||
| Admin hub | `/fa/admin/system` | [`admin/system/page.tsx`](../../client/src/app/%5Blocale%5D/%28private-routes%29/admin/system/page.tsx) — the 6 system consoles (capability-filtered) **plus** identity/appearance/sign-out. Always shown: it is the only way out |
|
||||
| Partner hub | `/fa/partner/more` | [`partner/more/page.tsx`](../../client/src/app/%5Blocale%5D/%28private-routes%29/partner/more/page.tsx) — center name + MoR chip, `SettingsPanel`, `SignOutRow` |
|
||||
| Appearance + language | (in every hub) | [`SettingsPanel.tsx`](../../client/src/components/settings/SettingsPanel.tsx) → [`ThemeModeSetting`](../../client/src/components/settings/ThemeModeSetting.tsx) (روشن/تیره/سیستم) + [`LocaleSwitcher`](../../client/src/components/common/LocaleSwitcher/LocaleSwitcher.tsx). **The customer hub does not use `SettingsPanel`** — it inlines `ThemeModeSetting` (`page.tsx:155`) and puts `LocaleSwitcher` inside the زبان sheet (`:207`) |
|
||||
| Actor switch | customer + nurse hubs | [`ActorSwitcher`](../../client/src/layout/components/ActorSwitcher.tsx) — renders **nothing** unless the session holds both `customer` and `nurse` (`:27`). No seeded demo account is dual-role |
|
||||
|
||||
## API
|
||||
|
||||
| Call | Endpoint | Notes |
|
||||
| --- | --- | --- |
|
||||
| identity for every hub | `GET /api/v1/me` | `MeController.cs:23`. The customer's first/last name is sourced from here by design |
|
||||
| customer profile read | `GET /api/v1/customer_profiles/me` | `404 → null` (an empty form), not an error — `profiles/apis/clientApi.ts:19-26` |
|
||||
| customer save (all 3 sheets) | `POST /api/v1/customer_profiles/upsert` | one call per sheet, always sending the **whole** profile — `clientApi.ts:60-75`. Names are written to the `users` row, not the profile (`UpsertCustomerProfileCommand.Handler.cs:45-57`) |
|
||||
| nurse identity | `GET /api/v1/nurse_profiles/me` | supplies `avatarUrl` + verification state for the header |
|
||||
| partner identity | `useMyPartnerCenter()` | **mocked** — `USE_PARTNER_MOCK = true`, and the mock resolves `MOCK_MY_CENTER_ID = 1` for any caller |
|
||||
| sign out | `POST /api/v1/auth/logout` | `AuthController.cs:48` → `LogoutCommand.Handler.cs` |
|
||||
|
||||
Request/response shapes: [profiles.md](../integration/domains/profiles.md), [auth.md](../integration/domains/auth.md).
|
||||
|
||||
### Does logout actually revoke server-side? **Yes — and it revokes everywhere.**
|
||||
|
||||
Traced: `SignOutRow.tsx:14` / `profile/page.tsx:269` → `useLogout()` → `authApi.logout({})` →
|
||||
`authClientApi.logout` (`auth/apis/clientApi.ts:47-52`) → `POST /api/v1/auth/logout` →
|
||||
[`LogoutCommand.Handler.cs:25-43`](../../server/src/Core/Baya.Application/Features/Identity/Commands/Logout/LogoutCommand.Handler.cs).
|
||||
Both callers send an **empty body**, and the handler treats a missing `refreshToken` as
|
||||
`Everywhere`: `RevokeAllActiveForUserAsync(...)` for every active session, then
|
||||
`UpdateSecurityStampAsync`, which makes every outstanding JWE access token fail the bearer handler's
|
||||
stamp check. Cookies are cleared in `onSettled` **regardless of the call's outcome**
|
||||
(`useLogout.ts` — `clearAuthTokens()`), so an offline sign-out still ends the local session.
|
||||
|
||||
Proven server-side by the integration test `RefreshAndLogoutTests.cs:41-53`, which posts `{}` — the exact
|
||||
client body — and asserts the follow-up `/me` returns `401`. **Not probed live on purpose:** calling
|
||||
`/auth/logout` would revoke the shared pre-minted demo token for that account and break other testers.
|
||||
|
||||
## Rules that must hold
|
||||
|
||||
| Rule | Value / source |
|
||||
| --- | --- |
|
||||
| Sign-out kills **all** sessions, not just this device | `LogoutCommand.cs:12` — no `refreshToken` ⇒ `Everywhere`. Session TTL itself is CONFIG `auth_session_ttl_days` = 30 |
|
||||
| Self-selectable roles are `customer` and `nurse` only | `RoleNames.SelfAssignable`; an admin sub-role self-assign is a **403**. The hub has no role-change affordance at all |
|
||||
| Phones are masked on `/me`-style payloads | INV-21 — live `/me` returns `0912*****10`; `ProfileSummary` renders it as a `dir="ltr"` island |
|
||||
| PII is encrypted at rest | INV-21 — the emergency contact name/phone are field-encrypted; only the masked/decrypted read comes back |
|
||||
| Colors come from tokens, never a literal | [docs/rules/client/theme.md](../rules/client/theme.md); the no-flash boot is **CSS-only** (client hard rule 9) |
|
||||
| `prefers-reduced-motion` has exactly one gate | `src/app/globals.css` (client hard rule 10) |
|
||||
|
||||
**Theme mechanics (no JS boot script).** `ColorSchemeCookieSync` (`theme/ThemeProvider.tsx:20-30`) writes the
|
||||
*resolved* scheme to a `color-scheme` cookie; the root layout reads it (`getThemeMode`,
|
||||
`lib/cookies/server.ts:42-50`) and stamps `data-mui-color-scheme` on `<html>` server-side
|
||||
(`[locale]/layout.tsx:112,125`). With no cookie the attribute is omitted and `tokens.css`'s
|
||||
`@media (prefers-color-scheme: dark)` fallback paints instead. `ThemeModeSetting` is the **only** subtree
|
||||
subscribed to `useColorScheme()`.
|
||||
|
||||
## How to test
|
||||
|
||||
1. Log in as **09120000010** (سارا محمدی, customer) — see [testing-setup.md](testing-setup.md).
|
||||
2. Open `/fa/profile`. **Expect:** initials avatar, «سارا محمدی», masked `0912*****10`, a «تماس اضطراری»
|
||||
card with a **green** check icon reading «بهرام محمدی · 09121110010», and rows for
|
||||
اطلاعات شخصی / نشانیها / زبان / نمایش / اعلانها / پشتیبانی, then a red «خروج از حساب» row.
|
||||
3. Tap the **نمایش** segments روشن → تیره. **Expect:** the whole app repaints immediately, no reload; reload
|
||||
the page and the choice survives (the `color-scheme` cookie), with no flash of the wrong scheme.
|
||||
4. Tap **زبان** → the globe button. **Expect:** the URL becomes `/en/profile` — the *same* page, not home.
|
||||
The «زبان برنامه» select below it saves `preferredLanguage` to the server but **changes nothing visible**
|
||||
(see gaps).
|
||||
5. Tap **اطلاعات شخصی**, change the family name, save. **Expect:** a «ذخیره شد» toast and the header name
|
||||
updates (the mutation invalidates `/me`).
|
||||
6. Tap **خروج از حساب** → confirm «خروج از حساب؟». **Expect:** you land on `/fa/login`. Re-using that
|
||||
account's old bearer token against `GET /api/v1/me` now returns `401`.
|
||||
7. Log in as **09120000001** (زهرا عزیزی, nurse) and open `/fa/nurse/more`. **Expect:** name + a green
|
||||
✓ تاییدشده `TrustBadge`, the نمایش/زبان panel, and a «خروج» button that signs out on **one tap, with no
|
||||
confirmation** (unlike the customer hub).
|
||||
8. `/fa/admin/system` as **09120000020**: the identity card renders, but the role label under the name is the
|
||||
raw key `admin.role_super_admin` (see gaps). The six console rows render; every one of them 403s — that is
|
||||
the [admin RBAC gap](testing-setup.md#-the-seeded-admins-cannot-reach-any-admin-endpoint), not this flow.
|
||||
9. `/fa/partner/more` — **type the URL**; nothing links to `/fa/partner`, and `09120000030` has no partner
|
||||
role (`/me` → `["customer"]`). Whatever center name you see is mock data.
|
||||
|
||||
**Seeded-world caveat:** every seeded customer already has an emergency contact, so the 400 in the first gap
|
||||
below **will not reproduce on a demo account**. To see it, sign in with a fresh phone, complete onboarding,
|
||||
then edit only the name.
|
||||
|
||||
## Known gaps
|
||||
|
||||
- **A customer with no emergency contact cannot save their name or language.** `save()`
|
||||
(`(customer)/profile/page.tsx:97-116`) always posts the whole profile, and
|
||||
`UpsertCustomerProfileCommand.Validator.cs:9-13` requires `DefaultEmergencyContactName` non-empty and
|
||||
`DefaultEmergencyContactPhone` to be a valid Iranian mobile. Verified live: `POST customer_profiles/upsert`
|
||||
with empty emergency fields → **400** (`'Default Emergency Contact Name' must not be empty.`). Only
|
||||
`saveEmergency` pre-validates (`:120-123`); `savePersonal`/`saveLanguage` do not.
|
||||
- **That 400 is completely silent.** `useUpsertCustomerProfile` has no `onError`, the call site passes only
|
||||
`onSuccess`, and `clientFetch` deliberately does not toast non-401/403/5xx 4xx
|
||||
(`lib/api/client.ts:11`). The sheet just stays open — a client hard-rule-22 violation.
|
||||
- **`preferredLanguage` is stored and never consumed.** It round-trips server-side
|
||||
(`CustomerProfileRepository.cs:25`), but the only client reads are the form's own default and its save
|
||||
(`profile/page.tsx:85,105`). The UI locale comes from the URL via next-intl; nothing reads the stored
|
||||
preference at login or on the server. The زبان sheet therefore shows **two** language controls that do
|
||||
different things.
|
||||
- **Nurse avatars never load.** `LocalDiskObjectStorage.GetUrl` returns a `file://` URI (`:51`); live
|
||||
`GET /nurse_profiles/me` for nurse 1 returns
|
||||
`avatarUrl: "file:///C:/Users/.../avatars/nurse/1/....png"`, which a browser will not fetch from an
|
||||
`http://` page. `ProfileSummary` degrades to the name's first letter. Affects the deployed `local`
|
||||
provider too — only `Seams:ObjectStorage:Provider = s3` would emit a usable URL.
|
||||
- **The admin hub renders a raw translation key as the role label.** `admin/system/page.tsx:79` calls
|
||||
``ta(`role_${primaryRoleCode}`)``; `admin.role_super_admin` / `role_finance` / `role_support` /
|
||||
`role_moderation` / `role_admin` exist in **neither** `messages/fa.json` nor `en.json`. No `onError`/
|
||||
`getMessageFallback` is configured in `i18n/request.ts`, so next-intl renders the key path.
|
||||
`npm run check` misses it — `check-copy.mjs` lints orthography and en/fa symmetry, not key existence.
|
||||
- **The partner settings hub shows fabricated identity.** `USE_PARTNER_MOCK = true` and the mock resolves
|
||||
`MOCK_MY_CENTER_ID = 1` for **any** caller, so the center name and the merchant-of-record chip are the same
|
||||
for everyone. There is no real tenancy on this screen.
|
||||
- **Sign-out confirmation is inconsistent.** The customer hub gates it behind a `ConfirmDialog`
|
||||
(`profile/page.tsx:259-271`); nurse, admin and partner use `SignOutRow`, which fires on the first tap. One
|
||||
mis-tap on `/fa/nurse/more` ends the session — and, per the handler, every other session too.
|
||||
- **A customer cannot set an avatar.** `POST /api/v1/customer_profiles/avatar` is live but the client's
|
||||
`uploadAvatar` targets the nurse route only (`profiles/apis/clientApi.ts:100-109`); the customer hub passes
|
||||
`initialsFallback` and no `avatarUrl`. Already recorded in
|
||||
[profiles.md](../integration/domains/profiles.md).
|
||||
- **No notification-preference surface exists.** The «اعلانها» row deep-links to the notification *centre*;
|
||||
there is no per-channel opt-in/out anywhere in the app.
|
||||
- **The nurse hub's support badge can never show a number.** `ticketsApi.getUnreadTotal` on the real path is
|
||||
literally `async () => null` (`tickets/apis/clientApi.ts:223`, REQ-059), so `useSupportUnreadTotal()`
|
||||
always returns `null` and `NurseMoreScreen.tsx:40` renders no badge.
|
||||
- **The customer hub navigates with a hand-built locale prefix.** `profile/page.tsx:66,129` imports
|
||||
`useRouter` from `next/navigation` and does ``router.push(`/${locale}${path}`)`` — client hard rule 13
|
||||
requires `@/i18n/navigation`. Works today; breaks silently if `localePrefix` ever changes.
|
||||
- **UNVERIFIED (no browser in this environment): a system-mode user may see one scheme flip after
|
||||
hydration.** `getThemeMode` collapses the cookie to a concrete `defaultMode` of `dark`/`light`
|
||||
(`lib/cookies/server.ts:47-48`), while `ThemeModeSetting` reads MUI's `mode`, which can still be
|
||||
`'system'`. If the OS scheme changed since the last visit, the SSR paint and the post-hydration resolution
|
||||
disagree. Code-traced only; not observed rendering.
|
||||
Reference in New Issue
Block a user