---
title: Multi-company login & company selection — implementation plan
slug: multi-company-login-plan
status: Steps 1 + 2 + 3 ALL DONE (committed, deployed; native + Codex reviewed each, 0 critical). Step 3 = super-admin login-time company picker (BE b3fac95b1) + install-wide module activation via fan-out (FE 146856f). Feature complete.
owner: hazem
created: 2026-06-21
codex_review: "SOUND-WITH-GAPS — C1 (validation already done, only DB migration remains), M3 (re-sync branch pivot on switch), H2 (scoped behaviour follows switch — document), H3 (picker = hash-matched companies only), H1 (no Core forgot-password today = non-issue). All applied above."
related:
  - super-admin-owner
  - branches
---

# Multi-company login & company selection — plan

## 0. Agreed model (owner decision, 2026-06-21)
- **Regular users → separate accounts per company.** The same **email** may exist in more than one company as **independent user records** (own password, own role/permissions). Email is unique **within a company**, not globally. The same user can NOT be duplicated inside one company.
- **Super-admin (program owner) → ONE account** across all companies that **picks which company to operate in** (this is what fixes the live "owner on company 1, work on company 4" confusion).
- The picked company must be **remembered (sticky)** so it is pre-selected next time.
- **(Step 3 addition, 2026-06-21)** The **super-admin** also picks a company **at login** (not only via the topbar), and the owner's **module on/off is install-wide** (off in every company) — modules-only at this stage; per-company limits deferred.

## 1. Current state (grounded in the code — verify before each change)
- **Login:** `Modules/Core/app/Http/Controllers/AuthController.php::login` — `User::where('email', …)->first()` + `Hash::check(password)` → `issueTokenForRequest($user)` (Sanctum) → returns `UserResource` (with `company`, `branches`, `roles`, `permissions`) + `token`. **Email lookup assumes global uniqueness.**
- **Email uniqueness:** `database/migrations/0001_01_01_000000_create_users_table.php:17` → `$table->string('email')->unique();` — **GLOBAL unique.**
- **Company scoping is everywhere:** `auth()->user()->company_id` (and variants) appears **~1199 times** across BE controllers/services. ⇒ Any approach that introduces a separate "active company" different from `user.company_id` would be a ~1199-site refactor. **AVOID THAT.**
- **Super-admin** = role `super-admin` (`Gate::before` bypass; `AppServiceProvider`). FE detect = `isSuperAdminUser()` / `PermissionService.isSuperAdmin`. Live: `owner@moonerp.app` (company 1). Real work company = 4 (بلاك سيركل), users `admin@moonerp.com`/`hazem@gt4it.com`.
- **No "list all companies" endpoint** exists — `CompanyController` only has `show`/`update` (single, scoped). Need a super-admin-only company list.
- **FE:** login = `src/app/features/auth/login/login.component.ts`; topbar = `src/app/layout/topbar/topbar.component.ts`; auth token + cached user in `AuthService` (localStorage).
- **Users ↔ branches** = many-to-many pivot (`user->branches()`, `is_primary`); `primaryBranch()` used for branch cash routing (see [[branches]]).

---

## ✅ STEP 1 — Super-admin company switcher — **DONE** (BE `aff137e9b` / FE `8d2aeaf`, native+Codex APPROVE, 0 critical/high)
**Implemented:** `GET /core/owner/companies` (active only, localized names) + `PUT /core/owner/active-company` on `OwnerEntitlementController` (reuses `assertOwner`) — mutates the owner's `company_id` + re-syncs the branch pivot to the target company's main branch, **in one `DB::transaction`** (a half-write would mis-route LIS cash). FE: p-select switcher in the topbar (super-admin + >1 company), reload on switch, sticky. **7 Pest**, full MoonStack suite 73 green, deployed. Review fixes applied: DB transaction, reject INACTIVE companies, localized names, +401/+no-branches/+inactive tests, `takeUntilDestroyed` on the topbar store subs. **This fixes the live "owner on company 1 vs work on company 4" confusion** — the owner picks بلاك سيركل (company 4) and sees the real lab data.

**Why first:** solves the live confusion immediately, **zero touch** to the 1199 scoping sites, no users-table schema change.

**Key idea:** when the super-admin picks a company, **mutate that super-admin's own `user.company_id`** to the picked company. Every scoped query then resolves to it automatically. Sticky for free (it's persisted on the row).

