Skip to content

Whitelabel branding & broker onboarding

An Egypt broker does not pre-register (that bespoke flow is Oman-only). Instead they self-signup, and the signup is gated by an email/domain allowlist that an ODH admin configures per brokerage. Once logged in, a broker can upload a whitelabel logo that brands their portal. This page traces both halves — the allowlist gate at signup, and the logo upload — end to end. Everything here is verified against the current backend (develop @ 8434e475) and the orascom-broker-app frontend.

sequenceDiagram
participant ADM as ODH admin (Voyager)
participant DB as brokerage_companies + broker_domains + broker_whitelisted_emails
participant SPA as Broker sign-up SPA
participant API as Broker API (Laravel)
participant MW as crms-middleware
participant BRK as Logged-in broker
ADM->>DB: create BrokerageCompany + BrokerDomain (domain_path) + whitelisted emails (BREAD or Excel import)
SPA->>API: GET broker-domains
API-->>SPA: domain_path list (client allowlist fast-path)
Note over SPA: applicant types their work email
alt email contains a known domain_path
  SPA->>SPA: accept locally, no server call
else not a known path
  SPA->>API: GET check-whitelisted-emails with the email
  API-->>SPA: exists true + brokerage_company_source_id, OR 422 not allowed
end
SPA->>API: POST register (company_source_id + register_from PORTAL)
API->>MW: POST brokers (country egypt, hardcoded)
MW-->>API: broker created
Note over BRK: after login the broker uploads a logo
BRK->>API: POST whitelabel/logo (image file)
API->>API: store Image slug logo on the Broker
Note over BRK: logo comes back in the broker profile (BrokerResource)
Brokerage provisioning to branded, allowlist-gated broker signup (verified)

The gate is a two-tier allowlist: a client-side fast-path over the brokerage domains, backed by a server-verified per-email whitelist. Both are read-only lookups — nothing is created until register.

  1. The SPA pulls the domain allowlist. On mount, sign-up calls User.getBrokerDomains() (src/pages/sign-up/sign-up.tsx:78) → GET broker-domains (routes/api/broker.php:31, UserController@getBrokerDomains, UserController.php:54). The action just returns every BrokerDomain with its company (app/Actions/User/Broker/GetBrokerDomains.php:12). The SPA keeps only the domain_path of each (sign-up.tsx:92-94).

  2. Fast-path: a known domain is accepted with no server call. In the Yup email validator, if the typed email includes() one of the domain_path strings, it resolves valid immediately (src/components/sign-up-form/sign-up-form.tsx:103-111). This is the “everyone at @acme.com is a broker” case — one BrokerDomain row admits the whole company.

  3. Slow-path: per-email server check. Otherwise, after a 500 ms debounce, validateDomain calls User.whitelistDomains(email)GET check-whitelisted-emails with the email as a query param (routes/api/broker.php:72, UserController@checkEmailExistence, UserController.php:116). The backend lowercases the email and looks up BrokerWhitelistedEmail::where('email', $email)->first() (:119-121). On a hit it returns { exists: true, brokerage_company_source_id } from the email’s company (:123-128) — otherwise { exists: false } (:130).

  4. A non-allowed email fails validation. The request rule is exists:broker_whitelisted_emails,email (CheckWhitelistedEmailsExistenceRequest.php:17-22), so an unknown email returns a 422, which the SPA’s catch treats as false (sign-up-form.tsx:63-65) and the field errors with agentEmailDomainNotAllowed / managerEmailDomainNotAllowed (:114-124). Result: you can only sign up with an email your brokerage was provisioned for.

  5. The matched company id rides along into registration. On a whitelist hit the returned brokerage_company_source_id is stashed via props.setWhitelistedDomainId(...) (sign-up-form.tsx:59-62) and later sent as company_source_id in the register payload — required by CreateBrokerRequest.php:20.

  6. Register mints the broker via crms-middleware. User.createBroker(data)POST register (routes/api/broker.php:32, UserController@store, UserController.php:63). The controller hardcodes Egypt (Country::where('slug', 'egypt'), :74) and hands off to CreateBrokerFromPortal (app/Actions/User/Broker/CreateBrokerFromPortal.php:12-26), which POSTs to {crms-middleware}/brokers stamping register_from = PORTAL (:30) and header country: egypt.

Provisioning a brokerage (admin, in Voyager)

Section titled “Provisioning a brokerage (admin, in Voyager)”

Both allowlist tables are managed as Voyager BREAD, scoped to Egypt (country_id = 1).

