---
feature: Authentication
slug: auth
surface: admin
consumer: react
version: 4.0.1
status: ready
brd_version: "1.3"
brd_ids: [ADMIN-001, ADMIN-024]
business_rules: [BR-29]
decisions: [TD-01, DEC-15, DEC-17, TD-19, TD-20, TD-23, TD-27, TD-28, TD-29, TD-53]
schema_tables: [users, roles, role_translations, permissions, permission_role, permission_user]
screens: []
related_docs: [admins-permissions.md, ../client/auth.md, ../provider/auth.md, ../delivery/auth.md]
last_updated: 2026-11-08
---

# Authentication — React Dashboard Integration Guide

Base URL: {HOST}/api/v1/admin. Shared conventions:
[API_CONVENTIONS.md](../API_CONVENTIONS.md). Responses below are captured from
passing MySQL feature tests; JWTs are redacted.

## Version history

| Version | Date | Type | Summary | Dashboard action required |
|---|---|---|---|---|
| 4.0.1 | 2026-11-08 | PATCH | Postman examples for sign-in, sign-out and own profile; `login`, `password` and `device_token` described. | None. |
| 4.0.0 | 2026-11-01 | MAJOR | `profile.permissions` (login, me, refresh, profile) and `auth/my-permissions` `permissions` use the `GET admin/permissions` shape: `{ [sectionTitle]: {id, title, back_route_name, front_route_name, icon}[] }` instead of `{resource: [actions]}` | Check a grant with `back_route_name` (`roles.index`) instead of `permissions.roles.includes('index')`; build menus from `front_route_name` and `icon`. `sections` is unchanged |
| 3.0.0 | 2026-10-27 | MAJOR | `GET auth/my-permissions`: `permissions` is now the `{resource: [actions]}` dictionary (same shape as `profile.permissions`, 2.0.0) and a super admin receives the full dictionary and every section instead of empty lists | Read `permissions[resource]?.includes(action)` here too; `is_super_admin` is still true, but you no longer need to special-case empty lists |
| 2.0.0 | 2026-10-26 | MAJOR | `profile.permissions` grouped as `{resource: [actions]}` dictionary | Read `profile.permissions[resource]?.includes(action)` or gate UI by resource actions dictionary |
| 1.2.0 | 2026-10-22 | MINOR | New `GET auth/my-permissions`: own grants, flat and grouped by section | Optional: load menus from `auth/my-permissions` (`sections`) and re-fetch it after a 403 `PERMISSION_DENIED` |
| 1.1.0 | 2026-10-21 | MINOR (contract change) | Wrong credentials return `INVALID_CREDENTIALS`; 12-hour access token, refresh after expiry for 7 days; localized messages | Branch on `INVALID_CREDENTIALS` for the login form; refresh on 401 before signing out |
| 1.0.1 | 2026-10-20 | PATCH | RBAC enforced in every environment; grants from `PermissionResolver`; super admin lists every route | None (same shape). Super-admin sessions now receive the full route list |
| 1.0.0 | 2026-10-20 | Initial | Admin app-scoped authentication | Keep login=email; store access_token; read user/profile and flat permissions |

## Changelog

### 4.0.1 — 2026-11-08

- **Changed:** Postman examples for sign-in, sign-out and own profile; `login`, `password` and `device_token` described.
- **Breaking:** No. **App action required:** None.

### 4.0.0 — 2026-11-01

- **Changed (breaking):** `profile.permissions` in `AuthResource` (`auth/login`, `auth/me`, `auth/refresh`, `GET profile`) and `permissions` in `auth/my-permissions` are keyed by the localized section title; each value is a list of `{id, title, back_route_name, front_route_name, icon}`, one per granted route ([admins-permissions.md](admins-permissions.md) 3.0.0 §4.3 describes the fields). A super admin on `auth/me` still gets the whole catalog; on `auth/my-permissions` the answer is unchanged by this release.
- **Retained:** `auth/my-permissions` `sections` and `is_super_admin`, and `profile.role`.
- **Breaking:** Yes — clients reading `permissions[resource]` as `string[]`.
- **Endpoints affected:** `POST auth/login`, `GET auth/me`, `POST auth/refresh`, `GET profile`, `GET auth/my-permissions`.
- **Dashboard action required:** Replace `permissions.roles?.includes('index')` with a lookup of `back_route_name === 'roles.index'` across the lists (flatten once); use `front_route_name` / `icon` for menus.
- **BRD / decisions:** ADMIN-024, BR-29, TD-27.

