# 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.