### BE
1. `GET /api/core/owner/companies` (super-admin only via `assertOwner`) → `[{id, name, name_ar?}]` for ALL companies. (New method on `OwnerEntitlementController` or a small `OwnerCompanyController`.)
2. `PUT /api/core/owner/active-company` `{company_id}` (super-admin only) →
   - validate the company exists;
   - `$user->update(['company_id' => $companyId])`;
   - **⚠️ also re-sync the branch pivot (Codex M3):** the super-admin's `branch_user` rows still point at company-1 branches, and `primaryBranch()` (`app/Models/User.php:80-89`) checks the **pivot FIRST** → it would keep returning a company-1 branch after switching, **breaking LIS cash routing** in the new company. On switch: detach the owner's `branch_user` rows + attach the target company's main branch as `is_primary`.
   - return refreshed `UserResource`. **No token re-issue needed (Codex M1, confirmed):** the Sanctum token embeds no `company_id` and the User is re-queried from DB every request, so `company_id`/branch are always fresh.
3. Pest: non-super-admin → 403; super-admin switch → company_id updated + branch re-synced + reflected; invalid company → 422.

### FE
4. `OwnerCompanyService` — `listCompanies()` + `setActiveCompany(id)`.
5. **Company dropdown in the topbar**, visible only to super-admins (`perm.isSuperAdmin()`), showing all companies with the current selected. On change → `setActiveCompany` → refresh `/me` + reload the relevant data (simplest: full app reload or re-dispatch the core loads).
6. Sticky: the selected = the user's current `company_id` (already persisted server-side), so it is pre-selected on next login automatically; optionally cache in localStorage for instant paint.

