|
| 1 | +# Tenant Branding: Display Name Propagation |
| 2 | + |
| 3 | +## Problem Statement |
| 4 | + |
| 5 | +When a tenant user visits their subdomain (e.g., |
| 6 | +`volterra-energy.demo.meridianhub.cloud/login`), the UI shows |
| 7 | +"Meridian" everywhere - on the login page, in the header after |
| 8 | +login, and in the page title. There is no indication of which |
| 9 | +tenant organization the user is accessing. |
| 10 | + |
| 11 | +This matters because: |
| 12 | + |
| 13 | +1. **Trust** - Users landing on their org's subdomain expect to |
| 14 | + see their org name. Seeing "Meridian" is confusing and erodes |
| 15 | + confidence that they're in the right place. |
| 16 | +2. **Multi-tenant clarity** - Platform admins switching between |
| 17 | + tenants need clear visual feedback about which tenant context |
| 18 | + they're operating in. |
| 19 | +3. **Demo credibility** - During demos, showing the prospect's |
| 20 | + org name instead of "Meridian" makes the product feel real. |
| 21 | + |
| 22 | +## Technical Context |
| 23 | + |
| 24 | +### What Exists |
| 25 | + |
| 26 | +| Component | Current State | |
| 27 | +|-----------|--------------| |
| 28 | +| Tenant `DisplayName` field | Stored in DB, exposed in proto, set during tenant creation | |
| 29 | +| Tenant slug | Extracted from subdomain by `TenantResolverMiddleware`, injected into context | |
| 30 | +| JWT claims | Include `x-tenant-slug` and `x-tenant-id`, but NOT display name | |
| 31 | +| Frontend tenant context | Has `tenantSlug` and `currentTenant` (platform admins only) | |
| 32 | +| Header component | Hardcoded "Meridian" text | |
| 33 | +| Login page | Hardcoded "Meridian Operations Console" title | |
| 34 | +| `<title>` tag | Hardcoded "Meridian Operations Console" in index.html | |
| 35 | + |
| 36 | +### Architecture |
| 37 | + |
| 38 | +The tenant resolver (`shared/platform/gateway/tenant_resolver.go`) |
| 39 | +already loads the full `domain.Tenant` entity (including `DisplayName`) |
| 40 | +when resolving slug to tenant ID. It discards the display name after |
| 41 | +resolution, only injecting `tenantID` and `slug` into the request |
| 42 | +context. |
| 43 | + |
| 44 | +JWT minting happens in two places: |
| 45 | +- `services/api-gateway/auth_handler.go` - password-based login |
| 46 | +- `services/api-gateway/auth_sso_handler.go` - SSO/OIDC callback |
| 47 | + |
| 48 | +Both have access to the tenant context at the point where claims |
| 49 | +are constructed. |
| 50 | + |
| 51 | +The login page is pre-authentication - no JWT exists yet. The only |
| 52 | +tenant signal available is the subdomain slug. To show the real |
| 53 | +display name pre-login, we need a public (unauthenticated) endpoint. |
| 54 | + |
| 55 | +## Solution |
| 56 | + |
| 57 | +### 1. Propagate display name through tenant context (backend) |
| 58 | + |
| 59 | +Extend the tenant context package to carry `DisplayName` alongside |
| 60 | +`TenantID` and `Slug`: |
| 61 | + |
| 62 | +- Add `WithDisplayName()` / `DisplayNameFromContext()` to |
| 63 | + `shared/platform/tenant/context.go` |
| 64 | +- Update `TenantResolverMiddleware` to inject display name into |
| 65 | + context after resolving the tenant entity |
| 66 | +- This makes display name available to all downstream handlers |
| 67 | + |
| 68 | +**Cache consideration:** The current `slugCache` only stores |
| 69 | +`TenantID`. On cache hits (common path), the full entity is not |
| 70 | +loaded. The resolver should extend the cache to store display name |
| 71 | +alongside tenant ID, or fall back to a DB query when display name |
| 72 | +is needed. Either approach is acceptable - the display name lookup |
| 73 | +is low-frequency (once per login, once per page load) and the |
| 74 | +tenant table is small. |
| 75 | + |
| 76 | +### 2. Add display name to JWT claims (backend) |
| 77 | + |
| 78 | +In both auth handlers (`auth_handler.go` and `auth_sso_handler.go`), |
| 79 | +read the display name from context and add it as a JWT claim |
| 80 | +(`x-tenant-display-name`). This propagates the real name to the |
| 81 | +frontend for the entire session without any additional API calls. |
| 82 | + |
| 83 | +**Staleness policy:** The display name in the JWT may become stale |
| 84 | +if a tenant is renamed mid-session. This is acceptable - tenant |
| 85 | +renames are rare administrative operations. The frontend will pick |
| 86 | +up the updated name on next token refresh (session expiry or |
| 87 | +explicit re-login). No token invalidation or forced refresh is |
| 88 | +needed for this claim. |
| 89 | + |
| 90 | +### 3. Public tenant info endpoint (backend) |
| 91 | + |
| 92 | +Add `GET /api/tenant-info` to the gateway as a public endpoint |
| 93 | +(no auth required, like `/api/auth/providers`). The endpoint: |
| 94 | + |
| 95 | +- Uses the tenant resolver to identify the tenant from the subdomain |
| 96 | +- Returns `{ slug, displayName }` as JSON |
| 97 | +- Returns 404 if no valid tenant subdomain is present |
| 98 | +- This serves the login page where no JWT exists yet |
| 99 | + |
| 100 | +**Abuse protections:** |
| 101 | +- The endpoint resolves tenant from the request's subdomain (Host |
| 102 | + header), not from a query parameter. There is no lookup-by-slug |
| 103 | + input - a caller must already be on the tenant's subdomain to |
| 104 | + get a response. This prevents enumeration: you cannot discover |
| 105 | + tenants you don't already have the subdomain for. |
| 106 | +- Rate limited per IP (reuse the gateway's existing rate limiter) |
| 107 | +- Positive responses cached via `Cache-Control: public, s-maxage=300` |
| 108 | + to reduce repeated lookups |
| 109 | +- Error responses return a uniform 404 with identical body and |
| 110 | + timing regardless of whether the subdomain is invalid, unknown, |
| 111 | + or absent - the existing tenant resolver already does this |
| 112 | +- Only slug and display name are returned - no tenant IDs, status, |
| 113 | + or internal metadata are exposed |
| 114 | +- Suspended tenants still return their display name (users need to |
| 115 | + know where they are). Deprovisioned tenants return 404. |
| 116 | + |
| 117 | +### 4. Frontend: consume tenant display name (frontend) |
| 118 | + |
| 119 | +**Login page:** |
| 120 | +- Call `/api/tenant-info` on mount when on a tenant subdomain |
| 121 | +- Show the tenant's display name as the page title |
| 122 | +- Fall back to formatted slug if the endpoint is unavailable |
| 123 | +- Update document title to match |
| 124 | + |
| 125 | +**Header:** |
| 126 | +- For tenant users: read `x-tenant-display-name` from JWT claims |
| 127 | +- For platform admins: use `currentTenant.name` (already available |
| 128 | + from tenant selector) |
| 129 | +- Fall back to formatted slug (title-case, hyphens to spaces: |
| 130 | + `volterra-energy` becomes `Volterra Energy`), then to "Meridian" |
| 131 | + |
| 132 | +**Document title:** |
| 133 | +- Set `document.title` dynamically based on tenant context |
| 134 | +- Pattern: `"{Tenant Name} - Operations Console"` on tenant |
| 135 | + subdomains, `"Meridian Operations Console"` on bare domain |
| 136 | + |
| 137 | +## Non-Goals |
| 138 | + |
| 139 | +- Tenant logos or custom color themes (future work, separate PRD) |
| 140 | +- Custom favicon per tenant |
| 141 | +- Tenant-specific email templates |
| 142 | +- Whitelabeling (removing Meridian branding entirely) |
| 143 | + |
| 144 | +## Success Criteria |
| 145 | + |
| 146 | +1. Visiting `volterra-energy.demo.meridianhub.cloud/login` shows |
| 147 | + "Volterra Energy" as the heading (not "Meridian") |
| 148 | +2. After login, the header shows "Volterra Energy" (not "Meridian") |
| 149 | +3. Browser tab shows "Volterra Energy - Operations Console" |
| 150 | +4. Platform admins see the selected tenant's name in the header, |
| 151 | + or "Meridian" when no tenant is selected |
| 152 | +5. On bare domain (`demo.meridianhub.cloud`), "Meridian" branding |
| 153 | + is preserved |
| 154 | + |
| 155 | +## Complexity Estimate |
| 156 | + |
| 157 | +**8 story points total** across 3-4 PRs: |
| 158 | + |
| 159 | +| PR | Points | Description | |
| 160 | +|----|--------|-------------| |
| 161 | +| Backend context + JWT | 3 | Tenant context display name, JWT claim propagation | |
| 162 | +| Public endpoint | 2 | `/api/tenant-info` handler + tests | |
| 163 | +| Frontend consumption | 3 | Login page, header, document title, auth context changes | |
| 164 | + |
| 165 | +Backend PRs can merge independently. Frontend PR depends on both |
| 166 | +backend PRs being deployed. |
0 commit comments