# Flow — admin-backoffice > Last verified: 2026-08-02 against commit `c841bde` **Actor(s):** admin (`super_admin` · `admin` · `support` · `finance` · `moderation`) · **Status:** mocked **Client:** partial (19 of 21 consoles on mocked seams, 2 on real) · **Server:** partial (real handlers, **403 for every seeded admin**) **Business source:** [product/business/14-notifications-and-admin.md](../../product/business/14-notifications-and-admin.md) **Integration:** [docs/integration/domains/admin.md](../integration/domains/admin.md) ## What it does The single operator console behind Balinyaar: approve nurses, moderate reviews, run the weekly payout batch, issue refunds, triage support alerts and tickets, edit the platform's runtime rates, and read the append-only audit trail. It is the only surface where a human authorises money movement — a scheduled job may *generate* a draft payout batch, but `process` is always an explicit admin action. ## ⚠ Read this first — the console cannot reach the server **Every `[Authorize(ConstantPolicies.DynamicPermission)]` endpoint returns `403` for both seeded admin accounts** (`09120000020` super_admin, `09120000021` finance). 23 controllers carry that policy. The chain: | Link | file:line | What it does | | --- | --- | --- | | the gate | [`DynamicPermissionService.cs:9`](../../server/src/Infrastructure/Baya.Infrastructure.Identity/Identity/PermissionManager/DynamicPermissionService.cs) | `if (user.IsInRole("admin")) return true;` — the **literal** string, not "any admin sub-role" | | the fallback | same file `:15-19` | else requires a `DynamicPermission` claim whose value is exactly `"{area}:{controller}:"` | | the vocabulary | [`RoleNames.cs:13-17`](../../server/src/Core/Baya.Domain/Entities/User/RoleNames.cs) | `Admin` · `Support` · `Finance` · `Moderation` · `SuperAdmin` are five **sibling** roles — `super_admin` is not a superset of `admin` | | the seeder | [`DemoWorldDefinitions.cs:144-145`](../../server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Seeding/DemoWorldDefinitions.cs) | grants `RoleNames.SuperAdmin` / `RoleNames.Finance` — **never** `RoleNames.Admin` | | the missing half | [`SeedDataBase.cs:50-71`](../../server/src/Infrastructure/Baya.Infrastructure.Identity/Identity/SeedDatabaseService/SeedDataBase.cs) | the only code path that calls `AddToRoleAsync(user, "admin")`, and it returns early unless `Seed:AdminUsername` **and** `Seed:AdminPassword` are configured. Neither is in `appsettings.Development.json` | No code anywhere writes a `DynamicPermission` claim, so branch 2 can never fire either. **No seeded account satisfies either branch.** **Why this is invisible in the UI:** `USE_ADMIN_MOCK = true` ([`admin/constants.ts:9`](../../client/src/services/admin/constants.ts)), plus `verification`, `payouts`, `refunds` and `partnerCenter` are all mock-primary — so 19 of the 21 consoles render a complete, filterable, mutable world out of in-browser fixtures and never call the API at all. The console *looks* built. Live-probed 2026-08-02 (`super_admin` token unless noted): | Probe | Result | | --- | --- | | `GET /api/v1/me` | `200` · `{"id":6,"roles":["super_admin"]}` — the token is fine | | `GET /api/v1/tickets?page=1` (user scope, same token) | `200`, 9 tickets — non-admin endpoints work normally | | `GET /api/v1/admin/tickets?page=1` | **`403`** `{"isSuccess":false,"statusCode":403,"message":"Authorization Error"}` | | `GET /api/v1/admin/reviews/moderation_queue?page=1` | **`403`** | | `POST /api/v1/platform_config/update_platform_config` (valid body) | **`403`** — writes are blocked at authz, *before* validation. Nothing mutates | | `GET /api/v1/admin_payouts/batches` (**finance** token) | **`403`** | ## Screens 21 routes, all inside the 480 px phone frame. `RoleGuard expected=admin` admits any of the five codes; `useAdminCapabilities()` ([`hooks/capabilities.ts:43`](../../client/src/hooks/capabilities.ts)) then hides group tabs and rows per code — **a UI hint only**, the consoles stay URL-reachable. | Group | Route | Client seam | Notes | | --- | --- | --- | --- | | — | `/fa/admin` | none | Overview hub; `NavHubList` filtered by capabilities | | اعتماد | `/fa/admin/trust` | none | Group root | | | `/fa/admin/verification` · `/…/[nurseId]` | `verification` **MOCK** | Queue folded per-nurse; case view with `DocumentViewer` re-signing its own URL | | | `/fa/admin/reviews` | `reviews` **REAL** | 4 moderation tabs → **403s live** | | مالی | `/fa/admin/finance` | none | Group root | | | `/fa/admin/payouts` · `/…/[batchId]` | `payouts` **MOCK** | Dry-run preview → idempotency-keyed run; `gross − clawback = net` | | پشتیبانی | `/fa/admin/support` | none | Group root | | | `/fa/admin/tickets` · `/…/[id]` | `tickets` **REAL** (+ `refunds` MOCK via `RefundPanel`) | Internal-notes composer → **403s live** | | | `/fa/admin/alerts` | `admin` **MOCK** | `support_alerts` triage; assign-to-self, resolve with note | | سیستم | `/fa/admin/system` | `auth` | Always shown — carries sign-out | | | `/fa/admin/config` | `admin` **MOCK** | Typed editor per `data_type`; rate keys validated to `[0, 1)` | | | `/fa/admin/holidays` | `admin` **MOCK** | `iranian_holidays` + the `is_bank_closed` flag | | | `/fa/admin/audit` | `admin` **MOCK** | Read-only; rows expand to the `changed_fields` diff | | | `/fa/admin/partners` · `/…/[id]` | `admin` + `partnerCenter` **MOCK** | IBAN write-then-masked, never displayed | | | `/fa/admin/users` | `admin` **MOCK** | Backed by `admin_users/search` — **route does not exist** | | | `/fa/admin/roles` | `admin` **MOCK** | RBAC grid; in-page banner says it is deferred | | | `/fa/admin/notifications` | none | `PlaceholderScreen` stub; **true orphan**, nothing links to it | ## API Shapes belong to the integration tree — do not restate them here. | Console | Endpoints | Contract | | --- | --- | --- | | config · holidays · audit · alerts | `platform_config/*`, `holidays/*`, `audit/get_audit_trail`, `support_alerts/*` | [admin.md](../integration/domains/admin.md) | | verification queue + case | `admin_verifications/*` | [verification.md](../integration/domains/verification.md) | | payout batches | `admin_payouts/*` | [payouts.md](../integration/domains/payouts.md) | | refunds (in the ticket thread) | `admin_refunds/*` | [refunds.md](../integration/domains/refunds.md) | | review moderation | `admin/reviews/moderation_queue` | [reviews.md](../integration/domains/reviews.md) | | ticket queue + admin thread | `admin/tickets`, `admin/tickets/{id}` | [tickets.md](../integration/domains/tickets.md) | | partner centers | `admin/partner-centers*` | [partner-center.md](../integration/domains/partner-center.md) | | **roles · user directory** | `admin_roles/*`, `admin_users/*` | **phantom — not on the wire** (REQ-031 deferred; REQ-061 never filed) | Client chain, verified link by link for the config console: `admin/config/page.tsx` → `usePlatformConfigs()` ([`services/admin/hooks/usePlatformConfigs.ts`](../../client/src/services/admin/hooks/usePlatformConfigs.ts)) → `adminApi` ([`apis/index.ts:10`](../../client/src/services/admin/apis/index.ts), the one-line selector, currently resolving to `adminMockApi`) → `adminClientApi.listPlatformConfigs` ([`apis/clientApi.ts:75`](../../client/src/services/admin/apis/clientApi.ts)) → `clientFetch` → `GET /api/v1/platform_config/get_platform_configs` → `PlatformConfigController` → `ListPlatformConfigs` handler. Every link exists. Only the selector and the authz gate stand in the way. ## Rules that must hold | Rule | Value | Source of truth | Product | | --- | --- | --- | --- | | Money movement stays human-approved | the scheduler opens a **draft** batch; `process` is an explicit admin action | `WeeklyPayoutGenerationJob` + `ExecutePayoutBatch` | [10-payouts.md](../../product/business/10-payouts.md) | | Config is rows read at compute time, and a rate change is **never retroactive** | rates snapshotted onto the row (`Bookings.PlatformFeeRate`, `Invoices.VatRate`) | CONFIG via `IPlatformConfig` | [14-…-admin.md](../../product/business/14-notifications-and-admin.md) | | Commission / VAT | `platform_fee_rate = 0.15`, `vat_rate = 0.10`, VAT on the **commission line only** | CONFIG keys (0.15 is a seeded default, not a product mandate) | [13-tax-invoicing-and-legal.md](../../product/business/13-tax-invoicing-and-legal.md) | | Rate keys are in `[0, 1)` | console validates before writing | `RATE_CONFIG_KEYS`, `admin/constants.ts:27` | — | | Holidays shift the payout date | server resolves the next business day; **the client never computes a shift** | `IHolidayCalendar` ROWS | [10-payouts.md](../../product/business/10-payouts.md) | | Audit trail is append-only | no edit/delete affordance; retention `730` d general / `2555` d financial | CONFIG `audit_retention_*_days` | — | | `is_internal` never leaves the query layer | admin thread is the *only* surface that carries internal notes | `TicketRepository` projections | [12-messaging-and-emergencies.md](../../product/business/12-messaging-and-emergencies.md) | | Refunds are admin-only and ticket-anchored | no customer self-service; every refund splits across both fee legs | `AdminRefundsController`, `CK_Refunds_LegSplit` | [07-cancellation-and-refunds.md](../../product/business/07-cancellation-and-refunds.md) | | Low-rating alert threshold | `≤ 2` | CONFIG `min_rating_for_support_alert` | [11-reviews-trust-and-safety.md](../../product/business/11-reviews-trust-and-safety.md) | | Publishing a review recomputes the nurse aggregate **server-side** | on every status transition | review moderation handlers | [11-…-safety.md](../../product/business/11-reviews-trust-and-safety.md) | | Suspending a nurse flips `is_searchable = 0` on every one of her rows | rows are kept, never deleted | `SearchIndexMaintainer.cs:177,248` | [11-…-safety.md](../../product/business/11-reviews-trust-and-safety.md) | ## How to test 1. Log in as `09120000020` (نگار مدیری, super_admin) — see [testing-setup.md](testing-setup.md). **Expect:** `RoleGuard` admits you and the app lands on `/fa/admin` with all four group tabs visible. 2. Walk `/fa/admin/config` → `/fa/admin/holidays` → `/fa/admin/audit` → `/fa/admin/alerts`. **Expect:** each renders a populated, filterable console; edits appear to save. **This proves nothing about the server** — these are `adminMockApi` fixtures held in module state, and they reset on every page reload or HMR. Open devtools Network: **there is no request**. 3. Open `/fa/admin/tickets` and `/fa/admin/reviews` — the two consoles on real seams. **Expect:** an error state, not a queue. `clientFetch` toasts the 403 itself. 4. Log in as `09120000021` (کامران مالی, finance) and open `/fa/admin`. **Expect:** «اعتماد» and «پشتیبانی» tabs are gone; only «مالی» and «سیستم» remain. `/fa/admin/payouts` renders a mock batch list. `/fa/admin/audit` is hidden (audit is `admin`/`super_admin` only) but still URL-reachable. 5. Confirm the gap directly, without the browser: `curl -s --noproxy '*' "http://localhost:5002/api/v1/platform_config/get_platform_configs?page=1" -H "Authorization: Bearer $T_09120000020"` **Expect:** `403` `{"isSuccess":false,"statusCode":403,"message":"Authorization Error"}`, while `GET /api/v1/me` with the same token returns `200`. **Break-glass (UNVERIFIED — not executed for this stamp):** add `"Seed": { "AdminUsername": "…", "AdminPassword": "…" }` to `appsettings.Development.json` **before boot**. `SeedDataBase.SeedBootstrapAdminAsync` then mints a user in the literal `admin` role, which satisfies branch 1. That account is **username/password, not phone-OTP**, so it cannot log in through the web UI — drive the API directly. It was not tested here because it needs a server restart shared with other agents. **Seeded-world caveat:** the demo world is 7 days stale (see [testing-setup.md](testing-setup.md#-it-has-aged-out--and-this-is-not-cosmetic)). Even with the RBAC gap fixed, the verification queue has no `in_review` step awaiting a decision beyond nurse 3's two blocking steps, and every dispute window has closed — so a live payout preview would sweep bookings you did not stage. ## Known gaps - **`DynamicPermissionService.CanAccess` grants only on the literal role `"admin"`.** The four sibling admin roles — `super_admin`, `support`, `finance`, `moderation` — get 403 on all 23 `DynamicPermission` controllers. `DynamicPermissionService.cs:9`. A `super_admin` has *less* access than an `admin`. - **No seeded account holds the literal `admin` role.** `DemoWorldDefinitions.cs:144-145` grants `super_admin`/`finance`; `SeedDataBase.cs:55-56` returns early because `Seed:AdminUsername`/`AdminPassword` are unset. The entire backoffice is untestable end-to-end out of the box. - **No code path ever writes a `DynamicPermission` claim**, so the per-controller fallback branch (`DynamicPermissionService.cs:15-19`) is dead — the claim key `"{area}:{controller}:"` has no producer. - **The 403 is invisible in the UI.** `USE_ADMIN_MOCK = true` plus mocked `verification`/`payouts`/`refunds`/ `partnerCenter` means 19 of 21 consoles never call the API. A reviewer clicking through the console concludes it works. - **`/fa/admin/tickets` and `/fa/admin/reviews` are broken for the operator right now** — real client seams onto 403ing controllers. The only two consoles where the defect surfaces. - **`admin/apis/clientApi.ts` `pageQuery()` sends `page_size`; the controllers declare `PageSize`.** Model binding is case-insensitive, not separator-insensitive, so every admin list silently falls back to the server default page size the moment `USE_ADMIN_MOCK` flips. - **`/fa/admin/roles` has no server** — `admin_roles/list_roles|grant_role|revoke_role` are phantom (REQ-031 deferred). Admin roles are seeded, never managed. - **`/fa/admin/users` has no server** — `admin_users/search|lookup` are phantom, and **REQ-061 was never filed in the ledger** despite ten client files citing it. `AuditLogRow` shows `#id` instead of a name. - **`/fa/admin/notifications` is a `PlaceholderScreen` stub and a true orphan** — no link reaches it, and `AdminLayout` renders no `NotificationBell`. - **`POST /api/v1/holidays/delete_holiday` is unwired** — the console offers no delete affordance. - **`admin_cancellation_policies/list|upsert` are unwired** — no screen edits the cancellation tiers, so the seeded `standard_24h`/`standard_inside_24h` rows are effectively read-only in production. - **`admin_search/rebuild_index` and `admin_booking_requests/expire` have no UI** — ops one-shots reachable only by curl (and both 403 for a seeded admin). - **`useAdminCapabilities` hides tabs but does not block routes.** Every console stays URL-reachable for any admin code; the enforcement is expected to be server-side, which is currently a blanket 403.