### 3.0.0 — 2026-10-27

- **Changed (breaking):** `GET auth/my-permissions` → `data.permissions` is a dictionary of actions grouped by resource (`Record<string, string[]>`, e.g. `{"users": ["index", "view"]}`) instead of a flat array of route names. The resource and action are the route name without the `admin.` prefix, split at the first dot.
- **Changed (breaking):** a super admin (`is_super_admin: true`) now receives the **full** dictionary and every catalog section, not empty lists.
- **Breaking:** Yes — clients that read `my-permissions.permissions` as `string[]` must use `permissions[resource]`. Menus built from `sections` are unaffected.
- **Endpoints affected:** `GET auth/my-permissions`.
- **Dashboard action required:** Replace `permissions.includes('admin.users.view')` with `permissions.users?.includes('view')`; remove any "empty list means everything" branch and keep checking `is_super_admin` first if you want to skip the lookup.
- **BRD / decisions:** ADMIN-024, BR-29, TD-53.

### 2.0.0 — 2026-10-26

- **Changed (breaking):** `profile.permissions` in `AuthResource` (returned by `auth/login`, `auth/me`, `auth/refresh`, and `GET profile`) is now formatted as a dictionary of actions grouped by resource (`Record<string, string[]>`, e.g. `{"roles": ["index"], "users": ["view"]}`) instead of a flat array of full route names. Actions within each resource follow standard CRUD precedence (`index`, `store`/`create`, `show`/`view`, `update`, `destroy`/`delete`) followed by custom actions (e.g. `toggle-active`, `wallet-transactions`).
- **Breaking:** Yes — clients parsing `profile.permissions` as a flat `string[]` must now inspect `profile.permissions[resource]` as an array of action names.
- **Endpoints affected:** auth/login, auth/me, auth/refresh, GET profile.
- **Dashboard action required:** Update permission checks from `permissions.includes('admin.resource.action')` to `permissions[resource]?.includes(action)`.
- **BRD / decisions:** ADMIN-001, ADMIN-024, BR-29.

### 1.2.0 — 2026-10-22

- **Added:** `GET auth/my-permissions` (route `admin.auth.my-permissions`, self-access, no section permission needed). Returns `is_super_admin`, `role`, the flat `permissions` route-name list (same list as `profile.permissions`) and `sections`: each dashboard section where the admin holds at least one route, with the granted matrix actions in catalog order. A super admin gets `is_super_admin: true` with empty `permissions` and `sections` (allowed everything implicitly).
- **Changed:** —
- **Fixed:** —
- **Removed / Deprecated:** —
- **Breaking:** No.
- **Endpoints affected:** auth/my-permissions (new).
- **Dashboard action required:** None. Optional: build the sidebar from `sections` and refresh grants from this endpoint without reloading the whole account.
- **BRD / decisions:** ADMIN-024, BR-29, TD-27, TD-28, TD-53.

### 1.1.0 — 2026-10-21

- **Changed (contract change):** unknown email, wrong password and non-admin credentials return 422 `error_code: INVALID_CREDENTIALS` (was `UNAUTHENTICATED`, which means 401 everywhere else). Same generic message for all three. Marked MINOR because no dashboard is built yet.
- **Changed (TD-29):** admin access tokens last 12 hours (`expires_in` = 43200, was 5184000). `auth/refresh` accepts an **expired** token for 7 days after the original sign-in, keeps `app=admin` and revokes the old token; after that it returns 401.
- **Changed (DEC-17):** validation, 401, 403, 404, 405, 429 and 500 messages are Arabic by default and English with `Accept-Language: en`; a 422 message carries a translated "(and N more errors)" suffix; a 500 returns `INTERNAL_ERROR` + `correlation_id` without internals.
- **Breaking:** No dashboard exists yet; any client branching on `UNAUTHENTICATED` for the login form must switch to `INVALID_CREDENTIALS`.
- **Endpoints affected:** auth/login, auth/refresh.
- **Dashboard action required:** handle `INVALID_CREDENTIALS` on the login form; on a 401 call `auth/refresh` once with the stored token before signing out.
- **BRD / decisions:** ADMIN-001, TD-29, DEC-17.

