# Roles and panels

Two panels, two role systems, one source of truth: `internal/api/roles.go`.

| Panel | Who | URL | Auth | Roles |
|---|---|---|---|---|
| Admin panel | our own team (staff) | `/admin/` | `POST /v1/staff/login` → Bearer session (scope `staff`), or `X-Admin-Key` (= superadmin, bootstrap only) | superadmin, ops, finance, support, sales, viewer |
| Management panel | customer teams | `/portal/` | `POST /v1/login` (account owner **or** team member email) → Bearer session (scope `owner`, claims `role`, `member_id`) | owner, admin, developer, finance, support, viewer |

## Staff roles → permissions

| Permission | superadmin | ops | finance | support | sales | viewer |
|---|---|---|---|---|---|---|
| stats.read | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| accounts.read | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| accounts.write (approve live, disable, reset 2FA) | ✓ | | | ✓ | | |
| apps.read | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| apps.write (create, settings, rotate, disable, delete) | ✓ | ✓ | | | | |
| billing.read (invoices) | ✓ | | ✓ | | ✓ | ✓ |
| billing.write (pricing/quota fields only) | ✓ | | ✓ | | | |
| audit.read | ✓ | ✓ | ✓ | ✓ | | ✓ |
| alerts.read | ✓ | ✓ | | ✓ | | ✓ |
| alerts.write (test notifiers, incidents) | ✓ | ✓ | | | | |
| webhooks.read | ✓ | ✓ | | ✓ | | ✓ |
| webhooks.write (replay) | ✓ | ✓ | | ✓ | | |
| consultations.read | ✓ | ✓ | | ✓ | | ✓ |
| staff.read | ✓ | | | | | ✓ |
| staff.write | ✓ | | | | | |

Org mapping: CEO + Tech Lead = superadmin; Platform/SDK Engineers = ops; Finance & Ops contractor = finance; Developer Success = support; Sales/BD = sales; Growth Lead = viewer.

## Staff API (all under the admin mux; `requireAdmin` enforces the table in roles.go)

- `POST /v1/staff/login` `{email, password}` → `200 {staff, role, permissions, session_token, session_expires_at}` (12 h). Public route. 401 on bad credentials or disabled staff. Rate-limited like `/v1/login`.
- `GET /v1/admin/me` → `{actor: "staff"|"admin_key", staff?, role, permissions[]}`.
- `GET /v1/admin/roles` → `{staff_roles:[{name, description, permissions[]}], member_roles:[...]}` (for the UIs).
- `POST /v1/admin/me/password` `{current_password, new_password}` (staff sessions only; 400 for admin key).
- `GET /v1/admin/staff` → `{staff:[...]}` (no hashes).
- `POST /v1/admin/staff` `{email, name, role, password}` → `201 {staff}`. First staff may be created with the admin key (bootstrap). Password ≥ 8 chars. 409 duplicate email. 400 invalid role.
- `PATCH /v1/admin/staff/{id}` `{name?, role?, disabled?, password?}` → `{staff}`. Rules: cannot change your own role or disable yourself; cannot remove/disable/demote the last enabled superadmin.
- `DELETE /v1/admin/staff/{id}` → 204 (same last-superadmin rule; cannot delete yourself).
- `GET /v1/admin/accounts/{id}/members` → `{members:[...]}` (staff view of a customer's team).
- `GET /v1/admin/apps/{id}/consultations?status=&limit=` → `{consultations:[...]}` (support view).
- Existing admin routes keep their paths; permissions per roles.go. `PATCH /v1/admin/apps/{id}` by an actor with only `billing.write` may change `settings.monthly_minutes`, `included_minutes`, `price_per_minute`, `currency` and nothing else (403 otherwise).
- 403 body: `{"error":{"message":"forbidden: requires <perm>", "code":"forbidden"}}`.
- Audit: staff actions are recorded with `actor_type: "staff"`, `actor_id: <staff id>`; admin-key actions stay `admin`.

## Member roles → permissions (customer teams)

| Permission | owner | admin | developer | finance | support | viewer |
|---|---|---|---|---|---|---|
| apps.read | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| apps.write (create, update, rotate key, delete) | ✓ | ✓ | ✓ | | | |
| billing.read (usage, invoices) | ✓ | ✓ | | ✓ | ✓ | ✓ |
| webhooks.read | ✓ | ✓ | ✓ | | ✓ | ✓ |
| webhooks.write (rotate secret, test, replay) | ✓ | ✓ | ✓ | | ✓ | |
| security.write (own 2FA) | ✓ | ✓ | ✓ | ✓ | ✓ | |
| team.read | ✓ | ✓ | | | | ✓ |
| team.write | ✓ | ✓ | | | | |
| audit.read | ✓ | ✓ | | ✓ | | |

Rules: only an `owner` may create/promote/demote/remove another `owner`; `admin` may manage every other role. Nobody may change their own role or remove themselves. The account's own login is always `owner` and cannot be removed. API keys are shown only to roles with `apps.write` (the list endpoint hides `key_prefix` for others).

## Team API (owner mux; `requireOwner` enforces the table in roles.go)

- `POST /v1/login` now also accepts a team member's email/password → same response shape plus `role`, `member`, `permissions[]`; account login returns `role: "owner"`. 2FA applies only to the account login (members have none yet).
- `GET /v1/account` → adds `role`, `permissions[]`, `member?` to the existing `{account, apps}`.
- `GET /v1/account/members` → `{members:[{id,email,name,role,disabled,created_at,last_login_at}], account_owner:{email}}`.
- `POST /v1/account/members` `{email, name, role, password?}` → `201 {member, temporary_password?}`. If no password given, one is generated and returned once (no email service yet).
- `PATCH /v1/account/members/{id}` `{name?, role?, disabled?}` → `{member}`.
- `DELETE /v1/account/members/{id}` → 204.
- `POST /v1/account/members/{id}/reset-password` → `{temporary_password}`.
- `POST /v1/account/me/password` `{current_password, new_password}` (account login or member).
- 403 body as above with `code: "forbidden"`.
- Audit: member actions `actor_type: "member"`, `actor_id: <member id>`, and the audit `Account` filter still matches the account.

## Admin panel UI (`web/admin/index.html`)

Login (email + password; link "use admin key" for bootstrap which then prompts to create the first superadmin). Left nav built from `permissions[]`: Overview (stats.read), Accounts (accounts.read), Applications (apps.read), Billing (billing.read), Consultations (consultations.read), Webhooks (webhooks.read), Alerts & incidents (alerts.read), Audit log (audit.read), Staff (staff.read), My account (everyone). Buttons for write actions render only when the permission is held; the server still enforces.

## Management panel UI (`web/portal/`)

`index.html` reads `role`/`permissions` from `GET /v1/account` and hides keys/actions accordingly; new `team.html` (Team page) for team.read/team.write; `webhooks.html` honours webhooks.read/write; nav shows "Team" only with team.read.
