Repository navigation
Develop the frontend for both Volto and Aurora from one repository #132
Description
Activity
Spike results
The layout works. One shared component rendered on Volto with React 18.2.0 and on Aurora with React 19.3.0, from the same source file, with both dev servers running at once. That held in the server render and after hydration in the browser.
Setup
- Aurora harness: generated with
uvx cookieplone aurora_addon(Aurora1.0.0-alpha.15).aurora-identitymoved out tofrontend/packages/. The template needed only three changes: the workspace globs, the add-on location, and the lint paths. - Volto harness: a copy of today's
frontend/, pointed at../packages/*. - Probe: a minimal
identity-corepackage with one component, a react-ariaButtonthat printsReact.version. Volto shows it insideLoginCard; Aurora shows it in theloginActionsslot, the slot Aurora's docs name for SSO buttons.
Correction to the notes above: not
autoInstallPeers: false- Stops the clash, breaks Volto. It does keep pnpm from linking peers into the shared package. But in the Volto 19 workspace it also splits webpack into two peer variants: the real lockfile resolves all 84 webpack references with the esbuild peer, while this install resolved only 39 that way. Volto then dies at startup with
The 'compilation' argument must be an instance of Compilation. - Optional peers don't help.
peerDependenciesMeta: { optional: true }gets linked into the shared package as well.
What works: both harnesses stay on pnpm's defaults, and the bundler makes the shared package's peers resolve to the harness's own copies. Each harness has a small private, dev-only add-on that does this:
Harness File Setting Aurora vite.extend.jsresolve.dedupe, plusserver.fs.allowso Vite serves../packagesVolto razzle.extend.jsresolve.alias, resolved fromcore/packages/voltoBoth read the peer list from
identity-core/package.json, so there is no second list to maintain.Why Volto needs the alias: with it removed, and the shared
reactlink pointing at the Aurora harness, Volto's client bundle contained both React 18.2.0 (701 module references) and React 19.3.0 (30, pulled in throughidentity-coreand react-aria). That is the "Invalid hook call" crash. With the alias, Volto rendered 18.2.0 in this worst case. Aurora'sdedupecovers the reverse case.Live editing
- Aurora: one edit to the shared source showed up straight away, server render included.
- Volto: the browser updated through hot reload; the server render needed a nudge (
rsin the console). The same happens for files insidevolto-identity, so it is not specific to the shared package.
Aurora hydration warning
Aurora logs a hydration mismatch on
/loginon every load: the client renders thecmsui.auth.*i18n keys untranslated. I restarted stock Aurora with neither add-on and saw the same error, so it is upstream. Like the one on the root route, it is probably dev-mode only.Decisions
- react-aria-components: use one version in both harnesses (today 1.20.0 in Volto, 1.17.0 to 1.21.1 in Aurora), and push for the same upstream later.
- Phase 1 stays inside today's harness. It extracts
identity-coreintofrontend/packages/with no behaviour change. Moving tofrontend/volto/andfrontend/aurora/waits for the Aurora work.
Follow-ups (research in progress)
- Overriding core routes from an add-on (for example
/login): Aurora has no route override or unregister API. - Extending
@plone/clientfrom the outside with custom endpoints (@identity-providersand the rest). Today it means plainfetchin loaders. - Completing control panels in Aurora: saving is not implemented (
cmsui/routes/controlpanel.tsx), and add-ons have no way to register a custom panel. Cost estimate to follow. - Aurora OAuth callback, end to end (phase 3):
setAuthOnResponse(redirect(url), token, { expires })from@plone/react-routershould work. The token has to be a Plone JWT withsubandexp.
- Aurora harness: generated with
Follow-ups: route overrides,
@plone/clientextensions, control panelsThese come from reading Aurora's code at
1.0.0-alpha.15(d739952fd). None of the proposals has been built or tested yet. Points marked (inferred) were reasoned out, not confirmed in the code.1. Overriding a core route from an add-on
There is no API for it today:
- Shadowing does not reach route modules.
getAddonRoutesConfig(packages/react-router/src/index.ts) rewrites each route'sfileto an absolute path, so thecustomizations/aliases never match it. - Registering the same path again loses. Core's
login/*was registered first and keeps winning (inferred). Registering a staticloginwins for/loginexactly, but core still serves/login/anything. - Duplicate route ids are a hard error in React Router.
Works today, but fragile:
config.routesis a plain mutable array (packages/registry/src/index.ts:144-150), and.plone/registry.routes.jsonis written only after every add-on'sapplyConfighas run (packages/registry/bin/init-loaders.js).- So an add-on loaded after
@plone/cmsuican walk the tree and swap thefileof the login entry. - It depends on the shape of the route tree and on matching file strings.
Proposed upstream API, about 1 day including docs:
- Give core routes stable ids, for example
options: { id: 'cmsui-login' }. Today onlyindex-controlpanelhas one. - In
@plone/registry, addunRegisterRoute(id)andreplaceRoute(id, entry). The replacement keeps the entry's position and id, so the parent layout,useRouteLoaderData(id)and ordering are unchanged. About 50 lines plus tests. getAddonRoutesConfigneeds no change. Optionally, match onfile.startsWith(name + '/')instead ofincludes.
2. Extending
@plone/clientwith custom endpointsAlready possible in part: Aurora builds the client from a
ploneClientutility (apps/aurora/app/config/server.server.ts:19-23, used byPloneClientMiddleware). An add-on can re-register it with a subclass. Two things block that:static initializeis fixed to the base class, so a subclass that inherits it getsPloneClientinstances.apiRequestis not exported (exportshas only.), so new methods have to make their own requests.
Proposal A, about 0.5 to 1 day:
- Export
apiRequestandgetBackendURL, with their types. - Make
initializepolymorphic:static initialize<T extends typeof PloneClient>(this: T, c) { return new this(...) as InstanceType<T> }. - Export a
PloneClientExtensionsinterface that the class merges with, so add-ons can type their methods through module augmentation.
Proposal B, builds on A, about 1 to 1.5 days:
PloneClient.extend({ getIdentityProviders(this: PloneClient) { ... } })returns a subclass. Each add-on extends whatever the utility currently returns, so several add-ons chain instead of clobbering each other. B is the one I'd suggest.Until then, the zero-change option is plain
fetchin loaders usingcli.config.apiPathandcli.config.token. That is why the core package keeps the endpoint table framework-free.Side finding:
packages/client/tsup.config.tslists an entrysrc/bla.tsthat does not exist.3. Completing control panels: what exists, and the cost
What exists:
@plone/clientalready covers it:getControlpanel,updateControlpanel(PATCH) and the users, groups, types, rules and other endpoints.- The panel form already submits JSON.
What is missing:
- The route's
actiondoes nothing. It logs'Updating control panel data not yet implemented'and redirects (packages/cmsui/routes/controlpanel.tsx:63-65). components/ControlPanel/ControlPanel.tsxis empty.- Every special panel in the list (users, groups, undo, add-ons and so on) links to the generic
/controlpanel/:idroute. plone.restapi doesn't serve those as control panels, so these pages most likely fail (inferred). - The widget registry lacks select, multi-select, tokens, textarea, number, password, email or URL, JSON or dict, rich text, and vocabulary-backed select. Those fields fall back to
TextField, which most likely breaks saving them (inferred).
Rough estimates for one developer who knows the codebase (guesses, not measurements):
Work Estimate Generic panels with save: the actionis about half a day; widget parity is 3 to 5 days, and content edit forms benefit too4–6 days Add-on panels: icons and groups in the list, a registerControlPanelhelper, a shared panel shell1.5–2.5 days Special panels: users 3–4, content-type schema editor 4–6, rules 3–4, the rest 0.5–3 each 15–25 days In total, about 3 weeks makes generic panels and the add-on API usable, and full parity with Volto's panels is another 3 to 5 weeks.
- Shadowing does not reach route modules.
- added 5 commits that reference this issue
on Oct 4, 2026 Plan: the Aurora pages (phases 6 to 9)
Phases 1 to 5 are #133, #134, #135, #136 and #137, all draft and stacked. What follows covers the remaining pages, one stacked draft PR per phase. Control panels stay Volto-only, as agreed.
How each page is built
The same split as the login page in phase 2:
Part Where it goes The presentational panel Moves to identity-core. It usesdefineMessagesfrom#i18nanduseIdentityUI()instead of react-intl, Volto icons or Semantic UIThe Volto container Stays in volto-identity, with its Redux state, and renders core's panelThe Aurora page A route in aurora-identity. Its loader reads from the backend, its action writes, both from the server. It sits behindrequireAuthCookieIdentityUIgrows only as the panels need it: more icons, a confirmation for destructive actions, and a "something happened" notice where Volto uses a toast.Each Aurora page gets a Playwright test in the harness's acceptance suite, beside the sign-in ones.
Phases
Phase Pages Notes 6 Identities ( /identities): linked identities and the profile's addresses. Link another provider, unlink one, verify an address by email. Plus the user-menu entry, in Aurora'sauthenticatedToolsslotLinking is POST @identitieswith the session, not the anonymous start route. Its redirect back already works: the callback sends the session and handleslinked7 Sign-in follow-ups: first login ( /first-login), email confirmation (/confirm-email), and the required-profile gateIn Volto the gate is an appExtrasentry. In Aurora it would be a check in a loader or middleware, sending an incomplete profile to its form8 Applications ( /applications): the OAuth clients a user has granted, and revoking themThe largest panel (Semantic UI and Volto icons today) 9 OAuth consent ( /oauth-consent), for sites running the[server]layerThe backend's server_consent_urlnames the page. The acceptance test needs an OAuth client of the acceptance siteLeft for later, unless you want them in: the Welcome card, the profile view of a user's content object, and the sign-in block. That last one needs Aurora's block API.
Per phase
- Panels moved with their stories and tests, and Volto's existing tests passing unchanged.
- An Aurora route with loader and action tests.
- A Playwright test.
- Docs:
concepts/frontends.mdupdated, so "What Aurora does not have yet" shrinks. - News fragments in each scope touched.
- added a commit that references this issue
on Oct 4, 2026 How to review the #132 series
@ericof, this is a guide for the nine stacked PRs that make the frontend work on both Volto and Plone Aurora. Each PR is built on the one before it. Review each one against its own base, which GitHub's "Files changed" shows by default, so every diff is one phase only.
The PRs, in order
# PR Base Size What to look at 1 #133 Extract identity-coremain+926/−288 The architecture. The package split, the ESLint boundary that keeps Volto, Aurora, Redux, routers and i18n libraries out of core, and the endpoint table 2 #134 Login components into core #133 +1256/−650 The pattern every later phase repeats: IdentityUIProvider(translate, link, icons), messages through#i18n, andVoltoIdentityUI3 #135 Aurora add-on and harness #134 +21157/−475 18,650 of those lines are frontend/aurora/pnpm-lock.yaml; skip it. Look ataurora-identity/lib/backend.ts(the virtual-host URL and the flow cookie relay), the callback route, and the "Two harnesses sharefrontend/packages" rules inAGENTS.md4 #136 Polish #135 +1934/−527 Aurora 1.0.0-alpha.16, Aurora's /loginreplaced (lib/routes.ts),--identity-*tokens moved into core, and the Aurora CI workflow with the Playwright acceptance tests5 #137 React Aria versions, magic-link test #136 +284/−111 The pnpm overrides in both harnesses, and the acceptance server switched to the add-on's own ACCEPTANCE_TESTINGlayer. One backend change: the test layer installs the virtual host monster6 #138 Sign-in methods page #137 +1555/−701 /identities, linking a provider throughPOST @identities, and a bug fix in #135's callback (a completed link landed on/)7 #139 Email confirmation and profile gate #138 +1562/−354 A UX difference from Volto: Aurora holds an incomplete profile at /complete-profileinstead of a toast, and pays one@my-profilerequest per signed-in page8 #140 Applications page #139 +1773/−622 ICU plurals in core's interpolate,ConfirmDialog, and Semantic UI's table and buttons rewritten as plain elements with Semantic UI's classes. Please check the Volto applications page by eye9 #141 OAuth consent #140 +1072/−288 Security-relevant: Aurora passes the authorization server's endpoints on to the backend, sending the session as Beareron@@oauth-authorizeonly (lib/oauth.ts)Each PR description has a "Checks" table and lists what is deliberately left out.
Where the decisions are
Each of these is explained where it is made, and most also in
docs/docs/concepts/frontends.md, which covers the split.- Two harnesses on one
packages/:AGENTS.md, "Two harnesses sharefrontend/packages". Bundling, typechecking, Storybook, catalogues, React Aria. Every rule there came from a failure that was reproduced first. - Aurora gaps worked around: route replacement and insertion (
lib/routes.ts), no add-on content expansions (config/server.ts), and no app-wide toast (/complete-profile). These are worth raising with Aurora upstream. - Translations: core's
.pofiles are the only source; Aurora'scommon.jsonfiles are generated bymake i18ninfrontend/aurora. One new message, "Your profile is complete." (Aurora: email confirmation and the profile gate (#132, phase 7) #139), was translated by me into de, es and pt_BR: worth a native speaker's look.
Running it
make install && make backend-create-site && make backend-start make frontend-start # Volto, port 3000 (its webpack takes 3001) make aurora-install && make aurora-start # Aurora, port 3000, instead
Side by side, run Aurora on port 3002 with
pnpm start --port 3002infrontend/aurora. Provider sign-in works only on port 3000, the registered redirect URI. Seecontributing.md, "Testing both frontends".The end-to-end suite runs from
frontend/aurora. It needs three services, each in its own shell:make acceptance-backend-start make acceptance-dex-start make acceptance-frontend-start
Then:
make acceptance-provider && make acceptance-testThat runs 14 tests: sign-in, the magic link, the profile gate, linking, and the full OAuth flow including the token exchange.
State
- CI is green on Extract a framework-agnostic identity-core frontend package #133 to Aurora: the applications page (#132, phase 8) #140, including the Aurora acceptance jobs; Aurora: the OAuth consent screen, and serving the authorization server (#132, phase 9) #141's run is in progress.
- Stacking. Nothing should be merged until the whole series is reviewed. Merging from the bottom up keeps each diff as reviewed.
- Out of scope:
- control panels in Aurora;
- the first-login route;
- a release.
identity-coreandaurora-identitywait for repoplone to publish more than one frontend package.
- Two harnesses on one
Goal
Develop the frontend of this solution for both Volto and Plone Aurora from one repository. A framework-agnostic bridge package holds everything both frontends share, and two thin adapters hold everything they don't. Each frontend keeps its own dev harness and build.
Starting point
helpers/import nothing from Volto, and neither doestypes/api.ts, the REST contract. Onlyavatar,redirectToSoleProvider,showPloneLoginandprofileGateread the Volto registry or runtime config.clientSchemaandproviderSchemausereact-intl.react-redux, 52 importreact-intland 17 importsemantic-ui-react. Only 9 use@plone/componentsor react-aria, the UI layer both frontends share.{ type, request: { op, path } }), so the endpoint paths can move into a shared table.Target layout
Two harnesses, not one workspace:
@plone/registry,@plone/components,@plone/typesand@plone/scripts, and pnpm refuses duplicate workspace package names.The Aurora harness comes from
uvx cookieplone aurora_addon, with the add-on package moved out tofrontend/packages/and the workspace globs pointed there.What goes in each package
identity-coretypes/(REST contract),constants/, pure helpers and their tests, an endpoint table, afetch-based client (createIdentityClient({ apiPath, token, fetch })), and presentational components on@plone/componentsthat take props and return callbacks (LoginCard,ProviderButton,PasswordForm,MagicLinkForm,IdentitiesList, …)@plone/volto*,react-redux,semantic-ui-react,react-intl,react-router*, Aurora packagesvolto-identitycustomizations/, Volto form widgets, the SignIn block,react-intlwiring, and containers that connect core components to the storeaurora-identity@plone/volto*, reduxRules:
make check-importsdoes for the backend: ESLintno-restricted-imports, scoped per package.IdentityI18nProvidercontext. Volto passesintl.formatMessage; Aurora passes i18next'st. Helpers that read the registry take their settings as arguments.volto-identitylistsidentity-corein itsaddonsarray so Volto transpiles it. Peer ranges are explicit (react: ^18.2.0 || ^19.0.0), nevercatalog:, which would be replaced at publish time with whichever harness's versions published.Phases
react-intlfrom them. Storybook moves with them.Stays Volto-only:
customizations/(Toolbar, PersonalTools, RenderUsers), the Volto form widgets, and possibly the SignIn block.Repository chores
frontend/packages/identity-core/news/andfrontend/packages/aurora-identity/news/, and updateAGENTS.md.repoplonecan release more than one frontend package together. If it can't, a coordinated release is blocked until it can.Spike findings so far
pnpm across two harnesses
Listing
../packages/*in a harness'spnpm-workspace.yamlworks. The catch: by default pnpm links each shared package's peer dependencies intopackages/<pkg>/node_modules, pointing at whichever harness installed last. I reproduced it twice:node_modulesbroke resolution.identity-core/node_modules.What fixed it in the throwaway test:
autoInstallPeers: falsein both harnesses, and shared packages that declare only peer dependencies. Nonode_modulesthen appears inside them.resolve.preserveSymlinks: trueand webpackresolve.symlinks: false. Each harness then saw its own version, and edits to the shared source showed up live in both.injectWorkspacePackages: truedid not fix it.In the Aurora harness the Vite settings go in a small private harness add-on (
frontend/aurora/harness/) with avite.extend.js, which Aurora's registry plugin merges automatically. The same add-on adds../packagestoserver.fs.allow. This keeps dev-only resolution settings out of the publishedaurora-identity.Dependency gaps
@plone/components@plone/registryGood news: every basic
@plone/componentscomponent this add-on uses (Tabs, Container, SearchField, Button, TextField) has byte-identical source in both versions, and so do their CSS files. Nothing here uses thequantaentry, which is where the two versions differ.Still open
identity-core, rendered in both harnesses, reporting React 18 in Volto and React 19 in Aurora from one source fileresolve.symlinks: false