### 1.0.1 — 2026-10-20

- **Added:** —
- **Changed:** `profile.permissions` is computed by `PermissionResolver`, the same source `rbac.auto` uses: role's own ∪ active descendant roles' (a senior role inherits its juniors, TD-28) ∪ direct grants. A `super_admin` now receives every catalog route name instead of only rows attached to her. The list is refreshed as soon as a role or grant changes.
- **Fixed:** Resource RBAC is no longer bypassed in local/testing; it is enforced in every environment (TD-28).
- **Removed / Deprecated:** —
- **Breaking:** No. Same keys and types.
- **Endpoints affected:** auth/login, auth/refresh, auth/me, GET profile (permissions list content).
- **Dashboard action required:** None. Keep hiding menus by `profile.permissions`; see [admins-permissions.md](admins-permissions.md) for the catalog.
- **BRD / decisions:** ADMIN-024, BR-29, TD-27, TD-28.

### 1.0.0 — 2026-10-20

- **Added:** auth/refresh, auth/me, role mini and flat permission route names.
- **Changed:** admin email/password only, JWT app=admin, dedicated auth response.
- **Fixed:** non-admin credentials receive generic invalid-credentials response.
- **Removed / Deprecated:** old token/name auth fields; use access_token and user.full_name.
- **Breaking:** Yes for consumers of the undocumented base; published guide starts at 1.0.0.
- **Endpoints affected:** auth/login, auth/logout, auth/refresh, auth/me, GET profile.
- **Dashboard action required:** store data.access_token; parse data.user and data.profile.
- **BRD / decisions:** ADMIN-001, TD-01, DEC-15, DEC-17.

## 1. Business summary

Only super_admin and supervisor accounts authenticate with email/password.
Every protected admin route requires an active account and signed admin app
claim. Tokens from other apps are denied. The account resource includes role
and a flat list of inherited-role and direct permission route grants.

## 2. Pages and permissions

| Page/component | Endpoint | Route name / access |
|---|---|---|
| Login | POST auth/login | admin.auth.login; public |
| Session | GET auth/me | admin.auth.me; authenticated self-access |
| Menu / button guards | GET auth/my-permissions | admin.auth.my-permissions; authenticated self-access |
| Profile alias | GET profile | admin.profile.view; authenticated self-access |
| Profile form | PUT profile | admin.profile.update; authenticated self-access |
| Password form | POST profile/change-password | admin.profile.change-password; authenticated self-access |
| Refresh | POST auth/refresh | admin.auth.refresh; authenticated self-access |
| Logout | POST auth/logout | admin.auth.logout; authenticated self-access |

No sidebar entry for auth. Use profile.permissions to hide business menus and
actions by exact route name. Auth/profile routes explicitly skip section RBAC
**after** auth:api, app:admin and active. Every other admin route is checked
by rbac.auto in every environment, testing included (TD-28). The permission
catalog, section matrix and grant rules are in
[admins-permissions.md](admins-permissions.md).

## 3. List / table

N/A — no list, filtering, sorting, searching or pagination.

## 4. Details view