BrokerDomainController (app/Http/Controllers/Dashboard/BrokerDomainController.php) creates/edits a BrokerDomain — its domain_name and domain_path — and requires a brokerage_company_id that exists:brokerage_companies,id (:49-52). The company dropdown is filtered to country_id = 1 (:56). A single domain row admits every email at that domain via the client fast-path.

Uploading the logo is a broker-authenticated action, separate from signup.

  1. Upload. POST whitelabel/logo (routes/api/broker.php:83) sits behind auth:sanctum + ability:access-broker-api (:79). WhitelabelController@storeLogo (WhitelabelController.php:14) resolves the logged-in broker via Broker::getSessionUserOrFail() and validates the file: required image mimes:png,jpg,jpeg max:5120 — i.e. 5 MB PNG/JPG only (StoreWhitelabelLogoRequest.php:17). Frontend wrapper: User.uploadLogo(file) (src/api/user.ts:94-101, endpoint src/api/endpoints.ts:521-522).

  2. Store as a polymorphic Image. UpdateWhitelabelLogo (app/Actions/Broker/UpdateWhitelabelLogo.php:18-45) uploads the file to the broker-logos path, deletes any existing logo file from storage, then Image::updateOrCreate keyed on (model_id = broker.id, model_type = Broker, slug = 'logo'). There is exactly one logo per broker — a re-upload replaces it.

  3. Serve. The logo comes back on the broker profile as BrokerResource.php:28getImageBySlugWithPath('logo') → the image’s absolute_path (app/Concerns/HasImages.php:25-31).

  4. Remove. DELETE whitelabel/logo (routes/api/broker.php:84, WhitelabelController@destroyLogo:23) → RemoveWhitelabelLogo (app/Actions/Broker/RemoveWhitelabelLogo.php:13-27) deletes the Image row and the storage file.

Model Table Key fields Role in this flow
BrokerageCompany (BrokerageCompany.php:10) brokerage_companies name, source_id, country_id The brokerage. source_id is the CRM id sent as company_source_id on register
BrokerDomain (BrokerDomain.php:9) broker_domains domain_name, domain_path, brokerage_company_id Whole-company allowlist. domain_path drives the client fast-path
BrokerWhitelistedEmail (BrokerWhitelistedEmail.php:9) broker_whitelisted_emails email, brokerage_company_id Per-email allowlist checked by check-whitelisted-emails
Broker (Broker.php:28, extends User) users (role broker) account fields, brokerage_company_id The minted account. Owns the logo Image
Image (polymorphic) images model_type, model_id, slug, path The logo lives here as slug = 'logo' on a Broker
  • domain_name is stored and served but the current broker SPA never reads it. Only domain_path is consumed (sign-up.tsx:92-94) as the signup email allowlist. There is no runtime host-to-brand resolver in orascom-broker-app — no window.location.host lookup that themes the portal per domain. The per-broker branding that actually renders is the uploaded logo (BrokerResource logo), after login. So “custom domain to branded portal” today means: domains gate who may sign up, and the logo is the visible brand.

  • The two tiers admit differently. A BrokerDomain.domain_path is a substring match done in the browser (value.includes(domain), sign-up-form.tsx:104-106) with no server call — it admits everyone at that domain. A BrokerWhitelistedEmail is an exact, lowercased server lookup. If a broker’s email is neither, signup is blocked regardless of which table the admin thinks they added.

  • register trusts company_source_id, not the email. The whitelist is not re-checked at store (UserController.php:63-99); it only requires a non-empty company_source_id. The real gate is the pre-submit check-whitelisted-emails call plus the required field.

  • check-whitelisted-emails is an unauthenticated enumeration surface. It is a public GET (routes/api/broker.php:72) that confirms whether an email is whitelisted and, on a hit, leaks the brokerage’s source_id. The + in an email is URL-encoded to %2B by the SPA (sign-up-form.tsx:56) — hand-built calls must do the same or the exists rule fails.

  • Registration is hardcoded to Egypt. UserController@store always resolves Country::where('slug', 'egypt') (:74) and CreateBrokerFromPortal always sends header country: egypt (:22-25). This self-signup path is Egypt-only by construction — other countries do not use broker-domains / check-whitelisted-emails (Oman uses the separate pre-registration flow).

  • One logo per broker, replaced on re-upload. UpdateWhitelabelLogo does updateOrCreate on slug = 'logo' (:32-44) and deletes the old storage file first (:28-30). A broker cannot keep two logos, and a failed re-upload after the old file is deleted can leave a stale DB path — verify the logo re-renders after uploading.

  • Import silently skips bad/duplicate rows. ImportWhitelistedEmails drops any row failing email / unique / regex validation (:73-74) and any already-present email (:33-36) with no per-row error — the response is only a count. An import reporting fewer than expected is usually duplicates or malformed cells, not a bug.