RFC: First-class white-labeling for the EmDash admin & login (completing #639) #1493
marcusbellamyshaw-cell
started this conversation in
Ideas
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
-
Screenshots — the same login screen, branding off vs on
Both screens are the stock login with
authProviders: [github(), google()]configured; the only difference is theadminbranding block (Step 0 fix applied, captured from a running instance via the dev-bypass). Before/after images attached below.1. Motivation
A core EmDash goal is to be a better WordPress. Today EmDash shares WordPress's
biggest white-labeling weakness: you can't fully customize the login and admin
UI without editing core, and such edits are lost on upgrade — the exact
"don't-fork-core / survive-upgrades" problem raised in
#137.
But EmDash is far closer to solving it than WordPress, and the maintainers have
already endorsed the goal:
white-labeling") was approved by @ascorbic ("This sounds like a good
idea. Setting in config makes sense… Happy for you to open a PR") and merged
as #705. Its changelog:
"Adds admin white-labeling support via
adminconfig… Agencies can set acustom logo, site name, and favicon for the admin panel." The original
request explicitly listed "Login/onboarding page (large logo)" as a target
surface.
Login Page Customization") is an active thread where a maintainer-side
contributor (@bergaaberg, who runs a white-label multi-tenant platform)
responded "It's small. It follows how
admin.faviconalready works,"prototyped
admin.customCss/ login-copy overrides / accent color, andoffered to open a PR with a changeset.
config → Site Identity → defaultbranding fallback chain.default login UX is "confusing for non-technical users… doesn't explain what
passkeys are or provide clear fallback guidance."
This RFC unifies those into one design. The incumbents set a low bar to beat:
Strapi gates full wordmark removal behind a paid Enterprise license;
WordPress can't touch login/admin without core edits lost on upgrade. EmDash
can offer free, additive, open, zero-fork white-labeling — and should.
2. Current state (verified against
main)2.1 What already exists (from #639 / #705)
packages/core/src/astro/integration/runtime.ts:503-529— the publicEmDashConfig.adminoption. Its docstring markets it as white-labeling for"agency or enterprise deployments" and promises the logo applies to the
"login page, sidebar":
The same shape is declared in three hand-synced places: the integration
config above, the manifest response interface
packages/core/src/astro/types.ts:194-198, and the admin clientpackages/admin/src/lib/api/client.ts:183-187(AdminManifest.admin).It's plumbed end-to-end: integration → middleware →
locals.emdash.config.admin→ exposed via the manifest route
(
packages/core/src/astro/routes/api/manifest.ts:42,64,75; the value comesfrom
locals.emdash.config.admin, not a build-time global) →fetchManifest()(client.ts:208) → React.The HTML shell honors
siteName/favicon:packages/core/src/astro/routes/admin.astro:22-23,32-33,40,92(page<title>, favicon<link>, boot-loader text). (Astro auto-escapesattribute expressions, so the favicon
hrefisn't an injection vector today;see §7.)
BrandLogo/BrandIconalready accept a custom logo and fall back to thehardcoded EmDash gradient mark
(
packages/admin/src/components/Logo.tsx:156-188).Two authenticated surfaces wire branding correctly via the manifest fetch:
Sidebar (
Sidebar.tsx:394-396) and Setup wizard(
SetupWizard.tsx:553-555).The theming substrate is ready:
styles.css:32-75overrides Kumo'ssemantic tokens under
[data-theme="classic"], andThemeProvider.tsx:71-77documents
data-themeas "reserved for visual identity overrides" — buthardcodes it to
"classic"(line 76).2.2 What's broken or missing
(A) The pre-auth surfaces ignore the branding they're documented to honor.
Three customer-facing, pre-auth screens render the hardcoded EmDash mark:
LoginPage.tsx:214and:226—<BrandLogo … />with nologoUrl/siteName.SignupPage.tsx:397— rendersLogoLockupdirectly (imported at line 20).InviteAcceptPage.tsx:204— same.So a configured
admin.logoappears in the sidebar/setup wizard but not onlogin, signup, or invite — directly contradicting #639's approved scope and
the docstring in §2.1.
(B) These surfaces have no branding source pre-auth. Branding reaches React
via the manifest, but the manifest endpoint is not in the public allowlist
(
PUBLIC_API_PREFIXES/PUBLIC_API_EXACT,auth.ts:94-122) — authenticatedpages (Sidebar, SetupWizard) fetch it fine, but the unauthenticated login/
signup/invite pages cannot. (
/_emdash/api/auth/modeis public — line 115 —and login already calls it.) This is the root cause of (A): there's no
synchronous, pre-auth branding channel.
(C) Branding arrives via an authenticated async fetch, so authenticated
pages briefly flash the EmDash logo before the custom one loads. There's no
single synchronous source of truth, even though
admin.astrohas the configserver-side and simply doesn't pass it to React (
admin.astro:95renders<AdminWrapper>with onlylocale/messages;PluginRegistry.tsx:16-30forwards only those).
(D) Branding is limited to logo + name + favicon. No brand colors/theme, no
custom font, no login background, no custom copy, no dark-mode logo, no
"powered by" control, no custom head/CSS — the gaps #1241 and #826 ask for.
(E) There's no escape hatch for full control. The login UI is a fixed,
single-column centered card (
LoginPage.tsx:222-353) with no seams to changelayout, add a marketing panel, reorder/hide auth methods, or add footer links —
and no way to replace it wholesale with a custom React component, even though
EmDash already has the exact injection pattern (auth-provider/plugin-admin
modules; see §4.2) and a documented react-admin plugin rail.
(F) The default login UX is weak (corroborated by #1132): page background
(
bg-kumo-base) and card background (bg-kumo-base) are identical so the cardbarely separates; magic-link is buried as a
ghostbutton (:284-291); helptext duplicates the card contents (
:321-327); passkeys aren't explained.(G) Other hardcoded "EmDash" surfaces a full white-label must address:
WelcomeModal.tsx:96— hardcodes "Welcome to EmDash, {name}!" (shown toevery new user).
Sidebar.tsx:463— footer falls back to "EmDash CMS" whensiteNameunset.Logo.tsx:74—aria-label="EmDash"on the mark (screen-reader brand name,not customizable).
<title>after SPA navigation may revert from the branded Astro-timetitle (
admin.astro:40).PluginManager.tsx) references "EmDash" (lowerpriority; Lingui strings).
3. Goals & non-goals
Goals
config isn't enough — wrap-don't-reimplement.
signup/invite).
is byte-for-byte today's EmDash.
Non-goals (first PRs) — runtime/DB-stored branding editable from the admin UI
for multi-tenant self-service (#826).
Deliberately deferred; the
BrandingProvider(§4.1) is designed so the samecontext can later be fed from DB settings. This is exactly Directus's model
(§4.5), so the phasing is "config-now, DB-later," not a missing capability.
4. Proposed design (tiered, additive)
One coherent system in three tiers; each independently mergeable. Each tier maps
to an accepted competitor precedent (§4.5) so reviewers see convention, not
novelty.
Step 0 — Fix the pre-auth logo (bug fix; ships first, own lane)
Per CONTRIBUTING.md, this is a bug fix (it completes the approved-and-merged
#639 promise), so it can open directly with a failing test. It needs a pre-auth
branding source, which is the small Tier-1 wiring below; once that's in,
LoginPage,SignupPage, andInviteAcceptPageconsumeuseBranding()and passlogoUrl/siteNametoBrandLogo. A failingLoginPagetest (renders theconfigured logo) locks it in (TDD-for-bugs).
Tier 1 — Server-inject branding (synchronous, pre-auth) + finish the wiring
1a. Add a
BrandingProviderReact context, mirroring the existingPluginAdminProvider(plugin-context.tsx) andAuthProviderProvider(
auth-provider-context.tsx) exactly.New file
packages/admin/src/lib/branding-context.tsx:1b. Thread
brandingthrough the existing props pipeline, identical topluginAdmins/authProviders.packages/admin/src/App.tsx:import { AuthProviderProvider, type AuthProviders } from "./lib/auth-provider-context"; +import { BrandingProvider, type AdminBranding } from "./lib/branding-context"; import { PluginAdminProvider, type PluginAdmins } from "./lib/plugin-context"; export interface AdminAppProps { pluginAdmins?: PluginAdmins; authProviders?: AuthProviders; + /** White-label branding, injected server-side from admin.astro */ + branding?: AdminBranding; locale?: string; messages?: Messages; } const EMPTY_AUTH_PROVIDERS: AuthProviders = {}; +const EMPTY_BRANDING: AdminBranding = {}; export function AdminApp({ pluginAdmins = EMPTY_PLUGINS, authProviders = EMPTY_AUTH_PROVIDERS, + branding = EMPTY_BRANDING, locale = "en", messages = {}, }: AdminAppProps) { ... return ( <ThemeProvider> + <BrandingProvider branding={branding}> <I18nProvider i18n={i18n}> ... <RouterProvider router={router} /> ... </I18nProvider> + </BrandingProvider> </ThemeProvider> ); }Export from
packages/admin/src/index.ts:+export { BrandingProvider, useBranding, type AdminBranding } from "./lib/branding-context";packages/core/src/astro/routes/PluginRegistry.tsx— this file is in core andalready imports from
@emdash-cms/admin(line 9), so importing the type here isvalid (the dependency edge already exists):
import { AdminApp } from "@emdash-cms/admin"; +import type { AdminBranding } from "@emdash-cms/admin"; import type { Messages } from "@lingui/core"; interface AdminWrapperProps { locale: string; messages: Messages; + branding?: AdminBranding; } -export default function AdminWrapper({ locale, messages }: AdminWrapperProps) { +export default function AdminWrapper({ locale, messages, branding }: AdminWrapperProps) { return ( <AdminApp pluginAdmins={pluginAdmins} authProviders={authProviders} locale={locale} messages={messages} + branding={branding} /> ); }packages/core/src/astro/routes/admin.astroalready hasadminConfig(line 22):1c. Fix the three pre-auth surfaces to read
useBranding()and passlogoUrl={branding.logo}/siteName={branding.siteName}toBrandLogo(
LoginPage.tsx:214,226;SignupPage.tsx:397;InviteAcceptPage.tsx:204).1d. Make the brand mark accessible.
BrandLogo/LogoLockupshould accept anaria-labelso the screen-reader name matchessiteNameinstead of thehardcoded
aria-label="EmDash"(Logo.tsx:74).1e. Migrate Sidebar/SetupWizard to
useBranding()(single synchronoussource; removes the flash), keeping the manifest field as a fallback. Change the
Sidebar footer fallback (
Sidebar.tsx:463) from"EmDash CMS"tositeNameora neutral default, and make
WelcomeModal(WelcomeModal.tsx:96) greet withsiteName.Tier 1b — Declarative login customization (feature)
Render
branding.login.{layout,background,heading,subheading}in the defaultLoginPage(centered vs split, brand panel/background, custom copy) + applyhidePoweredByandlogoDark. Maps to #1241's request and Strapi'sconfig.auth(§4.5).Tier 2 — Component override / slot system (the escape hatch; login-first)
Let integrators replace specific surfaces with their own React components —
modeled on the existing auth-provider/plugin-admin injection (props → virtual
module → context → resolver) and the documented react-admin plugin rail.
New
packages/admin/src/lib/branding-components.tsx(mirrorsplugin-context.tsx):renderDefault(borrowed from Sanity Studio) is the key ergonomic: everyoverride receives the built-in surface, and EmDash already exports the auth
primitives (
PasskeyLogin,useAuthProviderList,fetchAuthMode), so a customLoginScreencan wrap/extend instead of reimplementing WebAuthn/magic-link. Thisdirectly answers the maintainers' likely worry that a custom login will rot when
internal auth changes.
Default surfaces consult the override first, e.g.:
Integration wiring follows the exact
authProvidersprecedent(
vite-config.ts:35-36,191-193,253-255;virtual-modules.ts—VIRTUAL_AUTH_PROVIDERS_ID/generateAuthProvidersModule):EmDashConfig.admin.components?: string(a module path) → newVIRTUAL_BRANDING_COMPONENTS_ID+generateBrandingComponentsModule()→PluginRegistry.tsximports it →AdminAppwraps inBrandingComponentsProvider. No new architecture.Tier 3 — Design tokens + theme + head injection (visual rebrand, no JS)
data-themefrom hardcoded"classic"to branding-driven(
ThemeProvider.tsx:76).admin.colorsinadmin.astromappingto the documented Kumo vars (
--color-kumo-brand,--color-kumo-brand-hover,--text-color-kumo-brand) — server-rendered, no flash.admin.customCss(a file path, like Payload'sadmin.css),injected into the admin shell. Scope it via cascade layers (
@layer) and theadmin-root selector to avoid the public-page bleed already tracked in issue
#1281 — aligning with the
fix direction the maintainers are already discussing.
fontsintegration option(
styles.css:78-90); document it as part of branding.A white-label "theme" is just a
[data-theme="<id>"]token bundle — the samemodel the theme marketplace manipulates
(
ThemeMarketplace*.tsx,lib/api/theme-marketplace.ts); Tier 3 should reuse itrather than fork it.
4.5 Precedent (each tier is conventional)
admin: { logo, siteName, favicon, colors }admin.meta(icons[],titleSuffix); Strapiconfig.auth.logo(login) vsconfig.menu.logo(nav),head.favicon,theme.light/darkadmin.login.{heading,…}config.auth+translations; Directuspublic_backgroundadmin.components→BrandingComponentsadmin.components.graphics.Logo(the login mark) / Sanitystudio.components.{logo,navbar,layout}+renderDefaultadmin.colors→ Kumo vars,admin.customCsstheme.*token overrides; Directuscustom_css+theme_*_overridesBrandingProviderfed from settingssettings(project_logo/project_color/custom_css)Two precedents are especially decisive: Strapi gives the login logo its own
config.auth.logofield distinct from the nav logo — independent evidence thatlogin deserves first-class treatment (the §2.2A bug). And Payload's
admin.components.graphics.Logois literally the login-view mark — confirming"login-first" framing. (APIs cited are the current shapes: Payload v3
admin.meta.icons[]/admin.cssas a path /components.graphics.*; Sanitypasses real imported components, not string paths.)
5. Example usage
No-code rebrand:
Full takeover:
6. Default login UX improvements (responds to #1132)
Grounded in
LoginPage.tsx:212-353:bg-kumo-base(:222,:244); usebg-kumo-elevated/the brand background so the card reads as a surface.login.layout: "split") — brand panel + form.ghostafter a confusing "Or continue with" divider (:255-291); Bug Report + UX: Subscriber role has excessive admin access + login page needs overhaul #1132 saysusers don't understand passkeys. Present passkey/email as clear peers with a
one-line passkey explainer and fallback guidance.
:321-327).logoDark).Before/after screenshots (captured via the documented dev-bypass,
GET /_emdash/api/setup/dev-bypass?redirect=/_emdash/admin) will be attached.7. Cross-cutting concerns (merge-readiness)
today's EmDash exactly. Additive — no migrations, no breaks.
Astro, which auto-escapes attribute expressions; the existing
escapeHtmlAttrhelper (
packages/core/src/page/metadata.ts:37, as used insite-identity.ts:49) is available for any non-template interpolation.customCssis build-time/trusted but should still be scoped via cascadelayers + the admin-root selector (re: issue Admin stylesheet bleeds into public pages in
astro dev, overriding host design tokens (layer-ordering) #1281) and run throughsanitize-html(already a core dep,packages/core/package.json) whereuser-supplied HTML is involved; document CSP implications.
useLingui/<Trans>), RTL-safelogical Tailwind. Per CONTRIBUTING.md, do not commit
messages.poinfeature PRs.
LoginPagetest that asserts a configured logorenders (locks the §2.2A fix) +
useBrandingunit test;packages/core/tests/unit/astro/manifest-route.test.ts:47-82already coversthe existing fields and gets new cases as fields are added.
is currently undocumented (no mention in
reference/configuration.mdx,concepts/admin-panel.mdx, orthemes/overview.mdx); a welcome docs win.@emdash-cms/admin+emdash/core."Approved for PR" label for the feature tiers; Step 0 ships as a direct
bug-fix PR with a failing test; AI disclosure included; small PRs per §8.
8. Rollout (small, reviewable PRs)
BrandingProvider+ threadbrandingthroughadmin.astro → AdminWrapper → AdminApp, fix login/signup/invite logos +aria-label, failing-test-first. (Completes Admin white-labeling: custom logo and site name in the admin UI #639; bug-fix lane.)
loginconfig,hidePoweredBy,logoDark,Sidebar/WelcomeModal fallbacks, §6 polish.
renderDefault.colors→ Kumo tokens, branding-drivendata-theme,layer-scoped
customCss.into the same
BrandingProvider.9. Why this approach
Feature Request: Admin UI & Login Page Customization #1241/feat(admin): use Site Identity logo/favicon as admin panel branding default #826/Bug Report + UX: Subscriber role has excessive admin access + login page needs overhaul #1132/Does EmDash need a child theme concept? #137 — convention, not novelty.
pipeline; the documented react-admin rail) — no new architecture.
bug-fix shipping first.
component replacement for the hard case, and a better default for everyone —
and beats the incumbents (free vs Strapi's paid wordmark removal; zero-fork vs
WordPress core edits).
Beta Was this translation helpful? Give feedback.
All reactions