GET /api/v1/admin/auth/me and existing GET /api/v1/admin/profile require an
admin bearer token, no body/query/path parameters. Success 200:
~~~json
{
    "status": "success",
    "message": "Account retrieved successfully.",
    "data": {
        "user": {
            "id": "01a15951-b46d-714b-9e37-3a1e88dd9e3e",
            "full_name": "Lenna Wilderman",
            "phone_code": "966",
            "phone": "582630702",
            "email": "auth-admin@example.test",
            "image": "https://ui-avatars.com/api/?name=U&background=dbeafe&color=1d4ed8&size=200",
            "locale": "ar"
        },
        "profile": {
            "role": {
                "id": "01a15951-b453-7328-b8bb-5e48015c0812",
                "name": "Sample-utjk48029760"
            },
            "permissions": {
                "Customers": [
                    {
                        "id": "01a19762-f589-700d-ae74-6cf545f3a9db",
                        "title": "Sample-pmri04676087",
                        "back_route_name": "users.view",
                        "front_route_name": "users/show",
                        "icon": ""
                    }
                ],
                "Administrators & permissions": [
                    {
                        "id": "01a19762-f586-707d-a4ef-8c34c6d2ed75",
                        "title": "Sample-suan14793041",
                        "back_route_name": "roles.index",
                        "front_route_name": "roles/show-all",
                        "icon": ""
                    }
                ]
            },
            "user_type": {
                "value": "super_admin",
                "label": "Super admin"
            }
        }
    }
}
~~~

| Field | Type | Nullable | Meaning |
|---|---|---|---|
| user.id | UUID string | no | Account public ID |
| user.full_name | string | no | Display name |
| user.email | string | yes | Login address |
| user.phone_code / phone | string | yes | Optional contact number |
| user.image | absolute URL | yes | Media or placeholder |
| user.locale | string | no | Stored preference |
| profile.role | {id: UUID, name: string} | yes | Localized role |
| profile.permissions | Record<string, {id, title, back_route_name, front_route_name, icon}[]> | no | Granted routes grouped by localized section title; test a grant with `back_route_name` (`roles.index`) |
| profile.user_type | {value,label} | no | super_admin/supervisor |

## 5. Forms (create / edit)

POST /api/v1/admin/auth/login is public, throttled: 5 per lowercased email and
30 per IP per minute (config/auth_limits.php). Headers: Accept/Content-Type
application/json, Accept-Language ar default or en.

| Field | Required | Validation | Notes |
|---|---|---|---|
| login | yes | email, max 255 | Existing field name retained, email value only |
| password | yes | string, max 4096 | Admin accounts only |

~~~json
{
  "login": "auth-admin@example.test",
  "password": "password"
}
~~~
~~~json
{
    "status": "success",
    "message": "You logged in successfully.",
    "data": {
        "access_token": "[redacted JWT]",
        "token_type": "bearer",
        "expires_in": 43200,
        "user": {
            "id": "01a15d7a-b1b8-706d-a80a-6345f7c1dd96",
            "full_name": "Clement Rempel",
            "phone_code": "966",
            "phone": "506269176",
            "email": "auth-admin@example.test",
            "image": "https://ui-avatars.com/api/?name=U&background=dbeafe&color=1d4ed8&size=200",
            "locale": "ar"
        },
        "profile": {
            "role": {
                "id": "01a15d7a-b19a-7322-af5b-ca33d2bd7907",
                "name": "Sample-asct33931138"
            },
            "permissions": {
                "Customers": [
                    {
                        "id": "01a19762-f589-700d-ae74-6cf545f3a9db",
                        "title": "Sample-pmri04676087",
                        "back_route_name": "users.view",
                        "front_route_name": "users/show",
                        "icon": ""
                    }
                ],
                "Administrators & permissions": [
                    {
                        "id": "01a19762-f586-707d-a4ef-8c34c6d2ed75",
                        "title": "Sample-suan14793041",
                        "back_route_name": "roles.index",
                        "front_route_name": "roles/show-all",
                        "icon": ""
                    }
                ]
            },
            "user_type": {
                "value": "supervisor",
                "label": "Supervisor"
            }
        }
    }
}
~~~

PUT profile retains the existing request and update-response contract:
full_name optional string max100; email optional email unique ignoring self;
phone optional string max20; phone_code nullable string max5; birth_date
nullable date; locale nullable string max10; image nullable media hash validated
by the existing Media module. It updates the authenticated account only.

POST profile/change-password accepts current_password required string;
password required string min8, confirmed with password_confirmation.
Incorrect current password returns 422; success data is null.
No forgot/reset-password endpoint is added for admins.