### Caveats (document)
- The super-admin's `company_id` is now mutable; two concurrent sessions of the **same** super-admin account share one value (switching in one affects the other). Acceptable for a single owner.
- Switching gives the super-admin full access in the target company (it's a `Gate::before` bypass role) — intended.
- **All company-scoped behaviour follows the switch — intended (Codex H2):** after switching, `EnforceOwnerLimits` enforcement, `OwnerEntitlementController::updateLimits`/`payload` (honor limits + usage display), `AuthController` preferences, and every one of the ~1199 `company_id` sites operate against the **selected** company. So editing limits while "looking around" company 4 affects company 4's caps — make the active company very visible in the UI.

---

## ✅ STEP 2 — Same email across companies for REGULAR users — **DONE** (BE `1b9c44b1c` / FE `1eb8330`, code-reviewer APPROVE 0-critical; Codex pending namespaces)
**Implemented:** migration `2026_06_21_110000` (global `users.email` unique → composite `(company_id, email)` + `email` index) — **run on moonui**. `AuthController::login` rewritten: match email+password across companies → 0=401, exactly 1=token, >1=`{choose_company:[…]}` (only companies where the password matched AND both user+company are active — no enumeration, no 1-item picker), 2nd submit `company_id` signs in. `LoginRequest` + nullable `company_id`. **+`throttle:30,1`** on login/register. FE: `loginChooseCompany` action + `companyChoices` store state/selector + effect guard + a **company picker on the login screen** (re-submits with company_id). **Backward-compatible** — single account logs in directly (verified live: hazem→token). **10 Pest**, full MoonStack suite 83 green, deployed.
**Review fixes applied (code-reviewer, 2 HIGH):** login throttle; login requires BOTH user.is_active AND company.is_active; +tamper/mixed-active/inactive-company tests; explicit drop-index name.

**Codex Step-2 review (BE `82f1d7768`) — APPROVE, 0 critical/high.** 6-axis security pass: Axis 1 (enumeration/leak) SOUND · Axis 5 (FE effect guard ordering) SOUND. Findings applied: **MEDIUM (Axis 4)** route throttle is per-IP only → added a **per-EMAIL `RateLimiter`** bucket (10 failures/60s, counts failures only, clears on success → no self-lockout) + bilingual `too_many_attempts` 429; the N×bcrypt **timing oracle** (reveals how many companies an email is in, not which) accepted/deferred — low risk over HTTPS at this fleet size. **LOW (Axis 3)** migration `down()` will fail if duplicate emails exist across companies → documented (dedupe before prod rollback). **LOW (Axis 6)** no forgot-password/invite endpoint exists today; added a docblock guard — any future one MUST scope `User::where('email')` by `company_id` (+ `password_reset_tokens.email` is a global PK → needs a company_id column / custom broker). **LOW (Axis 2)** `company_id` on the 1st submit isn't exploitable (FE never sends it) → commented. +2 Pest (lockout→429, success clears). 12 file tests, suite 85 green.
**Known/deferred (Codex H1 from plan review):** no Core forgot-password endpoint today → non-issue; if added, scope `password_reset_tokens` (global `email` PK) per company. WebStore `StoreCustomer` is a separate model → unaffected.

(original plan for Step 2 below — kept for reference)
## 🟡 STEP 2 — Same email across companies for REGULAR users (BIGGER, do after Step 1)
**Risk:** touches the core `users` table + the login path fleet-wide.

### BE
1. **Email uniqueness migration:** drop the global unique on `users.email`, add **composite unique `(company_id, email)`**. Existing rows satisfy it (each email currently appears once). ⚠️ find the real index name; guard the drop; reversible `down()`.
2. **Login flow** (`AuthController::login`): replace `where('email')->first()` with:
   - find ALL active users with that email; keep those where `Hash::check(password)` passes;
   - **0** → 401; **1** → issue token (log in directly);
   - **>1** (same email+password in multiple companies) → return `{ choose_company: [{company_id, company_name}], … }` (NO token) → FE shows a company picker → user re-submits `{email, password, company_id}` → authenticate that exact record → token. **⚠️ (Codex H3) the picker MUST list ONLY the companies where `Hash::check` actually passed** — never a company where the email exists with a DIFFERENT password (else one stolen credential enumerates company names).
   - The super-admin is one record → logs in normally, then uses the Step-1 switcher.
3. **User-management validation — ALREADY DONE (Codex C1, verified):** `StoreUserRequest:29`, `UpdateUserRequest:30`, `RegisterRequest:30` (+ `UpdateProfileRequest`) already validate `unique:users,email,…,company_id,{companyId}` → **no change needed**, only re-confirm + add a Pest. App-validation is composite already; today the row would still be **rejected at INSERT by the global DB constraint**, so **action 1 (the DB migration) is the ONLY real remaining uniqueness work.**
4. **Audit other `User::where('email')` paths.** Confirmed today: **no forgot-password/reset endpoint exists in `Modules/Core`** (Codex H1) → non-issue now; if one is added later, scope it (the `password_reset_tokens.email` column is a GLOBAL primary key → needs `company_id` / a company-aware broker). WebStore `StoreAuthService` login/reset use the **separate `StoreCustomer` model** (Codex L2) → **out of scope, unaffected.** Still audit: notifications, invites, impersonation.
5. Pest: duplicate email + same password → picker; duplicate email + different passwords → direct login to the matching one; create user same email different company → allowed; same email same company → 422.

### FE
6. Login component: handle the `choose_company` response → render a company picker step → re-submit with `company_id`. Remember the last chosen company (localStorage) → pre-select.
7. Users screen: allow creating the same email under the current company (drop any FE-side global-email assumption).

### Risks / decisions for Step 2 (resolve before coding it)
- Forgot-password / reset by email becomes ambiguous → needs a company step too.
- Any external integration that assumes globally-unique email.
- The fleet migration on the core users table — test on sqlite + a backup-first on real installs.

---

## ✅ STEP 3 — Login-time company selection (super-admin) + install-wide module activation — **DONE** (BE `b3fac95b1` / FE `146856f`)
**Implemented + reviewed (native + Codex, 0 critical). 17 Pest, full MoonStack suite 100 green, FE deployed.** Part A: `AuthController::login` detects the super-admin single record BEFORE the regular narrowing (C1) → 1st submit + >1 active company = choose_company(ALL active); 2nd submit validates the target active + `User::switchToCompany()` (shared atomic company_id + branch-pivot re-sync, also used by the topbar switcher; refactored `OwnerEntitlementController::switchCompany` onto it). Reuses the Step-2 FE picker. Part B: `PUT /core/owner/modules` fans `system.enabled_modules` to EVERY company + flushes each `module-gate:{c}` (post-commit), clamped to signed `allowedModules()` ONLY when non-null (H3), core forced; tampered seal → 422; FE `save()` routes super-admin → owner endpoint (rollback on error). **Review fixes:** native HIGH-1 = block assigning `super-admin` via the user API (`assertRoleAssignable`); HIGH-2 = FE optimistic rollback; +switchToCompany super-admin guard, tampered-422, `modules.*` max:64. Codex confirmed all plan-review items (C1/H1/H2/H3/C2/M3/M4) applied.
**Deferred (this stage = modules only):** install-wide LIMITS still per-company; new-company module-seed (when multi-company creation lands) — gate fails open (all-enabled) for a company with no setting, accepted now.

<details><summary>original Step-3 plan + Codex plan-review (kept for reference)</summary>
**Owner clarification (2026-06-21):** "لو اليوزر موجود في كذا شركة يطلع له يختار (✅ done in Step 2) **ولو السوبر-أدمن بيختار من الأول** [at login] بردو، **كمان الموديول اتقفل يقفل في كل الشركات — على الأقل في المرحلة دي**." ⇒ two parts: **(A)** a login-time company picker for the **super-admin** (regular multi-company users already get it via Step 2); **(B)** the owner's module on/off becomes **install-wide** (off in every company), modules-only for now (limits stay per-company, deferred).

### Current state (grounded — verified 2026-06-21)
- **Login:** `AuthController::login` resolves `$active` (email+password matches, both user&company active). Exactly 1 match (the super-admin is always a single record) → token immediately, landing in the user's stored `company_id`. The super-admin switches company only via the **Step-1 topbar switcher** (`OwnerEntitlementController::switchCompany`, which sets `company_id` + re-syncs the branch pivot in a `DB::transaction`). There is **no company choice at login** for the super-admin.
- **Module on/off:** stored in setting **`system.enabled_modules`** (JSON array) — **company-scoped**. FE `SystemModulesService` reads/writes it via `/core/settings/{key}` (scoped to the caller's company). `EnforceEnabledModules` blocks `/api/{module}/*` when disabled by EITHER layer: `entitlement->allowedModules()` (**install-wide**, signed; unsealed=all, tampered=core-only) **∩** the **company-scoped** `system.enabled_modules` (cache `module-gate:{companyId}`, 300s). So the OWNER's toggle today only affects their **active** company.
- **🔑 SettingsService is strictly company-scoped:** `get/set(string $key, int $companyId, …)` — `company_id` is **NOT-NULL + FK to companies** (`settings` table). ⇒ there is **NO global/install-wide setting store** via SettingsService. This rules out a simple `owner.enabled_modules` global setting (would need a schema change to allow null company_id, breaking the FK + unique index).

### Part A — Super-admin login-time company picker
**Reuses the Step-2 `choose_company` response + FE picker verbatim — no new FE widget.**
- **BE (`AuthController::login`):** after `$active` is built and (if a `company_id` was supplied) narrowed, when exactly **one** record remains AND it `hasRole('super-admin','web')` AND **>1 active company** exists AND **no `company_id` was submitted** → return `{ choose_company: [ALL active companies, localized] }` (reuse the `OwnerEntitlementController::companies()` query). On the **2nd submit** (`company_id` present) for the super-admin: authenticate, then apply the **switchCompany logic** (set `company_id` + re-sync the branch pivot, in the same `DB::transaction`) **before** issuing the token, then return token+UserResource.
  - **Distinct from the regular >1 picker:** the regular picker lists ONLY hash-matched companies (Codex H3, anti-enumeration). The super-admin picker lists **all active companies** — intended and safe: the super-admin is already authenticated as the platform owner with `Gate::before` access to every company, so it leaks nothing they can't already see.
  - **Ordering:** the super-admin branch must run only when `$active->count() === 1` (it never collides with the regular >1 branch). Single-company install → no picker (direct login, unchanged). Per-email throttle (Step-2 Axis-4) still applies.
  - **Sticky:** the picker pre-selects the super-admin's current `company_id` (FE highlight). The pick persists via the `company_id` update → next login pre-selects the same.
- **FE:** the picker already exists. Only addition: pre-highlight the remembered company; ensure `onCompanyPick` re-submits `company_id` (already does). The Step-1 topbar switcher stays for mid-session switching.
- **Pest:** super-admin + >1 active company → picker(all active); 2nd submit → token + company_id switched + branch re-synced; super-admin + single company → direct login; regular user paths unchanged (Step-2 tests still green); 2nd-submit with an INACTIVE company_id → 401/422.

### Part B — Install-wide module activation (modules-only this stage)
**Because SettingsService has no global store, the lowest-risk design is FAN-OUT (recommended B2), not a new global setting.**
- **Design B2 — fan-out (RECOMMENDED):** a NEW owner-only endpoint **`PUT /core/owner/modules`** writes `system.enabled_modules` to **EVERY company** in one transaction + flushes every `module-gate:{c}` cache. The owner's toggle is then install-wide by construction. **Enforcement + the FE nav read path stay UNCHANGED** (`EnforceEnabledModules` and `SystemModulesService.load()` keep reading the per-company setting — now uniform across companies), so zero risk to the already-live, tested gate. **Honor ceiling preserved:** the endpoint **clamps the saved list to `entitlement->allowedModules()`** (when sealed) so the owner can never enable beyond the signed cap — mirrors Phase-6 "honor can only tighten". `core` always forced in.
  - **Multi-company drift:** if a new company is created later it won't have the list. Mitigated by (1) multi-company creation is **not enabled today** (Company has no `store()`), and (2) a `Company::created` hook (or the existing create path) copies the install-wide list when multi-company lands. Document the TODO.
  - **No auto-unify migration** on existing installs (companies 1–4 on moonui may currently differ): the owner's **first save** via the new endpoint unifies them. An auto-migration could silently flip company 2/3's modules — rejected. Document.
- **Design B1 — global setting (REJECTED for now):** add a null-company_id global setting / new `OwnerModulesService` + change `EnforceEnabledModules` + FE read to the global. Cleaner semantically but needs a schema change (nullable company_id breaks FK+unique) OR a `storage/moonstack/owner-modules.json` file store + new read endpoint + a seed migration — much larger surface on a live gate. Defer unless the owner wants a true global record.
- **BE:** `OwnerEntitlementController::updateModules` (super-admin only via `assertOwner`) — validate `modules: string[]` (each in the known MODULE_MAP), clamp to `allowedModules()`, force-add `core`, `DB::transaction` loop over `Company::all()` → `SettingsService::set('system.enabled_modules', $list, $company->id)` + `Cache::forget('module-gate:'.$id)` + `Cache::forget('settings.company.'.$id)`. Route in `Modules/Core/routes/api.php`. Optionally surface the unified list in the owner payload.
- **FE:** `SystemModulesService.save()` gains an owner path that POSTs to `/core/owner/modules` (instead of the per-company `/core/settings`). The owner-settings screen calls it; nav-hiding read stays as-is (now uniform).
- **Pest:** owner save → all companies' `system.enabled_modules` equal; non-owner → 403; clamp beyond `allowedModules` (sealed) is rejected/clamped; `core` always present; caches flushed.

### Risks / open decisions for Step 3
- **B2 vs B1:** recommend **B2 (fan-out)** now (smallest live-gate risk); revisit B1 if a true single global record is wanted. **← owner/Codex confirm.**
- Super-admin login picker lists ALL active companies (by design) — confirm that's the desired UX vs. landing directly in the last company with only the topbar switcher.
- Existing multi-company installs: no auto-unify; the owner's first module-save unifies. Confirm acceptable.
- Limits stay per-company this stage (owner said "modules at least") — install-wide limits deferred.

### ✅ Codex plan-review applied (2026-06-21) — corrections folded in before implementation
- **C1 (CRITICAL) — restructure `login()` ordering.** The existing `if ($companyId !== null)` narrowing filters `$active` by *user record* `company_id`; the super-admin has ONE record (in their current company), so picking a different company on the 2nd submit empties `$active` → 401 before the super-admin branch runs. **Fix:** detect the super-admin case (`$active->count()===1 && $single->hasRole('super-admin','web')`) and branch on it **BEFORE** the regular narrowing. Super-admin path: 1st submit + `>1` active company → `choose_company(ALL active)`; 2nd submit → validate `Company::where('id',$companyId)->where('is_active',true)->exists()` **directly** (NOT by filtering user records) → `$user->switchToCompany($companyId)` → token. Regular users keep the narrowing + hash-matched picker. (If the super-admin credential is ALSO duplicated in another company, `count>1` → the regular picker fires — L4, documented/acceptable.)
- **H1 — single `RateLimiter::clear()` for ALL success paths.** Put one clear() immediately before token issuance covering both the super-admin 2nd-submit and the regular direct-login; the 1st-submit `choose_company` returns early (no clear, no token) by construction.
- **H3 — null-guard the module clamp.** `entitlement->allowedModules()` is **null on unsealed** (= no cap = all). Clamp ONLY when it's a non-null list: `$clamped = $allowed === null ? $list : array_values(array_intersect($list, $allowed))`. Never `array_intersect($list, null)` (would block everything). Mirror `EntitlementService::isModuleAllowed`.
- **H2 — new-company policy (documented & accepted for this stage).** Fan-out covers EXISTING companies; a future-created company has no `system.enabled_modules` → gate reads null → all-enabled (pass-through). **Accepted now** (multi-company creation isn't enabled — Company has no `store()`). TODO when it lands: seed the new company's list from the install-wide list on `Company::created` (or re-run fan-out). Noted in the endpoint docblock.
- **C2/L2 — flush trap.** `SettingsService::set()` flushes only `settings.company.{c}`, NOT `module-gate:{c}`. The fan-out loop MUST add an explicit `Cache::forget('module-gate:'.$id)` per company (the gate's own cache key). The signed-entitlement cache `owner-entitlement:modules` does NOT need flushing (file unchanged).
- **M3 — permissive validation (don't reject FE ids).** FE `ALL_MODULE_IDS` (`hr`,`crm`,`cmms`,`qms`,`production`,…) ≠ BE `MODULE_MAP` (`hrm`,…). The endpoint must NOT strict-validate against `MODULE_MAP` (would reject `hr` etc.). Instead: accept a string list, normalize (lowercase/trim/unique), force-add `core`, store verbatim. The gate only acts on `MODULE_MAP` ids; unknown ids sit harmlessly in the setting. The pre-existing `hr`↔`hrm` taxonomy gap (a `hr`-disable wouldn't gate `/api/hrm/*`) is **OUT OF SCOPE for Step 3** — noted as a follow-up.
- **M4 — FE must route super-admin to the new endpoint.** `SystemModulesService.save()` currently always PUTs the per-company `/core/settings`. For the owner it MUST POST `/core/owner/modules` (the fan-out) — else only the active company changes and B2 is defeated. Branch on `isSuperAdminUser()`.
- **M1 — UserResource reload is post-transaction** (load after `switchToCompany`) so the returned user reflects the new company. (Already the order in `switchCompany`.)
- **Refactor (DRY):** extract the switch transaction (set `company_id` + detach/attach main branch) into `User::switchToCompany(int): void` and have BOTH `AuthController::login` (super-admin 2nd submit) and `OwnerEntitlementController::switchCompany` call it — single source for the Codex-M3-reviewed branch re-sync.

### Verification vs the owner's clarified ask
- Regular user in >1 companies → picker at login → ✅ (Step 2, done).
- Super-admin picks the company at login, sticky → **Part A** (C1-restructured). ✅
- Owner turns a module off → off in **all** companies → **Part B (fan-out)**. ✅
- Modules-only this stage (limits later) → Part B scoped to `system.enabled_modules`. ✅

</details>

---

## Sequencing & process
- **Do Step 1 fully** (BE + FE + Pest + native&Codex review per [[feedback_codex_review_after_each_task]] + commit + CHANGELOG `[Unreleased]` + KB) before starting Step 2.
- **Codex reviews THIS PLAN first** to confirm it achieves the agreed model, then reviews each implemented step against the plan.
- Out of scope (for now): many-to-many user↔company single-identity model (explicitly rejected in favour of separate-accounts-per-company); install-wide module/limit config (separate deferred item, see [[super-admin-owner]]).

## Verification that the plan meets the agreed model
- Regular user, same email in 2 companies, independent password/role → **Step 2 (1,2,3)**. ✅
- Can't duplicate a user inside one company → **Step 2 (1,3)** composite unique. ✅
- Super-admin = one account that picks a company at login, sticky → **Step 1**. ✅
- No mass refactor of the 1199 scoping sites → **Step 1 mutates company_id; Step 2 keeps per-record company_id**. ✅
