feat: Hanzo binding packages — @hanzo/cms-plugin-whitelabel + @hanzo/cms-auth-iam
@hanzo/cms-plugin-whitelabel: - brand-by-domain resolution (resolveBrand: longest-suffix match, '*' fallback) - injects logo/icon/title/favicon/og/theme per brand; neutral when unmatched - shared-infra white-label rule (never hardcodes a brand) @hanzo/cms-auth-iam: - hanzoIAMStrategy: verifies Hanzo IAM (Casdoor RS256) tokens via JWKS (jose, no shared secret) - org == tenant: maps IAM 'owner' claim -> tenant, provisions tenant + user, sets payload-tenant cookie - iamAuthFields helper (iamSub unique, iamOrg, username) - IAM = sole identity authority (no separate CMS login) tsconfig paths registered; removed dangling @hanzo/cms-figma enterprise path
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
.env
|
||||
dist
|
||||
demo/uploads
|
||||
build
|
||||
.DS_Store
|
||||
package-lock.json
|
||||
@@ -0,0 +1,12 @@
|
||||
.tmp
|
||||
**/.git
|
||||
**/.hg
|
||||
**/.pnp.*
|
||||
**/.svn
|
||||
**/.yarn/**
|
||||
**/build
|
||||
**/dist/**
|
||||
**/node_modules
|
||||
**/temp
|
||||
**/docs/**
|
||||
tsconfig.json
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/swcrc",
|
||||
"sourceMaps": true,
|
||||
"jsc": {
|
||||
"target": "esnext",
|
||||
"parser": {
|
||||
"syntax": "typescript",
|
||||
"tsx": true,
|
||||
"dts": true
|
||||
},
|
||||
"transform": {
|
||||
"react": {
|
||||
"runtime": "automatic",
|
||||
"pragmaFrag": "React.Fragment",
|
||||
"throwIfNamespace": true,
|
||||
"development": false,
|
||||
"useBuiltins": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"module": {
|
||||
"type": "es6"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2018-2025 Payload CMS, Inc. <info@payloadcms.com>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of this software and associated documentation files (the
|
||||
'Software'), to deal in the Software without restriction, including
|
||||
without limitation the rights to use, copy, modify, merge, publish,
|
||||
distribute, sublicense, and/or sell copies of the Software, and to
|
||||
permit persons to whom the Software is furnished to do so, subject to
|
||||
the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be
|
||||
included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
||||
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
||||
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "@hanzo/cms-auth-iam",
|
||||
"version": "1.0.0",
|
||||
"description": "Hanzo IAM SSO auth strategy for Hanzo CMS. Verifies IAM (Casdoor) access tokens via JWKS; org == tenant. No separate CMS login.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"default": "./src/index.ts"
|
||||
}
|
||||
},
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "pnpm copyfiles && pnpm build:types && pnpm build:swc",
|
||||
"build:debug": "pnpm build",
|
||||
"build:swc": "swc ./src -d ./dist --config-file .swcrc --strip-leading-paths",
|
||||
"build:types": "tsc --emitDeclarationOnly --outDir dist",
|
||||
"clean": "rimraf -g {dist,*.tsbuildinfo}",
|
||||
"copyfiles": "copyfiles -u 1 \"src/**/*.{html,css,scss,ttf,woff,woff2,eot,svg,jpg,png,json}\" dist/",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hanzo/cms": "workspace:*",
|
||||
"jose": "5.10.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hanzo/cms-eslint-config": "workspace:*"
|
||||
},
|
||||
"publishConfig": {
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { Field } from '@hanzo/cms'
|
||||
|
||||
export type { HanzoIAMStrategyConfig, IAMClaims } from './types.js'
|
||||
export { hanzoIAMStrategy } from './strategy.js'
|
||||
|
||||
/**
|
||||
* Fields the IAM strategy needs on the auth collection to map + dedupe users.
|
||||
* Spread these into your users collection `fields`.
|
||||
*
|
||||
* fields: [...iamAuthFields, ...yourFields]
|
||||
*/
|
||||
export const iamAuthFields: Field[] = [
|
||||
{
|
||||
name: 'iamSub',
|
||||
type: 'text',
|
||||
admin: { readOnly: true, description: 'Hanzo IAM subject (user id).' },
|
||||
index: true,
|
||||
label: 'IAM Subject',
|
||||
unique: true,
|
||||
},
|
||||
{
|
||||
name: 'iamOrg',
|
||||
type: 'text',
|
||||
admin: { readOnly: true, description: 'Hanzo IAM org slug (== tenant).' },
|
||||
index: true,
|
||||
label: 'IAM Org',
|
||||
},
|
||||
{
|
||||
name: 'username',
|
||||
type: 'text',
|
||||
label: 'Username',
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,172 @@
|
||||
import type { AuthStrategy, AuthStrategyFunctionArgs, AuthStrategyResult, Payload } from '@hanzo/cms'
|
||||
|
||||
import { createRemoteJWKSet, jwtVerify } from 'jose'
|
||||
|
||||
import type { HanzoIAMStrategyConfig, IAMClaims } from './types.js'
|
||||
|
||||
const DEFAULT_ISSUER = 'https://hanzo.id'
|
||||
const DEFAULT_JWKS = 'https://hanzo.id/v1/iam/.well-known/jwks'
|
||||
|
||||
/** Lazily-built, per-JWKS-URL cached remote key set (jose caches internally). */
|
||||
const jwksCache = new Map<string, ReturnType<typeof createRemoteJWKSet>>()
|
||||
|
||||
const getJWKS = (uri: string) => {
|
||||
let set = jwksCache.get(uri)
|
||||
if (!set) {
|
||||
set = createRemoteJWKSet(new URL(uri))
|
||||
jwksCache.set(uri, set)
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
const getBearer = (headers: AuthStrategyFunctionArgs['headers']): null | string => {
|
||||
const raw = headers.get('authorization') || headers.get('Authorization')
|
||||
if (!raw) {
|
||||
return null
|
||||
}
|
||||
const [scheme, token] = raw.split(' ')
|
||||
if (!token || scheme.toLowerCase() !== 'bearer') {
|
||||
return null
|
||||
}
|
||||
return token.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Provision-or-refresh the tenant doc for an IAM org (org == tenant), returning
|
||||
* its id. Idempotent: keyed on the org slug.
|
||||
*/
|
||||
const ensureTenant = async (args: {
|
||||
claims: IAMClaims
|
||||
payload: Payload
|
||||
tenantsSlug: string
|
||||
}): Promise<number | string | undefined> => {
|
||||
const { claims, payload, tenantsSlug } = args
|
||||
const slug = claims.owner
|
||||
if (!slug) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Only touch the collection if it actually exists in this config.
|
||||
if (!payload.collections?.[tenantsSlug]) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const existing = await payload.find({
|
||||
collection: tenantsSlug,
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
where: { slug: { equals: slug } },
|
||||
})
|
||||
|
||||
if (existing.docs.length > 0) {
|
||||
return existing.docs[0].id as number | string
|
||||
}
|
||||
|
||||
const created = await payload.create({
|
||||
collection: tenantsSlug,
|
||||
data: { name: slug, slug },
|
||||
})
|
||||
return created.id as number | string
|
||||
}
|
||||
|
||||
/**
|
||||
* The Hanzo IAM SSO auth strategy.
|
||||
*
|
||||
* Validates a Bearer access token issued by Hanzo IAM (Casdoor, RS256) against
|
||||
* the published JWKS — no shared secret, no per-request round-trip to IAM. Maps
|
||||
* the verified claims to a Payload user (find-or-provision by IAM `sub`), links
|
||||
* the user to the tenant derived from the IAM `owner` org (org == tenant), and
|
||||
* sets the `payload-tenant` cookie so the multi-tenant plugin scopes every
|
||||
* subsequent query to that org.
|
||||
*
|
||||
* IAM is the sole identity authority: there is no separate CMS login.
|
||||
*/
|
||||
export const hanzoIAMStrategy = (config: HanzoIAMStrategyConfig = {}): AuthStrategy => {
|
||||
const name = config.name || 'hanzo-iam'
|
||||
const authSlug = config.authSlug || 'users'
|
||||
const tenantsSlug = config.tenantsSlug || 'tenants'
|
||||
const tenantsArrayField = config.tenantsArrayField || 'tenants'
|
||||
const issuer = config.issuer || process.env.HANZO_IAM_ISSUER || DEFAULT_ISSUER
|
||||
const jwksUri = config.jwksUri || process.env.HANZO_IAM_JWKS_URI || DEFAULT_JWKS
|
||||
|
||||
return {
|
||||
name,
|
||||
authenticate: async ({
|
||||
canSetHeaders,
|
||||
headers,
|
||||
payload,
|
||||
}: AuthStrategyFunctionArgs): Promise<AuthStrategyResult> => {
|
||||
const token = getBearer(headers)
|
||||
if (!token) {
|
||||
return { user: null }
|
||||
}
|
||||
|
||||
let claims: IAMClaims
|
||||
try {
|
||||
const { payload: verified } = await jwtVerify(token, getJWKS(jwksUri), {
|
||||
issuer,
|
||||
})
|
||||
claims = verified as IAMClaims
|
||||
} catch {
|
||||
// invalid / expired / wrong-issuer token → anonymous
|
||||
return { user: null }
|
||||
}
|
||||
|
||||
if (!claims.sub) {
|
||||
return { user: null }
|
||||
}
|
||||
|
||||
const tenantID = await ensureTenant({ claims, payload, tenantsSlug })
|
||||
|
||||
// find-or-provision the user by IAM subject
|
||||
const found = await payload.find({
|
||||
collection: authSlug,
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
where: { iamSub: { equals: claims.sub } },
|
||||
})
|
||||
|
||||
const baseData = {
|
||||
email: claims.email || `${claims.sub}@iam.local`,
|
||||
iamOrg: claims.owner,
|
||||
iamSub: claims.sub,
|
||||
username: claims.name,
|
||||
...(config.claimsToUser ? config.claimsToUser(claims) : {}),
|
||||
...(tenantID !== undefined
|
||||
? { [tenantsArrayField]: [{ tenant: tenantID }] }
|
||||
: {}),
|
||||
}
|
||||
|
||||
let userDoc
|
||||
if (found.docs.length > 0) {
|
||||
userDoc = await payload.update({
|
||||
id: found.docs[0].id,
|
||||
collection: authSlug,
|
||||
data: baseData,
|
||||
})
|
||||
} else {
|
||||
userDoc = await payload.create({
|
||||
collection: authSlug,
|
||||
data: baseData,
|
||||
})
|
||||
}
|
||||
|
||||
const responseHeaders = new Headers()
|
||||
if (canSetHeaders && tenantID !== undefined) {
|
||||
responseHeaders.append(
|
||||
'Set-Cookie',
|
||||
`payload-tenant=${encodeURIComponent(String(tenantID))}; Path=/; SameSite=Lax; HttpOnly`,
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
responseHeaders: canSetHeaders ? responseHeaders : undefined,
|
||||
user: {
|
||||
...userDoc,
|
||||
_strategy: name,
|
||||
collection: authSlug,
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Claims we rely on from a Hanzo IAM (Casdoor) access token.
|
||||
* `owner` is the org slug — in Hanzo, org == tenant. There is no separate CMS
|
||||
* tenant concept; the IAM org IS the tenancy boundary.
|
||||
*/
|
||||
export type IAMClaims = {
|
||||
aud?: string | string[]
|
||||
email?: string
|
||||
exp?: number
|
||||
iss?: string
|
||||
/** username */
|
||||
name?: string
|
||||
/** org slug — the tenant */
|
||||
owner?: string
|
||||
/** user id (uuid) */
|
||||
sub?: string
|
||||
}
|
||||
|
||||
export type HanzoIAMStrategyConfig = {
|
||||
/**
|
||||
* Slug of the Payload auth collection users are mapped into (e.g. 'users').
|
||||
*/
|
||||
authSlug?: string
|
||||
/**
|
||||
* Extra fields to set/refresh on the mapped user each login. Receives the
|
||||
* verified claims; returns a partial user document.
|
||||
*/
|
||||
claimsToUser?: (claims: IAMClaims) => Record<string, unknown>
|
||||
/**
|
||||
* Expected token issuer. Defaults to HANZO_IAM_ISSUER or https://hanzo.id.
|
||||
*/
|
||||
issuer?: string
|
||||
/**
|
||||
* JWKS URL. Defaults to HANZO_IAM_JWKS_URI or
|
||||
* https://hanzo.id/v1/iam/.well-known/jwks.
|
||||
*/
|
||||
jwksUri?: string
|
||||
/**
|
||||
* Strategy name surfaced to Payload. Defaults to 'hanzo-iam'.
|
||||
*/
|
||||
name?: string
|
||||
/**
|
||||
* Name of the tenant field on the user (link user -> tenant). When the
|
||||
* multi-tenant plugin is wired, this is the array field it manages.
|
||||
* Defaults to 'tenants'.
|
||||
*/
|
||||
tenantsArrayField?: string
|
||||
/**
|
||||
* Slug of the tenants collection (org == tenant). Defaults to 'tenants'.
|
||||
* A tenant doc is provisioned per IAM org on first login.
|
||||
*/
|
||||
tenantsSlug?: string
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"references": [{ "path": "../payload" }],
|
||||
"compilerOptions": {
|
||||
// Do not include DOM and DOM.Iterable as this is a server-only package.
|
||||
"lib": ["ES2022"],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
.env
|
||||
dist
|
||||
demo/uploads
|
||||
build
|
||||
.DS_Store
|
||||
package-lock.json
|
||||
@@ -0,0 +1,12 @@
|
||||
.tmp
|
||||
**/.git
|
||||
**/.hg
|
||||
**/.pnp.*
|
||||
**/.svn
|
||||
**/.yarn/**
|
||||
**/build
|
||||
**/dist/**
|
||||
**/node_modules
|
||||
**/temp
|
||||
**/docs/**
|
||||
tsconfig.json
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/swcrc",
|
||||
"sourceMaps": true,
|
||||
"jsc": {
|
||||
"target": "esnext",
|
||||
"parser": {
|
||||
"syntax": "typescript",
|
||||
"tsx": true,
|
||||
"dts": true
|
||||
},
|
||||
"transform": {
|
||||
"react": {
|
||||
"runtime": "automatic",
|
||||
"pragmaFrag": "React.Fragment",
|
||||
"throwIfNamespace": true,
|
||||
"development": false,
|
||||
"useBuiltins": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"module": {
|
||||
"type": "es6"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2018-2025 Payload CMS, Inc. <info@payloadcms.com>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of this software and associated documentation files (the
|
||||
'Software'), to deal in the Software without restriction, including
|
||||
without limitation the rights to use, copy, modify, merge, publish,
|
||||
distribute, sublicense, and/or sell copies of the Software, and to
|
||||
permit persons to whom the Software is furnished to do so, subject to
|
||||
the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be
|
||||
included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
||||
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
||||
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"name": "@hanzo/cms-plugin-whitelabel",
|
||||
"version": "1.0.0",
|
||||
"description": "Brand-neutral / white-label theming for Hanzo CMS. Resolves brand (logo, icon, name, colors) by domain; neutral by default.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"default": "./src/index.ts"
|
||||
},
|
||||
"./types": {
|
||||
"import": "./src/types.ts",
|
||||
"types": "./src/types.ts",
|
||||
"default": "./src/types.ts"
|
||||
}
|
||||
},
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "pnpm copyfiles && pnpm build:types && pnpm build:swc",
|
||||
"build:debug": "pnpm build",
|
||||
"build:swc": "swc ./src -d ./dist --config-file .swcrc --strip-leading-paths",
|
||||
"build:types": "tsc --emitDeclarationOnly --outDir dist",
|
||||
"clean": "rimraf -g {dist,*.tsbuildinfo}",
|
||||
"copyfiles": "copyfiles -u 1 \"src/**/*.{html,css,scss,ttf,woff,woff2,eot,svg,jpg,png,json}\" dist/",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hanzo/cms": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hanzo/cms-eslint-config": "workspace:*"
|
||||
},
|
||||
"publishConfig": {
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"./types": {
|
||||
"import": "./dist/types.js",
|
||||
"types": "./dist/types.d.ts",
|
||||
"default": "./dist/types.js"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { Config } from '@hanzo/cms'
|
||||
|
||||
import type { WhiteLabelPluginConfig } from './types.js'
|
||||
|
||||
import { resolveBrand } from './resolveBrand.js'
|
||||
|
||||
export type { Brand, WhiteLabelPluginConfig } from './types.js'
|
||||
export { brandThemeCSS, resolveBrand } from './resolveBrand.js'
|
||||
|
||||
/**
|
||||
* White-label plugin.
|
||||
*
|
||||
* Resolves the active brand for THIS deployment (by `hostEnvVar`, defaulting to
|
||||
* HANZO_CMS_HOST / VERCEL_URL) and injects the brand's logo, icon, name and
|
||||
* meta into the admin config. When nothing resolves, the config is returned
|
||||
* untouched — the fork's brand-neutral defaults apply.
|
||||
*
|
||||
* This is the shared-infra white-label rule (like KMS/IAM/Base): one product,
|
||||
* many brands, selected by domain. Never hardcodes any single brand.
|
||||
*
|
||||
* For a SINGLE deployment serving MULTIPLE brands, point `admin.components.
|
||||
* graphics.Logo/Icon` at your own dispatcher components and call `resolveBrand`
|
||||
* inside them with the per-request host — this plugin's static injection then
|
||||
* only supplies the fallback.
|
||||
*/
|
||||
export const whiteLabelPlugin =
|
||||
(pluginConfig: WhiteLabelPluginConfig) =>
|
||||
(incomingConfig: Config): Config => {
|
||||
if (pluginConfig.enabled === false) {
|
||||
return incomingConfig
|
||||
}
|
||||
|
||||
const host =
|
||||
(pluginConfig.hostEnvVar ? process.env[pluginConfig.hostEnvVar] : undefined) ??
|
||||
process.env.HANZO_CMS_HOST ??
|
||||
process.env.VERCEL_URL ??
|
||||
undefined
|
||||
|
||||
const brand = resolveBrand(pluginConfig.brands, host)
|
||||
|
||||
// Nothing to apply → keep neutral defaults.
|
||||
if (!brand) {
|
||||
return incomingConfig
|
||||
}
|
||||
|
||||
const config = incomingConfig
|
||||
config.admin = config.admin || {}
|
||||
config.admin.components = config.admin.components || {}
|
||||
config.admin.components.graphics = config.admin.components.graphics || {}
|
||||
config.admin.meta = config.admin.meta || {}
|
||||
|
||||
if (brand.logo) {
|
||||
config.admin.components.graphics.Logo = brand.logo
|
||||
}
|
||||
if (brand.icon) {
|
||||
config.admin.components.graphics.Icon = brand.icon
|
||||
}
|
||||
if (brand.name) {
|
||||
config.admin.meta.titleSuffix = `— ${brand.name}`
|
||||
}
|
||||
if (brand.favicon) {
|
||||
config.admin.meta.icons = [{ type: 'image/svg+xml', rel: 'icon', url: brand.favicon }]
|
||||
}
|
||||
if (brand.ogImage) {
|
||||
config.admin.meta.openGraph = {
|
||||
...(config.admin.meta.openGraph || {}),
|
||||
images: [{ url: brand.ogImage }],
|
||||
}
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { Brand } from './types.js'
|
||||
|
||||
/**
|
||||
* Pick the brand for a given host. Longest matching hostname suffix wins;
|
||||
* '*' is the fallback. Returns `undefined` when nothing matches and no '*'
|
||||
* fallback exists — callers then use the brand-neutral defaults.
|
||||
*/
|
||||
export const resolveBrand = (brands: Brand[], host?: null | string): Brand | undefined => {
|
||||
const normalized = (host || '').toLowerCase().split(':')[0].trim()
|
||||
|
||||
let best: Brand | undefined
|
||||
let bestLen = -1
|
||||
let fallback: Brand | undefined
|
||||
|
||||
for (const brand of brands) {
|
||||
for (const hn of brand.hostnames) {
|
||||
if (hn === '*') {
|
||||
fallback = fallback ?? brand
|
||||
continue
|
||||
}
|
||||
const h = hn.toLowerCase()
|
||||
const matches = normalized === h || normalized.endsWith(`.${h}`)
|
||||
if (matches && h.length > bestLen) {
|
||||
best = brand
|
||||
bestLen = h.length
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return best ?? fallback
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize a brand's theme map into a CSS string for a <style> tag.
|
||||
* Keys are CSS custom-property names without the leading `--`.
|
||||
*/
|
||||
export const brandThemeCSS = (brand?: Brand): string => {
|
||||
if (!brand?.theme || Object.keys(brand.theme).length === 0) {
|
||||
return ''
|
||||
}
|
||||
const decls = Object.entries(brand.theme)
|
||||
.map(([k, v]) => ` --${k}: ${v};`)
|
||||
.join('\n')
|
||||
return `:root {\n${decls}\n}\n`
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* A single brand definition for the white-label registry.
|
||||
*
|
||||
* Everything is optional: unset fields fall through to the brand-neutral
|
||||
* defaults that this fork already ships (no product name, abstract mark).
|
||||
*/
|
||||
export type Brand = {
|
||||
/**
|
||||
* Hostnames this brand owns. Matched against the request Host header
|
||||
* (exact or suffix). e.g. ['maxpower.hanzo.cms', 'cms.maxpower.co'].
|
||||
* The special value '*' makes this the fallback brand.
|
||||
*/
|
||||
hostnames: string[]
|
||||
/** Import-map path to a Logo server component, e.g. '@myorg/brand/Logo'. */
|
||||
logo?: string
|
||||
/** Import-map path to an Icon server component. */
|
||||
icon?: string
|
||||
/** Human name, appended to admin page titles as "… — {name}". */
|
||||
name?: string
|
||||
/** Path to a favicon asset served by the app (e.g. '/brand/favicon.svg'). */
|
||||
favicon?: string
|
||||
/** Path to an OpenGraph share image. */
|
||||
ogImage?: string
|
||||
/**
|
||||
* CSS custom properties applied to :root, keyed WITHOUT the leading `--`.
|
||||
* e.g. { 'theme-elevation-1000': '#0b1220', 'color-success-500': '#12b76a' }.
|
||||
* Injected as a <style> tag so a brand can theme colors with zero rebuild.
|
||||
*/
|
||||
theme?: Record<string, string>
|
||||
}
|
||||
|
||||
export type WhiteLabelPluginConfig = {
|
||||
/** Set false to disable without unwiring the plugin. */
|
||||
enabled?: boolean
|
||||
/**
|
||||
* The brand registry. Order matters only for readability; resolution is by
|
||||
* hostname specificity (longest matching suffix wins, then '*').
|
||||
*/
|
||||
brands: Brand[]
|
||||
/**
|
||||
* Env var holding the current request host when SSR can't read it directly.
|
||||
* Defaults to reading the standard Host/x-forwarded-host at request time.
|
||||
*/
|
||||
hostEnvVar?: string
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"references": [{ "path": "../payload" }],
|
||||
"compilerOptions": {
|
||||
// Do not include DOM and DOM.Iterable as this is a server-only package.
|
||||
"lib": ["ES2022"],
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -31,8 +31,10 @@
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@hanzo/cms-figma": ["../enterprise-plugins/packages/figma/src/index.ts"],
|
||||
"@payload-config": ["./test/_community/config.ts"],
|
||||
"@hanzo/cms-auth-iam": ["./packages/auth-iam/src/index.ts"],
|
||||
"@hanzo/cms-plugin-whitelabel": ["./packages/plugin-whitelabel/src/index.ts"],
|
||||
"@hanzo/cms-plugin-whitelabel/types": ["./packages/plugin-whitelabel/src/types.ts"],
|
||||
"@hanzo/cms-admin-bar": ["./packages/admin-bar/src"],
|
||||
"@hanzo/cms-live-preview": ["./packages/live-preview/src"],
|
||||
"@hanzo/cms-live-preview-react": ["./packages/live-preview-react/src/index.ts"],
|
||||
|
||||
Reference in New Issue
Block a user