## 6. Actions (non-CRUD)

### 6.1 Refresh — POST /api/v1/admin/auth/refresh

Admin bearer token — **may be expired** — route admin.auth.refresh, empty body.
Refresh works for 7 days after the original login, not after the last refresh
(TD-29). The new token keeps app=admin and lasts 12 hours (`expires_in` 43200);
the old token is revoked. Save the replacement token atomically. Outside the
window, or with a revoked token, the answer is 401 UNAUTHENTICATED; a token
from another app is 403. Response after the access token expired (real capture):

~~~json
{
    "status": "success",
    "message": "Token refreshed.",
    "data": {
        "access_token": "[redacted JWT]",
        "token_type": "bearer",
        "expires_in": 43200,
        "user": {
            "id": "01a15d7a-b26d-70d3-a619-64d63f47f953",
            "full_name": "Casper Bradtke",
            "phone_code": "966",
            "phone": "564696818",
            "email": "estehr@example.org",
            "image": "https://ui-avatars.com/api/?name=U&background=dbeafe&color=1d4ed8&size=200",
            "locale": "ar"
        },
        "profile": {
            "role": null,
            "permissions": [],
            "user_type": {
                "value": "super_admin",
                "label": "Super admin"
            }
        }
    }
}
~~~

### 6.2 My permissions — GET /api/v1/admin/auth/my-permissions

Admin bearer token; route admin.auth.my-permissions; no body, query or path
parameters. Exempt from section RBAC like the other `auth/*` routes, so a
supervisor with no grants still gets 200 with empty lists. Grants come from
`PermissionResolver`, the same source `rbac.auto` checks: role ∪ active
descendant roles ∪ direct grants. A super admin is allowed every admin route
implicitly, so she gets `is_super_admin: true` together with the **full**
`permissions` dictionary and every section; checking the flag first is still the
cheapest test (TD-53).

Supervisor with the `customers.view` cell plus a direct `admin.drivers.show`
grant (real capture):

~~~json
{
    "status": "success",
    "message": "Permissions retrieved successfully.",
    "data": {
        "is_super_admin": false,
        "role": {
            "id": "01a17c27-7609-710b-8bb4-031fbbaa6c0b",
            "name": "Sample-kyzd44508999"
        },
        "permissions": {
            "Customers": [
                {
                    "id": "01a19762-f96b-721d-9da6-f457e7b9254a",
                    "title": "Customers · View (users.index)",
                    "back_route_name": "users.index",
                    "front_route_name": "users/show-all",
                    "icon": ""
                },
                {
                    "id": "01a19762-f96f-7202-a599-e3ac4d2f64d7",
                    "title": "Customers · View (users.view)",
                    "back_route_name": "users.view",
                    "front_route_name": "users/show",
                    "icon": ""
                },
                {
                    "id": "01a19762-f971-736b-999a-d94798f2acba",
                    "title": "Customers · View (users.wallet-transactions)",
                    "back_route_name": "users.wallet-transactions",
                    "front_route_name": "users/wallet-transactions",
                    "icon": ""
                }
            ],
            "Drivers": [
                {
                    "id": "01a19762-f88c-71c5-8916-4a717fdb2a16",
                    "title": "Drivers · View (drivers.show)",
                    "back_route_name": "drivers.show",
                    "front_route_name": "drivers/show",
                    "icon": ""
                }
            ]
        },
        "sections": [
            {
                "key": "customers",
                "label": "Customers",
                "actions": [
                    "view"
                ]
            },
            {
                "key": "drivers",
                "label": "Drivers",
                "actions": [
                    "view"
                ]
            }
        ]
    }
}
~~~

| Field | Type | Nullable | Meaning |
|---|---|---|---|
| is_super_admin | boolean | no | `true` = every route allowed; the lists below hold the full catalog — show everything |
| role | {id: UUID, name: string} | yes | Localized role; `null` for a super admin without a role |
| permissions | Record<string, {id, title, back_route_name, front_route_name, icon}[]> | no | Granted routes grouped by localized section title, same shape as `GET permissions`; `back_route_name` is the route name without `admin.` |
| sections | {key, label, actions[]}[] | no | Sections with at least one granted route, catalog order; `actions` ⊂ view/create/update/delete/approve |

