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.
The whole path, once through
Section titled “The whole path, once through”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)
How the signup gate actually works
Section titled “How the signup gate actually works”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.
-
The SPA pulls the domain allowlist. On mount, sign-up calls
User.getBrokerDomains()(src/pages/sign-up/sign-up.tsx:78) → GETbroker-domains(routes/api/broker.php:31,UserController@getBrokerDomains,UserController.php:54). The action just returns everyBrokerDomainwith its company (app/Actions/User/Broker/GetBrokerDomains.php:12). The SPA keeps only thedomain_pathof each (sign-up.tsx:92-94). -
Fast-path: a known domain is accepted with no server call. In the Yup email validator, if the typed email
includes()one of thedomain_pathstrings, it resolves valid immediately (src/components/sign-up-form/sign-up-form.tsx:103-111). This is the “everyone at@acme.comis a broker” case — oneBrokerDomainrow admits the whole company. -
Slow-path: per-email server check. Otherwise, after a 500 ms debounce,
validateDomaincallsUser.whitelistDomains(email)→ GETcheck-whitelisted-emailswith the email as a query param (routes/api/broker.php:72,UserController@checkEmailExistence,UserController.php:116). The backend lowercases the email and looks upBrokerWhitelistedEmail::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). -
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’scatchtreats asfalse(sign-up-form.tsx:63-65) and the field errors withagentEmailDomainNotAllowed/managerEmailDomainNotAllowed(:114-124). Result: you can only sign up with an email your brokerage was provisioned for. -
The matched company id rides along into registration. On a whitelist hit the returned
brokerage_company_source_idis stashed viaprops.setWhitelistedDomainId(...)(sign-up-form.tsx:59-62) and later sent ascompany_source_idin the register payload — required byCreateBrokerRequest.php:20. -
Register mints the broker via crms-middleware.
User.createBroker(data)→ POSTregister(routes/api/broker.php:32,UserController@store,UserController.php:63). The controller hardcodes Egypt (Country::where('slug', 'egypt'),:74) and hands off toCreateBrokerFromPortal(app/Actions/User/Broker/CreateBrokerFromPortal.php:12-26), which POSTs to{crms-middleware}/brokersstampingregister_from = PORTAL(:30) and headercountry: 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.
BrokerWhitelistedEmailController creates/edits one email at a time (unique + regex validated,
:31-40), and supports bulk import from xlsx / xls / csv via importView / import
(:68-92). The same importer is also exposed on the API as POST import-whitelisted-emails
(routes/api/broker.php:71, UserController@importWhitelistedEmails, UserController.php:101).
ImportWhitelistedEmails (app/Actions/User/Broker/ImportWhitelistedEmails.php) validates each row
(email, unique, regex — :63-71), lowercases, dedupes against existing rows (:33-36), and bulk
inserts (:47). Zero valid/new rows returns 422 “No valid or new emails found in the file.”
(UserController.php:107-109).
Whitelabel logo (the branding half)
Section titled “Whitelabel logo (the branding half)”Uploading the logo is a broker-authenticated action, separate from signup.
-
Upload. POST
whitelabel/logo(routes/api/broker.php:83) sits behindauth:sanctum+ability:access-broker-api(:79).WhitelabelController@storeLogo(WhitelabelController.php:14) resolves the logged-in broker viaBroker::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, endpointsrc/api/endpoints.ts:521-522). -
Store as a polymorphic
Image.UpdateWhitelabelLogo(app/Actions/Broker/UpdateWhitelabelLogo.php:18-45) uploads the file to thebroker-logospath, deletes any existing logo file from storage, thenImage::updateOrCreatekeyed on(model_id = broker.id, model_type = Broker, slug = 'logo'). There is exactly one logo per broker — a re-upload replaces it. -
Serve. The logo comes back on the broker profile as
BrokerResource.php:28→getImageBySlugWithPath('logo')→ the image’sabsolute_path(app/Concerns/HasImages.php:25-31). -
Remove. DELETE
whitelabel/logo(routes/api/broker.php:84,WhitelabelController@destroyLogo:23) →RemoveWhitelabelLogo(app/Actions/Broker/RemoveWhitelabelLogo.php:13-27) deletes theImagerow and the storage file.
The models involved
Section titled “The models involved”| 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 |
Gotchas
Section titled “Gotchas”-
domain_nameis stored and served but the current broker SPA never reads it. Onlydomain_pathis consumed (sign-up.tsx:92-94) as the signup email allowlist. There is no runtime host-to-brand resolver inorascom-broker-app— nowindow.location.hostlookup that themes the portal per domain. The per-broker branding that actually renders is the uploaded logo (BrokerResourcelogo), 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_pathis 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. ABrokerWhitelistedEmailis an exact, lowercased server lookup. If a broker’s email is neither, signup is blocked regardless of which table the admin thinks they added. -
registertrustscompany_source_id, not the email. The whitelist is not re-checked atstore(UserController.php:63-99); it only requires a non-emptycompany_source_id. The real gate is the pre-submitcheck-whitelisted-emailscall plus the required field. -
check-whitelisted-emailsis 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’ssource_id. The+in an email is URL-encoded to%2Bby the SPA (sign-up-form.tsx:56) — hand-built calls must do the same or theexistsrule fails. -
Registration is hardcoded to Egypt.
UserController@storealways resolvesCountry::where('slug', 'egypt')(:74) andCreateBrokerFromPortalalways sends headercountry: egypt(:22-25). This self-signup path is Egypt-only by construction — other countries do not usebroker-domains/check-whitelisted-emails(Oman uses the separate pre-registration flow). -
One logo per broker, replaced on re-upload.
UpdateWhitelabelLogodoesupdateOrCreateonslug = '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.
ImportWhitelistedEmailsdrops any row failingemail/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.
