# Admins (Manage)

**Portal:** Manage · **Routes:** `manage.people.admins.*` · **Nav:** Others → **System → Admins** (Operations suite; one entry fronting Admins / Roles / Devices / AI Requests / Notifications / System Health)

## What it does
Manages **admin-portal accounts** — the people who run the back office. Admins can list/search/filter admins, create/edit them (name, email, phone, role + Group Super Admin toggle, status, group/team, employee no., notes), ban/unban, delete, and view an admin's cached **Zoom recordings**.

> An admin is just a `User` with a manage-portal role. Account/auth lives on `users`, the person on `user_profiles`, and **admin-only data lives on the dedicated `admins` table** (so the shared `User` model — which also serves members — never carries admin-only columns). **Positions were removed entirely** (2026-07-09, client request; migration `2026_07_09_100001_drop_positions` drops the table + column) — an admin's job is expressed purely through their role. See [Roles](/docs/modules_handbook/manage/people/roles/readMe.md) for permissions.

## How it works
- The list is `User` scoped to `Role::manageRoles()`. Search hits the account email + linked profile name; filters cover status, role and a created-date range (`AdminQueryRequest`).
- Writes go through `UserRepository` inside `DB::transaction` — one path creates/updates the `user` + `user_profile` + `admin` records and syncs the role (plus the Group Super Admin add-on flag). The controller maps input explicitly and returns `Inertia::render` / `back()`.
- **Every admin also gets a Lead facet** (their own portal identity, flagged `leads.is_staff`) so they can use — and **write** — the member-portal features — created by `UserRepository::ensureStaffLead` on the create / promote / `ensureAdmin` paths, and backfilled for existing admins. An admin is a **full lead**: visible in the Leads list, just **badged "Admin"** (`is_staff` is a label, not a filter). See the [identity foundation](/docs/modules_handbook/shared/user-lead-admin-login-register-merge/readMe.md).
- **Create resolves identity first, so it never duplicates a person.** `store()` matches the typed email/phone (tolerantly, via `LeadRepository::resolveUserByIdentity`) **before** creating anything: already an admin → rejected; an **existing customer/lead** → the modal asks *"make them an admin too?"* and, on confirm (`promote_confirmed`), `UserRepository::promoteToAdmin` attaches the admin hat to **that same user** (keeping their account, profile and lead data, flipping the lead to `is_staff`) — no second account, so the old `user_profiles.phone` unique collision can't happen. A brand-new person is created as before. This replaced the blind `unique:users,email` rule: an email/phone that already belongs to an admin is now rejected in `StoreRequest`/`UpdateRequest` validation (`withValidator`), while the promote-a-lead path stays in `store()` (a Form Request can't express the *"needs confirmation"* outcome).
- **Edit guards the phone against another person's number.** `update()` blocks (with a clear message, not a 500) when the typed phone belongs to a *different* user — combining two accounts is a merge, which isn't supported here.
- **Create** defaults the password to the email when left blank (mirrors the seeded accounts); **edit** keeps the current password unless a new one is typed. **Promote does NOT get the create default**: a promoted lead's user keeps its unknowable `Str::random(40)` password (leads sign in by OTP, never password) unless a password is typed on the promote form — so type one, or the new admin cannot password-sign-in. Both "typed password takes effect" paths (promote + edit) were silently broken until 2026-08-11 — `promoteToAdmin` / `update` dropped `user.password` from their `data_only` whitelists while the form, validation and `mapInput` all accepted it (the classic whitelist trap); pinned by the three password tests in `AdminLeadFacetTest`.
- **Zoom is account-level, so an admin holds no Zoom credentials of their own.** The whole integration runs on one **Server-to-Server OAuth** row (`zoom_server_credentials`, resolved via `ZoomCredentialProvider`), and an admin's Zoom capability is derived, not stored: `transform()` sets `is_zoom_user` by checking their login **email** against the connected account's user list (`ZoomServerService::accountUserEmails()`, cached). The Show page only renders a **Zoom** tab when that flag is true; it lists the admin's **cached** cloud recordings (`ZoomRecording` filtered to real 1:1 meetings, so a webinar recording copied onto the host never leaks in as a titleless row) and offers a **self-only** refresh via `AdminZoomRecordingsController@sync`. *This replaced the per-admin `admin_zoom_credentials` token store + self-service connect/disconnect OAuth flow, dropped 2026-06-23 (`2026_06_23_000006_drop_admin_zoom_credentials_table`) — one account app beats N admin tokens to re-authorise.* See [Zoom](/docs/modules_handbook/manage/zoom/readMe.md).
- **Ban/unban** flips the account `status`; **delete** removes the user, and the `User` deleting hook cascades to the profile, admin record, **their Lead(s) — deleted one by one so each lead's own hook cascades its children** (AI credentials/credit, subscriptions) — addresses and role assignments.
- Authorization is permission-based on the route group: `view-admins` for index/show, `manage-admins` for every write (store/update/ban/unban/destroy). Super Admins pass via the `Gate::before` bypass; Group Super Admins are additionally scoped to their own group inside the controller. The Zoom recording sync route sits **outside** the `manage-admins` group on purpose — it is self-service, gated on self + `is_zoom_user` in the controller.