A section action is listed when **any** route of that cell is granted, so a
partial direct grant still shows the menu entry. Guard each button with
`permissions[resource]?.includes(action)`. Section keys and labels:
[admins-permissions.md](admins-permissions.md).

Super admin (real capture):

~~~json
{
    "status": "success",
    "message": "Permissions retrieved successfully.",
    "data": {
        "is_super_admin": true,
        "role": null,
        "permissions": [],
        "sections": []
    }
}
~~~

Errors: 401 UNAUTHENTICATED (no/expired token); 403 PERMISSION_DENIED (token
from another app).

### 6.3 Logout — POST /api/v1/admin/auth/logout

Admin bearer token; route admin.auth.logout, empty body. Success 200, revokes
current token, data=null.

~~~json
{
    "status": "success",
    "message": "You logged out successfully.",
    "data": null
}
~~~

### Errors

| HTTP | data.error_code | Trigger | Dashboard behavior |
|---|---|---|---|
| 422 | — | Request shape invalid | Correct form |
| 422 | INVALID_CREDENTIALS | Unknown email, wrong password, non-admin credentials | Same generic message; no distinction |
| 403 | ACCOUNT_INACTIVE | Banned/disabled/inactive account or role | Contact administrator |
| 403 | PERMISSION_DENIED | Other-app/claimless token or non-admin type | Admin sign-in |
| 401 | UNAUTHENTICATED | Missing/expired/invalid/revoked JWT | Call auth/refresh once; clear the session if that fails too |
| 500 | INTERNAL_ERROR | Unexpected failure; `data.correlation_id` set | Generic error; report the correlation id |
| 429 | RATE_LIMITED | Named email/IP limit | Respect Retry-After |

All errors use {status,message,data}; branch on error_code. No token on failure.

Wrong credentials (real capture):

~~~json
{
    "status": "fail",
    "message": "Invalid email or password.",
    "data": {
        "error_code": "INVALID_CREDENTIALS"
    }
}
~~~

Messages follow `Accept-Language` (Arabic when missing). A 422 message is the
first error plus a translated count: `حقل البريد الإلكتروني مطلوب. (و 1 خطأ آخر)` /
`The Email field is required. (and 1 more error)`.

## 7. Statuses and enums

| Enum | value | English label | Access |
|---|---|---|---|
| user_type | super_admin | Super admin | Self auth/profile and assigned resource grants |
| user_type | supervisor | Supervisor | Self auth/profile and assigned resource grants |

Labels localize to Arabic/English. Admin has no review status. Account and role
must be active; bans and administrative disablement prevent login and access.

## Data model

| Table | Read/write | Use |
|---|---|---|
| users | read/write | Credential hash, status, last_login_at |
| roles / role_translations | read | Assigned role mini |
| permissions / permission_role / permission_user | read | Role ancestry and direct grants |

## 8. TypeScript types

~~~ts
export interface EnumValue<V extends string> { value: V; label: string; }
export interface AuthUser {
  id: string; full_name: string; email: string | null;
  phone_code: string | null; phone: string | null;
  image: string | null; locale: string;
}
export interface AdminAccount {
  user: AuthUser;
  profile: {
    role: {id: string; name: string} | null;
    permissions: string[];
    user_type: EnumValue<'super_admin' | 'supervisor'>;
  };
}
export interface AdminSession extends AdminAccount {
  access_token: string; token_type: 'bearer'; expires_in: number;
}
export interface AdminPermissions {
  is_super_admin: boolean;
  role: {id: string; name: string} | null;
  permissions: string[];
  sections: {key: string; label: string; actions: Array<'view' | 'create' | 'update' | 'delete' | 'approve'>}[];
}
export interface AdminLogin { login: string; password: string; }
~~~

## 9. Business rules enforced by the backend

