Skip to content

Develop the frontend for both Volto and Aurora from one repository #132

Description

@sneridagh

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

  • 15 of the 20 files in helpers/ import nothing from Volto, and neither does types/api.ts, the REST contract. Only avatar, redirectToSoleProvider, showPloneLogin and profileGate read the Volto registry or runtime config. clientSchema and providerSchema use react-intl.
  • Of 161 component files, 48 import react-redux, 52 import react-intl and 17 import semantic-ui-react. Only 9 use @plone/components or react-aria, the UI layer both frontends share.
  • Actions are already plain data ({ type, request: { op, path } }), so the endpoint paths can move into a shared table.

Target layout

frontend/
  packages/
    identity-core/      @plone-collective/identity-core   (bridge, MIT)
    volto-identity/     @plone-collective/volto-identity  (adapter, MIT)
    aurora-identity/    @plone-collective/aurora-identity (adapter, MIT)
  volto/                dev harness: mrs.developer → Volto 19, own pnpm workspace
  aurora/               dev harness: cookieplone aurora_addon template → Aurora, own pnpm workspace

Two harnesses, not one workspace:

  • Both core checkouts contain @plone/registry, @plone/components, @plone/types and @plone/scripts, and pnpm refuses duplicate workspace package names.
  • They also differ in React (18.2 against 19.2) and pnpm (10.20 against 11.20).

The Aurora harness comes from uvx cookieplone aurora_addon, with the add-on package moved out to frontend/packages/ and the workspace globs pointed there.

What goes in each package

Package Contains Must not import
identity-core types/ (REST contract), constants/, pure helpers and their tests, an endpoint table, a fetch-based client (createIdentityClient({ apiPath, token, fetch })), and presentational components on @plone/components that take props and return callbacks (LoginCard, ProviderButton, PasswordForm, MagicLinkForm, IdentitiesList, …) @plone/volto*, react-redux, semantic-ui-react, react-intl, react-router*, Aurora packages
volto-identity Redux actions and reducers built on the core endpoint table, routes, customizations/, Volto form widgets, the SignIn block, react-intl wiring, and containers that connect core components to the store Aurora packages
aurora-identity Route modules with loaders and actions that use the core client on the server, slots, the login override, and i18next wiring @plone/volto*, redux

Rules:

  1. Enforce the boundary in lint, the way make check-imports does for the backend: ESLint no-restricted-imports, scoped per package.
  2. Make core i18n-agnostic. Core exports message descriptors (id plus English default), and components read a translate function from an IdentityI18nProvider context. Volto passes intl.formatMessage; Aurora passes i18next's t. Helpers that read the registry take their settings as arguments.
  3. Ship TypeScript source. volto-identity lists identity-core in its addons array so Volto transpiles it. Peer ranges are explicit (react: ^18.2.0 || ^19.0.0), never catalog:, which would be replaced at publish time with whichever harness's versions published.

Phases

  1. Spike: in progress, findings below.
  2. Extract core with no behaviour change. Move types, constants, pure helpers and their tests, parametrise the 4 coupled helpers, and point the Volto actions at the endpoint table. Volto tests, Cypress and coverage stay green. This step pays off even if Aurora is dropped.
  3. Move the login components to core, dropping Semantic UI and react-intl from them. Storybook moves with them.
  4. Aurora vertical slice: a login page that lists providers, the redirect to a provider, and a callback route that sets the session. This step proves the approach or kills it.
  5. Expand by user value: identities, profile, consent, applications. Control panels come last, or stay Volto-only.

Stays Volto-only: customizations/ (Toolbar, PersonalTools, RenderUsers), the Volto form widgets, and possibly the SignIn block.

Repository chores

  • Add towncrier scopes frontend/packages/identity-core/news/ and frontend/packages/aurora-identity/news/, and update AGENTS.md.
  • Check whether repoplone can release more than one frontend package together. If it can't, a coordinated release is blocked until it can.
  • Make CI a matrix: Volto harness, Aurora harness (Playwright), and a core-only unit and Storybook job.
  • Docs: a concepts page on the split, and a reference table of which feature exists in which frontend.

Spike findings so far

pnpm across two harnesses