## Related files

**Backend — Models**
- [src/People/User.php](/src/People/User.php) — account + `admin()` relation (admin-only data kept off this shared model).
- [src/People/Admin.php](/src/People/Admin.php) — admin-specific data (`group_id`/`team_id`, `employee_no`, `notes`); `displayName()`; `options()`.
- [src/People/UserProfile.php](/src/People/UserProfile.php) — `full_name`, `phone`.
- [src/Zoom/ZoomServerCredential.php](/src/Zoom/ZoomServerCredential.php) — the single account-level S2S credential row (read via [`ZoomCredentialProvider`](/src/Zoom/Services/ZoomCredentialProvider.php)). *There is no per-admin `ZoomCredential` model any more.*

**Backend — Repositories**
- [src/People/Repositories/UserRepository.php](/src/People/Repositories/UserRepository.php) — create/update (user + profile + admin), ban/unban, delete.

**Backend — Controller**
- [app/Http/Controllers/Manage/People/AdminsController.php](/app/Http/Controllers/Manage/People/AdminsController.php) — index/store/show/update/ban/unban/destroy; `transform()` derives `is_zoom_user` + the cached-recordings props.
- [app/Http/Controllers/Manage/People/AdminZoomRecordingsController.php](/app/Http/Controllers/Manage/People/AdminZoomRecordingsController.php) — self-only re-sync of the admin's own cloud recordings (30-day lookback) into the `zoom_recordings` cache.

**Backend — Form Requests**
- [app/Http/Requests/Manage/People/Admins/AdminQueryRequest.php](/app/Http/Requests/Manage/People/Admins/AdminQueryRequest.php) — search/status/role/date.
- [app/Http/Requests/Manage/People/Admins/StoreRequest.php](/app/Http/Requests/Manage/People/Admins/StoreRequest.php)
- [app/Http/Requests/Manage/People/Admins/UpdateRequest.php](/app/Http/Requests/Manage/People/Admins/UpdateRequest.php) — extends Store; ignores the current email; password optional.

**Frontend (Vue)**
- [resources/js/Pages/Manage/People/Admins/Index.vue](/resources/js/Pages/Manage/People/Admins/Index.vue) — list, filters, create/edit modal, ban, delete.
- [resources/js/Pages/Manage/People/Admins/Show.vue](/resources/js/Pages/Manage/People/Admins/Show.vue) — identity header + `ShowTabs` (Overview, plus **Zoom** only when `is_zoom_user`), ban/delete, and Edit reusing the index's modal.
- [resources/js/Pages/Manage/People/Admins/Partials/AdminForm.vue](/resources/js/Pages/Manage/People/Admins/Partials/AdminForm.vue) — shared form fields.
- [resources/js/Pages/Manage/People/Admins/Partials/AdminFormModal.vue](/resources/js/Pages/Manage/People/Admins/Partials/AdminFormModal.vue) — create/edit modal (used by both Index and Show).
- [resources/js/Pages/Manage/People/Admins/Partials/Tabs/SummaryTab.vue](/resources/js/Pages/Manage/People/Admins/Partials/Tabs/SummaryTab.vue) — the Summary tab body: three rows only — Role (with a **GSA** badge when `is_group_super_admin` and the role is not already Group Super Admin), Employee no., Created. Email/status live in the identity header card above the tabs; group/team and notes exist only in `AdminFormModal`, not on Show.
- [resources/js/Components/ZoomRecordingsPanel.vue](/resources/js/Components/ZoomRecordingsPanel.vue) — the shared cached-recordings list mounted in the Zoom tab (`can-refresh` = self + Zoom account user).
- [resources/js/Layouts/ManageLayout.vue](/resources/js/Layouts/ManageLayout.vue) — sidebar nav entry.

**Migrations**
- [database/migrations/2026_06_03_000002_create_admins_table.php](/database/migrations/2026_06_03_000002_create_admins_table.php)
- [database/migrations/2026_06_03_000003_create_admin_zoom_credentials_table.php](/database/migrations/2026_06_03_000003_create_admin_zoom_credentials_table.php) — historical only; the table it created no longer exists.
- [database/migrations/2026_06_23_000006_drop_admin_zoom_credentials_table.php](/database/migrations/2026_06_23_000006_drop_admin_zoom_credentials_table.php) — drops `admin_zoom_credentials`; Zoom moved to account-level S2S (`zoom_server_credentials`), retiring per-admin connect/disconnect.
- [database/migrations/2026_07_09_100001_drop_positions.php](/database/migrations/2026_07_09_100001_drop_positions.php) — drops `positions` + `admins.position_id` (feature removed).

**Seeder**
- [database/seeds/PeopleSeeder.php](/database/seeds/PeopleSeeder.php) — seeds super-admins + admins (user + profile + admin record).

**Routes**
- [routes/web.php](/routes/web.php) — the `manage.people.admins.*` group (`view-admins` outer, `manage-admins` on the writes, plus the self-service `admins.zoom-recordings.sync`).