| Source | Enforcement | Dashboard result |
|---|---|---|
| ADMIN-001 / DEC-15 | Admin email/password only | Credentials form |
| TD-01 | Admin type and signed admin app claim | Other app tokens denied 403 |
| BR-29 | rbac.auto on every resource route via PermissionResolver; auth/profile explicitly exempt | Hide sections using route grants |
| TD-19/20 | Account and role UUIDs | IDs remain strings |
| DEC-17 | Arabic default, English supported | Translated messages/labels |

## 10. Notifications triggered

N/A. Admin auth adds no SMS/email/push/socket event.

## 11. Integration checklist

- [ ] Send email under login, using admin prefix
- [ ] Store access_token; rotate after refresh
- [ ] On 401, refresh once with the stored (possibly expired) token before signing out
- [ ] Show the generic message for `INVALID_CREDENTIALS`
- [ ] Parse user/profile; IDs remain strings
- [ ] Hide business actions by profile.permissions (or auth/my-permissions)
- [ ] Build the sidebar from auth/my-permissions `sections`; re-fetch after a 403 PERMISSION_DENIED
- [ ] When `is_super_admin` is true, show every menu and action (the lists hold the full catalog)
- [ ] Keep self auth/profile available without section permissions
- [ ] Mirror validation; show generic credential errors
- [ ] Handle 401/403/422/429; respect rate-limit headers
- [ ] Verify Arabic RTL and English

## 12. Acceptance scenarios

| AC ID | Scenario | Expected |
|---|---|---|
| AC-ADMIN-001 | Super-admin/supervisor credentials | Admin JWT, role and flat grants |
| AC-ADMIN-001 | Wrong password, unknown or non-admin identity | Identical generic 422, no token |
| AC-ADMIN-001 | Other-app token | 403 PERMISSION_DENIED |
| AC-ADMIN-001 | Refresh/logout | Claim retained, old/logout tokens revoked |
| AC-ADMIN-001 | Banned/disabled identity | 403 ACCOUNT_INACTIVE |
| AC-ADMIN-001 | Login limit exceeded | 429 envelope |
| AC-ADMIN-001 | No section grants, production RBAC mode | Auth/profile still work |
| AC-ADMIN-024 | Supervisor reads own grants | Role + direct routes as a resource → actions dictionary and by section; partial cell still listed |
| AC-ADMIN-024 | Super admin reads own grants | `is_super_admin: true`, the full `permissions` dictionary and every section |
| AC-ADMIN-024 | Supervisor without grants | 200 with empty `permissions` and `sections` |
| AC-ADMIN-024 | Guest / other-app token | 401 / 403 |

Evidence: tests/Feature/Api/V1/Admin/AuthControllerTest.php, MyPermissionsTest.php and CrossAppAuthTest.php.

## 13. Open decisions and known limitations

| ID | Impact | Current behavior |
|---|---|---|
| ADMIN-001 | Admin forgot-password unspecified | No endpoint; product decision remains open |
| TD-23 | Compatibility | login retains email value; new dedicated response |
| Existing RBAC | Environment bypass and resolver/seed alignment | Closed in 1.0.1: bypass removed, catalog synced from routes (TD-27, TD-28) |
| TD-29 | Admin session length is a working value | Access 12 hours, refresh window 7 days; final values pending product owner (`AUTH_ADMIN_ACCESS_TTL`, `AUTH_ADMIN_REFRESH_TTL`) |

No admin prototype exists. Tests prove backend integration; no React browser
acceptance or deployment is claimed.

## Postman examples (2026-11-08)

Real responses captured by `tests/Feature/Api/V1/Admin/AdminAccessExamplesTest.php`; `login` and `device_token` are described in the collection.

- `admin.auth.login` › "200 Login", "200 Login Succeeded", "422 Invalid Credentials", "422 Login Missing Fields", "422 Login Wrong Password", "429 Too many login attempts"
- `admin.auth.logout` › "200 Logout", "200 Logout Succeeded", "422 Logout Device Token Too Long"
- `admin.profile.change-password` › "200 Profile Password Changed", "422 Profile Password Confirmation Missing"
- `admin.profile.update` › "200 Profile Updated", "422 Profile Update Invalid Email"
- `admin.profile.view` › "200 Profile Viewed"