Listing ../packages/* in a harness's pnpm-workspace.yaml works. The catch: by default pnpm links each shared package's peer dependencies into packages/<pkg>/node_modules, pointing at whichever harness installed last. I reproduced it twice:

  • With throwaway packages, both harnesses resolved the last installer's version, and deleting the other harness's node_modules broke resolution.
  • With the real harnesses, the Volto install linked React 18.2.0 and react-aria-components 1.20.0 into identity-core/node_modules.

What fixed it in the throwaway test:

  • autoInstallPeers: false in both harnesses, and shared packages that declare only peer dependencies. No node_modules then appears inside them.
  • Resolve from the symlink path. That's Vite resolve.preserveSymlinks: true and webpack resolve.symlinks: false. Each harness then saw its own version, and edits to the shared source showed up live in both.
  • injectWorkspacePackages: true did not fix it.

In the Aurora harness the Vite settings go in a small private harness add-on (frontend/aurora/harness/) with a vite.extend.js, which Aurora's registry plugin merges automatically. The same add-on adds ../packages to server.fs.allow. This keeps dev-only resolution settings out of the published aurora-identity.

Dependency gaps

Volto 19.3 Aurora 1.0.0-alpha.15
react 18.2.0 19.2
@plone/components 4.2.1 5.0.0-alpha.4
@plone/registry 3.0.1 4.0.0-alpha.3
router react-router-dom 5 react-router 8
i18n react-intl 3.12 i18next 26 + react-i18next 15
vitest 3 4
pnpm 10.20 11.20
react-aria-components resolved 1.20.0 1.17.0

Good news: every basic @plone/components component 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 the quanta entry, which is where the two versions differ.

Still open

  • A probe component from identity-core, rendered in both harnesses, reporting React 18 in Volto and React 19 in Aurora from one source file
  • Aurora add-on API: route registration and overrides, slots, how the auth cookie reaches loaders and actions, setting the session after an external OAuth callback
  • Whether Volto's webpack runs cleanly with resolve.symlinks: false

Activity

  1. sneridagh commented on Oct 2, 2026

    @sneridagh
    MemberAuthor

    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 (Aurora 1.0.0-alpha.15). aurora-identity moved out to frontend/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-core package with one component, a react-aria Button that prints React.version. Volto shows it inside LoginCard; Aurora shows it in the loginActions slot, 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.js resolve.dedupe, plus server.fs.allow so Vite serves ../packages
    Volto razzle.extend.js resolve.alias, resolved from core/packages/volto

    Both 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 react link 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 through identity-core and react-aria). That is the "Invalid hook call" crash. With the alias, Volto rendered 18.2.0 in this worst case. Aurora's dedupe covers 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 (rs in the console). The same happens for files inside volto-identity, so it is not specific to the shared package.

    Aurora hydration warning

    Aurora logs a hydration mismatch on /login on every load: the client renders the cmsui.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-core into frontend/packages/ with no behaviour change. Moving to frontend/volto/ and frontend/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/client from the outside with custom endpoints (@identity-providers and the rest). Today it means plain fetch in 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-router should work. The token has to be a Plone JWT with sub and exp.
  2. sneridagh commented on Oct 2, 2026

    @sneridagh
    MemberAuthor

    Follow-ups: route overrides, @plone/client extensions, control panels

    These 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's file to an absolute path, so the customizations/ aliases never match it.
    • Registering the same path again loses. Core's login/* was registered first and keeps winning (inferred). Registering a static login wins for /login exactly, but core still serves /login/anything.
    • Duplicate route ids are a hard error in React Router.

    Works today, but fragile:

    • config.routes is a plain mutable array (packages/registry/src/index.ts:144-150), and .plone/registry.routes.json is written only after every add-on's applyConfig has run (packages/registry/bin/init-loaders.js).
    • So an add-on loaded after @plone/cmsui can walk the tree and swap the file of 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:

    1. Give core routes stable ids, for example options: { id: 'cmsui-login' }. Today only index-controlpanel has one.
    2. In @plone/registry, add unRegisterRoute(id) and replaceRoute(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.
    3. getAddonRoutesConfig needs no change. Optionally, match on file.startsWith(name + '/') instead of includes.

    2. Extending @plone/client with custom endpoints

    Already possible in part: Aurora builds the client from a ploneClient utility (apps/aurora/app/config/server.server.ts:19-23, used by PloneClientMiddleware). An add-on can re-register it with a subclass. Two things block that:

    • static initialize is fixed to the base class, so a subclass that inherits it gets PloneClient instances.
    • apiRequest is not exported (exports has only .), so new methods have to make their own requests.

    Proposal A, about 0.5 to 1 day:

    • Export apiRequest and getBackendURL, with their types.
    • Make initialize polymorphic: static initialize<T extends typeof PloneClient>(this: T, c) { return new this(...) as InstanceType<T> }.
    • Export a PloneClientExtensions interface 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 fetch in loaders using cli.config.apiPath and cli.config.token. That is why the core package keeps the endpoint table framework-free.

    Side finding: packages/client/tsup.config.ts lists an entry src/bla.ts that does not exist.

    3. Completing control panels: what exists, and the cost

    What exists:

    • @plone/client already 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 action does nothing. It logs 'Updating control panel data not yet implemented' and redirects (packages/cmsui/routes/controlpanel.tsx:63-65).
    • components/ControlPanel/ControlPanel.tsx is empty.
    • Every special panel in the list (users, groups, undo, add-ons and so on) links to the generic /controlpanel/:id route. 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 action is about half a day; widget parity is 3 to 5 days, and content edit forms benefit too 4–6 days
    Add-on panels: icons and groups in the list, a registerControlPanel helper, a shared panel shell 1.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.

  3. sneridagh commented on Oct 4, 2026

    @sneridagh
    MemberAuthor

    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 uses defineMessages from #i18n and useIdentityUI() instead of react-intl, Volto icons or Semantic UI
    The Volto container Stays in volto-identity, with its Redux state, and renders core's panel
    The Aurora page A route in aurora-identity. Its loader reads from the backend, its action writes, both from the server. It sits behind requireAuthCookie

    IdentityUI grows 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's authenticatedTools slot Linking is POST @identities with the session, not the anonymous start route. Its redirect back already works: the callback sends the session and handles linked
    7 Sign-in follow-ups: first login (/first-login), email confirmation (/confirm-email), and the required-profile gate In Volto the gate is an appExtras entry. In Aurora it would be a check in a loader or middleware, sending an incomplete profile to its form
    8 Applications (/applications): the OAuth clients a user has granted, and revoking them The largest panel (Semantic UI and Volto icons today)
    9 OAuth consent (/oauth-consent), for sites running the [server] layer The backend's server_consent_url names the page. The acceptance test needs an OAuth client of the acceptance site

    Left 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.md updated, so "What Aurora does not have yet" shrinks.
    • News fragments in each scope touched.
  4. sneridagh commented on Oct 4, 2026

    @sneridagh
    MemberAuthor

    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-core main +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, and VoltoIdentityUI
    3 #135 Aurora add-on and harness #134 +21157/−475 18,650 of those lines are frontend/aurora/pnpm-lock.yaml; skip it. Look at aurora-identity/lib/backend.ts (the virtual-host URL and the flow cookie relay), the callback route, and the "Two harnesses share frontend/packages" rules in AGENTS.md
    4 #136 Polish #135 +1934/−527 Aurora 1.0.0-alpha.16, Aurora's /login replaced (lib/routes.ts), --identity-* tokens moved into core, and the Aurora CI workflow with the Playwright acceptance tests
    5 #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_TESTING layer. One backend change: the test layer installs the virtual host monster
    6 #138 Sign-in methods page #137 +1555/−701 /identities, linking a provider through POST @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-profile instead of a toast, and pays one @my-profile request per signed-in page
    8 #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 eye
    9 #141 OAuth consent #140 +1072/−288 Security-relevant: Aurora passes the authorization server's endpoints on to the backend, sending the session as Bearer on @@oauth-authorize only (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 share frontend/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 .po files are the only source; Aurora's common.json files are generated by make i18n in frontend/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 3002 in frontend/aurora. Provider sign-in works only on port 3000, the registered redirect URI. See contributing.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-test

    That runs 14 tests: sign-in, the magic link, the profile gate, linking, and the full OAuth flow including the token exchange.

    State

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions