# Roles & Permissions (Manage)

**Portal:** Manage · **Routes:** `manage.people.roles.*` · **Nav:** the pinned **Setting** entry at the sidebar footer (Operations suite only) → **Roles** tab of [`Components/SettingTabs.vue`](/resources/js/Components/SettingTabs.vue) (Admins / Roles / Merge Requests / Payment Gateways / Markets / Devices / Closing Modes / Lead Distribution / AI Requests / Notifications / AI E-Learning / System Health). Replaces the old "Others → System" sidebar group — configuration is wayfinding, not work, so it was pinned out of the scrolling nav (GUIDELINES §15).

## What it does
Shows the application **roles** and lets an admin choose **which permissions each role grants**. Every Manage module carries a `view-{module}` / `manage-{module}` pair, and three **scoped resources** — Leads, connected Meta accounts, connected WhatsApp channels — carry mutually exclusive view **levels** (`-all` / `-group` / `-team` / `-own`) plus an independent Manage toggle. Two modules carry a **second, narrower view surface** instead of a level: WhatsApp (`view-whatsapp` = inbox only vs `view-whatsapp-settings`) and — since 2026-08-11 — **Events** (`view-events` = Full View, every hub/session tab, vs `view-events-reports` = Reports Only: the funnel Dashboard, the funnels list, a funnel's **Sessions** tab and a session's **Summary / Ads** tabs — plus, on a VSL funnel, its own **Summary / Ads**; the full-view-only reads — Slots/series, Weekly, Group status, Attendance export, Check-in — pin `view-events` on their own routes, and both Show pages filter their tab strips on it). ⚠️ **A third axis crosses that one: PERSON ROSTERS follow lead visibility, not the events permission** (2026-08-12) — the session **Registrations** tab, a VSL funnel's **Leads** tab and the buyer names inside Summary's *What sold* all gate on `Permission::leadLevels()`, because they name customers (phone, WhatsApp, the AI occupation/income guesses) more richly than the Leads index does. A role barred from `/manage/leads` must not read the same people one page over. ⚠️ **Gate the PROP, not the tab** — the same sweep found three siblings shipping past their own consumers' gates (the session `webinar` prop, whose `activity_feed` names every attendee; `vslCallers`, which carries staff email + phone; `projectOptions`, read only by a manage-only modal), and **deleted** the roster's dormant `registrations/{id}/insight` endpoint outright: it had lost its UI in August but kept answering with a person's occupation / income guesses to anyone the group admits. Both splits register their manage→view implication in `Permission::impliedViews()` (a group with two view entries falls outside the generic manage-implies-view rule). **Super Admin** is locked — it always holds every permission (enforced by a `Gate::before` bypass). An **"Add sales roles"** button seeds the common agency roles (`group-super-admin`, `sales-leader`, `sales-agent`) with default grants. **Deletion**: only the protected system roles (`super-admin`, `group-super-admin`, `member`, `non-member` — `Role::isProtected()`) are undeletable; standard sales roles, custom roles and legacy roles (`admin`, the old new-project/subsales pairs on existing installs) can be deleted once no account holds them. The permission editor is grouped into sidebar-shaped sections (`RolesController::permissionSections()`).

**Sales Execution** (Sales & Marketing box, a single Yes toggle — `Permission::SALES_EXECUTION`, `sales-execution`) is a **capability flag, not a page gate**: it marks a role's holders as *sales staff*. Only holders are offered in the sales-person pickers — the shared assignable-staff pool (`App\Http\Controllers\Concerns\ResolvesAssignableManagers`: lead account-manager / engagement stage owners and every consumer of it) and the booking commission-split agent select — and only holders appear on the **Agents** productivity page (`/manage/agents`). It grants access to nothing. Seeded ON for the common sales roles (`SeedCommonRolesAction` `$shared`); switch it OFF on roles that are not sales staff (ops, finance) to keep them out of the pickers.


**Marketing** (2026-08-11) is a migration-seeded **custom role** (`marketing`, label column — deletable/renameable like any custom role, platform-assigned only): `view-events-reports` + `view-marketing`, nothing else. It is view-only **by construction** — every events/marketing write route carries its own `manage-*` middleware — and **person-free** as of 2026-08-12: it reads aggregates (spend, CPL, the waterfall, what sold and for how much) and never a customer list. ⚠️ The first cut had the VSL funnel exactly **inverted** — it withheld that funnel's Summary / Ads **reports** and left its **Leads roster** standing, which is the one thing this role must not read; the two report endpoints (`{id}/vsl-summary`, `{id}/vsl-ads`) now carry the group's own gate, like a session's `{id}/summary` + `{id}/ad-insights`. Seeded by `2026_08_11_500001_add_marketing_role_and_events_reports_permission.php` (which also grants the new permission to `super-admin`/`admin`, mirroring their full-catalogue seeds); pinned by `tests/Feature/Manage/MarketingRoleTest.php`. **The member world follows lead visibility** (2026-08-12): Portal Engagement and the AI Employee (routes + sidebar entries), the hub's per-module counts/chips, the Investor Portal hub card and the sidebar's "Switch to User Portal" all key on `viewLeadsAny()` — every sales role holds a level so nothing changed for them; Marketing holds none, so its sidebar is exactly **Funnels + Traffics**. (The member portal itself at `/dashboard` stays reachable by URL — it is the person's own account, only the affordances hide.) Likewise the **Meta integration side** (Campaign Mapping, the ads diagnostics page, the strip's Meta Ads tab) requires a Meta-account level on top of `view-marketing` — Marketing reads Campaign Performance on Traffics, never the integration config (see the [meta-ads marketing handbook](/docs/modules_handbook/manage/meta-ads/marketing/readMe.md)).

**Custom roles** (2026-07-07): admins with `manage-roles` can create their own roles from the page ("New role"). The internal `name` is slugged from the typed label (`roles.label` column stores the display label); a custom role starts with zero permissions, can be renamed (label only — the slug never changes), and can be deleted only while no account holds it. Built-in roles (those in `Role::ROLES`) can never be deleted. `Role::manageRoles()` returns built-ins **plus** every custom role, so custom-role holders pass `User::isManageUser()` and appear in the Admins module automatically; a Group Super Admin still can only hand out the fixed group-level whitelist (custom roles are platform-assigned only, since a custom matrix could carry cross-group visibility). Display labels resolve via `Role::labels()` / `$role->displayLabel()` (`ROLES` const → `label` column → headlined name).

**Group Super Admin is an ADD-ON, not a main role** (2026-07-08): a user holds one MAIN role (anything in `Role::mainManageRoles()` — which excludes `group-super-admin`) and may additionally hold the GSA role; spatie unions the permissions, so the main role decides module access and GSA adds group powers on top. `User::mainRole()`/`roleName()` display the main role. The Admins form grants GSA via an `is_group_super_admin` toggle, never the role dropdown. See the [Groups](/docs/modules_handbook/manage/people/groups/readMe.md) handbook for the full mechanics.

Permissions are **enforced**, not decorative: every `/manage` route group sits behind spatie `permission:` middleware (reads behind the view gate, writes behind the manage gate); the sidebar and action buttons hide what the user can't do; and lead-scoped visibility filters what scoped users see.

## How it works
- Roles & permissions are **spatie/laravel-permission** (`Src\Auth\Role`, `Src\Auth\Permission`). The page is a **role list (left) + permission editor (right)**: roles ordered by `Role::LEVELS` (custom roles slot in at `LEVEL_CUSTOM`), each showing its people count; the editor shows the grouped catalogue (`Permission::adminPermissions()` + `Permission::scopedGroups()`) with unsaved-change markers per role.
- Scoped groups render a **pick-one segmented control** (All / Group / Team / Own / No access); `RolesController@update` re-normalizes server-side (keeps only the highest submitted level, and auto-grants `view-x` whenever `manage-x` is granted).
- **Manage-portal entry**: the `admin` route middleware admits any role in `Role::manageRoles()` (via `User::isManageUser()`); module access inside is decided purely by `permission:` middleware. `Gate::before` in `AuthServiceProvider` short-circuits Super Admin.
- **Lead-scoped visibility** is resolved centrally by `Src\Auth\Support\LeadVisibility` (level → `all`/`group`/`team`/`own`/`none`) and **propagates** to WhatsApp inbox conversations (contact → platform user → lead) and Zoom meetings/recordings (visible lead, or lead-less + owned via `admins.id`). `AccountVisibility` does the same for Meta integrations and WhatsApp channels.
- **GROUP + TEAM LEVELS ARE LIVE** (see the [Groups](/docs/modules_handbook/manage/people/groups/readMe.md) module): group = the user's agency (`leads.group_id` stamp OR member-assigned), team = leads assigned to the user's team members; a group/team grant without the matching membership degrades to `own`.
- **Lead assignment** (`leads.assigned_admin_id` → `users.id`) powers the `own` level; it is also the lead's single **account manager**. Assign/Reassign lives on the Leads **Show** page identity header (`LeadsController@assign`, shared `Components/AssignLeadManagerModal.vue`) — the index row only offers Edit / Delete and its comment says so ("the row itself opens the Lead page; assignment lives in there"); the index's `assignableAdmins` prop now feeds only the CSV-import modal.
- `seed-defaults` posts to `SeedCommonRolesAction` — idempotent: a role's defaults apply only when it's newly created or holds zero permissions.
- Frontend gating: `HandleInertiaRequests` shares `auth.user.permissions` + `lead_scope`; the `usePermissions()` composable (`can` / `canAny` / `leadScope`) drives sidebar filtering in `ManageLayout.vue` and per-button gating.
- **Tests**: the base `Tests\TestCase` seeds the RBAC foundation (snapshot fast-path) for every `RefreshDatabase` test, so `admin`-role test users keep full manage access.
- **Deploy runbook** (same release as any permission change): `php artisan migrate && php artisan db:seed --class='\RolesSeeder' && php artisan permission:cache-reset`. The seeder grants the admin role everything except `manage-roles`, preserving pre-enforcement behavior.

## Related files

**Backend — Models & resolvers**
- [src/Auth/Role.php](/src/Auth/Role.php) — role names/labels/levels (`ROLES`, `LEVELS`), `manageRoles()` / `mainRoles()` / `customRoles()` / `isBuiltIn()` / `labels()` / `displayLabel()`.
- [src/Auth/Permission.php](/src/Auth/Permission.php) — full permission catalogue, `adminPermissions()`, `scopedGroups()`, `viewLeadsAny()` / `viewMetaAny()` / `viewWhatsappChannelsAny()` middleware strings.
- [src/Auth/Support/LeadVisibility.php](/src/Auth/Support/LeadVisibility.php) — lead scope resolver + propagation (conversations, zoom). **The group seam.**
- [src/Auth/Support/AccountVisibility.php](/src/Auth/Support/AccountVisibility.php) — Meta integrations + WhatsApp channels scope resolver.
- [src/People/User.php](/src/People/User.php) — `isManageUser()` (manage-portal entry check).
- [src/Lead/Lead.php](/src/Lead/Lead.php) — `assigned_admin_id` + `assignedAdmin()`.
- [src/Whatsapp/WhatsappChannel.php](/src/Whatsapp/WhatsappChannel.php) — `user_id` owner + `owner()`.

**Backend — Controllers & actions**
- [app/Http/Controllers/Manage/People/RolesController.php](/app/Http/Controllers/Manage/People/RolesController.php) — index / store (custom role) / update (normalize + syncPermissions + label rename) / destroy (custom, unassigned only) / seedDefaults.
- [app/Actions/SeedCommonRolesAction.php](/app/Actions/SeedCommonRolesAction.php) — the 4 common roles + default grants (single source of truth; used by seeder + button).
- [app/Http/Controllers/Manage/Leads/LeadsController.php](/app/Http/Controllers/Manage/Leads/LeadsController.php) — `assign()`, `assignableAdmins()`, `LeadVisibility` on index/export/search/counts + object checks.
- Scoped consumers: [InboxController](/app/Http/Controllers/Manage/Whatsapp/InboxController.php), [MessagesController](/app/Http/Controllers/Manage/Whatsapp/MessagesController.php), [ChannelsController](/app/Http/Controllers/Manage/Whatsapp/ChannelsController.php), [Zoom/RecordingsController](/app/Http/Controllers/Manage/Zoom/RecordingsController.php), [FacebookAuthController](/app/Http/Controllers/Manage/Facebook/FacebookAuthController.php), [LeadDiscussionController](/app/Http/Controllers/Manage/Leads/LeadDiscussionController.php), lead-scoped Zoom controllers.

**Backend — Middleware / providers**
- [app/Http/Middleware/EnsureUserIsAdmin.php](/app/Http/Middleware/EnsureUserIsAdmin.php) — manage-portal gate (`isManageUser()`).
- [app/Http/Kernel.php](/app/Http/Kernel.php) — `permission` / `role` middleware aliases (spatie).
- [app/Providers/AuthServiceProvider.php](/app/Providers/AuthServiceProvider.php) — `Gate::before` Super Admin bypass.
- [app/Http/Middleware/HandleInertiaRequests.php](/app/Http/Middleware/HandleInertiaRequests.php) — shares `permissions` + `lead_scope`.

**Backend — Form Requests**
- [app/Http/Requests/Manage/People/Roles/StoreRequest.php](/app/Http/Requests/Manage/People/Roles/StoreRequest.php) — custom role label; slugs it into a unique `name`.
- [app/Http/Requests/Manage/People/Roles/UpdateRequest.php](/app/Http/Requests/Manage/People/Roles/UpdateRequest.php) — validates permissions against the catalogue + optional label rename.
- [app/Http/Requests/Manage/Leads/AssignRequest.php](/app/Http/Requests/Manage/Leads/AssignRequest.php) — assignee uuid (null = unassign).

**Frontend (Vue)**
- [resources/js/Pages/Manage/People/Roles/Index.vue](/resources/js/Pages/Manage/People/Roles/Index.vue) — role list + editor (segmented scoped levels incl. Team, toggle pairs, people counts, unsaved markers), create/rename/delete custom roles, seed button; mounts `<SettingTabs />`.
- [resources/js/Components/SettingTabs.vue](/resources/js/Components/SettingTabs.vue) — the Setting strip that carries the Roles tab (gated on `view-roles`).
- [resources/js/composables/usePermissions.js](/resources/js/composables/usePermissions.js) — `can` / `canAny` / `leadScope`.
- [resources/js/Layouts/ManageLayout.vue](/resources/js/Layouts/ManageLayout.vue) — permission-filtered sidebar (`permission` / `permissionAny` keys + `filterNav`).
- [resources/js/Components/AssignLeadManagerModal.vue](/resources/js/Components/AssignLeadManagerModal.vue) — assign/reassign picker, mounted by the Leads **Show** page identity header.

**Migrations**
- [database/migrations/2026_06_02_104952_create_permission_tables.php](/database/migrations/2026_06_02_104952_create_permission_tables.php) — the spatie tables (shared).
- [database/migrations/2026_07_06_000001_add_assigned_admin_to_leads_table.php](/database/migrations/2026_07_06_000001_add_assigned_admin_to_leads_table.php) — `leads.assigned_admin_id`.
- [database/migrations/2026_07_06_000002_add_user_id_to_whatsapp_channels_table.php](/database/migrations/2026_07_06_000002_add_user_id_to_whatsapp_channels_table.php) — channel owner (backfilled from `created_by`).
- [database/migrations/2026_07_07_100001_add_label_to_roles_table.php](/database/migrations/2026_07_07_100001_add_label_to_roles_table.php) — `roles.label` for custom-role display names.

**Seeder**
- [database/seeds/RolesSeeder.php](/database/seeds/RolesSeeder.php) — roles + full catalogue + admin near-full grant + calls `SeedCommonRolesAction`.

**Routes**
- [routes/web.php](/routes/web.php) — `manage.people.roles.*` group + the portal-wide `permission:` gates on every module group.

**Tests**
- [tests/TestCase.php](/tests/TestCase.php) — RBAC foundation seeding (snapshot fast-path) for `RefreshDatabase` tests.
- [tests/Feature/Manage/ManageAccessTest.php](/tests/Feature/Manage/ManageAccessTest.php), [tests/Feature/Manage/People/RolesSeedDefaultsTest.php](/tests/Feature/Manage/People/RolesSeedDefaultsTest.php), [tests/Feature/Manage/People/RolesCustomRolesTest.php](/tests/Feature/Manage/People/RolesCustomRolesTest.php), [tests/Feature/Lead/LeadVisibilityTest.php](/tests/Feature/Lead/LeadVisibilityTest.php), [tests/Feature/Whatsapp/InboxVisibilityTest.php](/tests/Feature/Whatsapp/InboxVisibilityTest.php), [tests/Feature/Zoom/RecordingsVisibilityTest.php](/tests/Feature/Zoom/RecordingsVisibilityTest.php).
