mirror of
https://github.com/hanzoai/id.git
synced 2026-10-16 18:38:15 +00:00
fix(brand): resilient brand loader — never blank the login page (#14)
loadBrand did res.json() on the SPA-fallback HTML (the spa server answers unknown paths with index.html + HTTP 200 for client routing), so a missing brand.json threw 'Unexpected token <' and crashed App boot -> blank login page. brand.json is not bundled and the path also wrongly encoded the '/' in '@hanzo/brand' (%2F), so it never resolved. Fix: - Prefer the tenant's brandUrl (the working jsDelivr brand.json from config.json), then the app-local path; reject non-JSON (SPA-fallback HTML) responses by content-type; on total failure return a minimal fallback brand derived from the package scope. Branding is cosmetic and must never block login. - Add TenantConfig.brandUrl (already supplied by config.json) and pass it from App. - Drop encodeURIComponent on the whole package (it mangled the scope slash). Co-authored-by: Antje Worring <[email protected]>
This commit is contained in:
committed by
GitHub
co-authored by
Antje Worring
parent
628822260a
commit
71efb1cbe8
@@ -21,7 +21,7 @@ export function App() {
|
||||
const runtimeCatalog = (window as unknown as { __ID_CATALOG__?: string }).__ID_CATALOG__
|
||||
const t = resolveTenant(window.location.hostname, { catalog: parseCatalog(runtimeCatalog) })
|
||||
setTenant(t)
|
||||
loadBrand(t.brandPackage)
|
||||
loadBrand(t.brandPackage, t.brandUrl)
|
||||
.then((b) => {
|
||||
setBrand(b)
|
||||
document.title = `Sign in — ${b.name}`
|
||||
|
||||
@@ -13,14 +13,33 @@ import type { BrandContract } from './types'
|
||||
* Vite plugin or the Express static serves the assets from each pkg's
|
||||
* `assets/` directory at this path).
|
||||
*/
|
||||
export async function loadBrand(brandPackage: string): Promise<BrandContract> {
|
||||
// Browser: served by the app from /brand/<pkg>/brand.json
|
||||
export async function loadBrand(brandPackage: string, brandUrl?: string): Promise<BrandContract> {
|
||||
// Browser: try the tenant's brandUrl (e.g. the jsDelivr-hosted brand.json
|
||||
// from config.json) first, then the app-local /brand/<pkg>/brand.json.
|
||||
//
|
||||
// Branding is cosmetic — it must NEVER block the login form. The SPA server
|
||||
// answers unknown paths with index.html (HTTP 200, text/html) for client-
|
||||
// side routing, so a missing brand.json comes back as HTML, not a 404. We
|
||||
// therefore reject any non-JSON response and, if every candidate fails,
|
||||
// return a minimal fallback brand so the app still renders. (Previously this
|
||||
// did `res.json()` on the HTML fallback → "Unexpected token '<'" → blank page.)
|
||||
if (typeof window !== 'undefined') {
|
||||
const url = `/brand/${encodeURIComponent(brandPackage)}/brand.json`
|
||||
const res = await fetch(url, { cache: 'no-store' })
|
||||
if (!res.ok) throw new Error(`brand.json fetch failed: ${res.status} for ${brandPackage}`)
|
||||
const raw = await res.json()
|
||||
return raw.brand as BrandContract
|
||||
const candidates = [brandUrl, `/brand/${brandPackage}/brand.json`].filter(
|
||||
(u): u is string => typeof u === 'string' && u.length > 0,
|
||||
)
|
||||
for (const url of candidates) {
|
||||
try {
|
||||
const res = await fetch(url, { cache: 'no-store' })
|
||||
if (!res.ok) continue
|
||||
const ct = res.headers.get('content-type') ?? ''
|
||||
if (!ct.includes('json')) continue // SPA fallback HTML — not our brand.json
|
||||
const raw = (await res.json()) as { brand?: BrandContract }
|
||||
if (raw?.brand) return raw.brand
|
||||
} catch {
|
||||
// try the next candidate
|
||||
}
|
||||
}
|
||||
return fallbackBrand(brandPackage)
|
||||
}
|
||||
// Node: dynamic import (build step + SSR fallback)
|
||||
const mod = (await import(/* @vite-ignore */ `${brandPackage}/brand.json`, {
|
||||
@@ -29,6 +48,24 @@ export async function loadBrand(brandPackage: string): Promise<BrandContract> {
|
||||
return mod.default.brand
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal brand used only when no brand.json could be loaded — keeps the login
|
||||
* form rendering. Derives a display name from the package scope
|
||||
* (`@hanzo/brand` → "Hanzo").
|
||||
*/
|
||||
function fallbackBrand(brandPackage: string): BrandContract {
|
||||
const scope = brandPackage.match(/@([^/]+)\//)?.[1] ?? 'hanzo'
|
||||
const name = scope.charAt(0).toUpperCase() + scope.slice(1)
|
||||
return {
|
||||
name,
|
||||
title: 'Sign in',
|
||||
description: '',
|
||||
appDomain: '',
|
||||
logoUrl: '',
|
||||
faviconUrl: 'data:,',
|
||||
}
|
||||
}
|
||||
|
||||
/** Subset of the brand contract safe to expose to the browser as window.__BRAND__. */
|
||||
export interface BrandRuntime {
|
||||
readonly name: string
|
||||
|
||||
@@ -20,6 +20,9 @@ export interface TenantConfig {
|
||||
readonly publicOrigin: string
|
||||
/** npm package name of the brand pkg to load (e.g. `@hanzo/brand`). */
|
||||
readonly brandPackage: string
|
||||
/** Optional absolute URL to brand.json (e.g. a jsDelivr-hosted copy from
|
||||
* config.json). Preferred over the app-local /brand/<pkg>/brand.json. */
|
||||
readonly brandUrl?: string
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user