feat(customization): ship Page Builder auth customization core (#173)
* feat: complete fixed-layout customization core
* feat(customization): integrate auth page builder alpha
* fix(customization): support editor preview and deep links
* fix(customization): align auth runtime and editor
* fix(customization): provide host preview context
* fix(customization): adopt page builder alpha 10
* fix(customization): own auth preview fixtures
* feat(customization): match login page builder template
* feat(customization): make login language switcher page-owned
* feat(auth): support page-built passwordless login
* fix(customization): scope app theme to custom pages
* feat(customization): add scoped application page themes
* feat(customization): adopt page builder alpha.11
* feat(customization): adopt page builder alpha.12 visuals
* fix(auth): accept app-hosted passkey origins
* feat(email): apply brand color to every template
* feat: add page builder compositions
* style: polish compositions overview
* feat: reuse amzettel visual composition
* feat: adopt page builder alpha12 palette
* refactor: hide legacy external login element
* refactor: remove legacy external login element
* fix: route Monaco JSON to its language worker
* chore: update cocoar vue packages to beta19
* fix(startup): stop logging a false error for the inert root key ring
AddTenantedDataProtection deliberately renders ASP.NET Core's root
DataProtection key manager inert — the per-realm providers own the real key
rings. Its DataProtectionHostedService stayed registered though, and that
service resolves the root key ring on StartAsync to surface problems early.
Against an inert manager the read can never succeed, so every cold start
logged two ERROR lines describing a state we chose on purpose. For an IdP
that is expensive noise: an operator has to be able to trust that an error
at boot means something is genuinely broken.
Remove that one hosted-service registration. It has to happen after every
other registration: OpenIddict's builder calls AddDataProtection(), and the
TryAddEnumerable puts the check straight back precisely because we removed
it. Dropping it only inside AddTenantedDataProtection therefore leaves the
boot log unchanged. Program.cs calls the idempotent extension again right
before Build().
Only the root registration is touched. The per-tenant containers keep
normal key generation against MartenXmlRepository, so a genuine per-realm
key-ring failure still surfaces.
Verified on a cold start: zero ERR/FTL lines where there were two, and a
session cookie issued before the change still decrypts afterwards.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(frontend): let the dev server sit behind a local reverse proxy
Vite bound ::1 only, so a proxy pointed at 127.0.0.1:4300 got connection
refused, and it rejected Host headers it did not recognise. Both stand in
the way of reaching the dev server through a local HTTPS proxy — which is
what WebAuthn, Secure cookies and OIDC redirect_uris want, rather than a
bare port. Browsers using localhost:4300 are unaffected.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(customization): accept Page Builder 3.0 documents at the publish boundary
Page Builder 3.0 stamps saved documents as schemaVersion 6, and the one
document change it carries is repeat.props.source -> props.contextPath. Our
server-side publish validator pinned the version at 5 and read the repeat
source only under its old name, so every document the new builder produced
would have been rejected by our own API — twice over, and with a message
pointing at neither cause.
Raise the accepted version and read contextPath first, falling back to
source. That mirrors the package's own migration, which skips when the new
key is already present. MinimumSchemaVersion stays at 4, so documents
stored before the rename keep validating, and the allowlist still bites
through the new spelling.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(customization): move to Page Builder 3.0 and own the auth page contract
3.0 removes `createAuthPageConfig()` and `createAuthPageDocument()`: the
package ships the runtime and nothing auth-specific. That is the right
boundary — the element, action and context allowlists decide what a
tenant-authored document may reach, so they belong to the IdP, not to a
generic authoring library.
The config is now ours (authPageConfig.ts), built from the shape the
package's IDP_INTEGRATION.md describes. The starting documents are ours
too, and rather than re-author them by hand — which would have silently
changed what a realm gets on "create page" — scripts/freeze-auth-documents
captured the existing derivation against the last pre-3.0 build and froze
it as JSON. What a realm gets is what it got before.
Migrated with them, since neither is an automatic ingest migration:
- 14 `visibleWhen: { source: 'state' }` conditions now read
`runtime.viewState` from the runtime context, which every caller already
passed. Page State bindings keep the name; there were none.
- 2 repeaters name their array through `contextPath`, and the documents are
stamped schemaVersion 6.
- The page root no longer carries `minHeight`, in the style or in Root Page
Code. 3.0 drops size on the root because the page is exactly its host
container, and every new page opened with a warning about a line that did
nothing. The container supplies the height already.
Renames applied: `config.fields` → `dataContract`, `config.elements` →
`elementTypes`, `PageVisualFont.source` → `src`. Note that
`normalizePageSchema`'s option key stays `elements` — only the PageConfig
property moved. Removed props: the renderer's `viewState` and the builder's
`previewState`, both redundant with the context. `availableStates` becomes
`runtime.viewState` with `allowedValues`, which is what restores the
dropdown in the condition editor; `previewFixtures` becomes host-owned
`previewViewports`; `stylePresets` is gone with nothing to replace it.
All six @cocoar/vue-* packages pinned to 3.0.0 as the guide asks, so an
authoring package cannot pair with an older renderer.
Verified end to end against the dev instance: create login page → save
(accepted by the schemaVersion 6 publish boundary) → activate in realm
settings → the runtime renders the document through CoarPageRenderer with
the SES page-script worker running. No console errors, no backend errors,
type-check and production build clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(customization): rebuild the amZettel login in the PageBuilder
The reference is the real page at https://app.amzettel.at, and the question
it answers is whether a realm can rebuild its own login without us shipping
anything tenant-specific. It can: the split screen, the brand panel and the
passwordless form are ordinary authored content, going in through the same
API the admin UI uses.
The brand panel is a composition — a visual-markup node with the wordmark,
tagline and the shopping list that ticks itself off. Login and logout can
pin the same immutable version, so updating it is one publish rather than an
edit per page. The form is the flow the reference shows: an email address, a
mailed code, a passkey alternative. The code step is not a second page but
the same document reacting to runtime.viewState.
Colour, radii and fonts are deliberately NOT in the document. They come from
the application theme, which is why amZettel is modelled as what it is — an
application of the realm, on its own subdomain, inheriting the realm's active
login variant. The same document renders in another application's colours
without touching a node.
Two things this surfaced:
- The visual-markup vocabulary was six variables, too thin for a real brand
panel. It is now a documented set (surface, ink shades, lines, brand,
radius, shadow, easing, fonts) fed from the application theme. The names
are generic on purpose: the predecessor was a stylePreset with one
tenant's background baked into our stylesheet, which only that tenant
could use — and which 3.0 removed anyway.
- LoginView decided whether the page owns the language switcher by looking
for the node id `login-language-switcher`, so a page that named its
switcher anything else got a second one drawn by the host. It now matches
on the `auth:toggle-language` action, which is the published contract; node
ids belong to the author.
Verified against the reference at 1280 and on the phone viewport, where the
panel hides itself and the form takes the screen.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(customization): scale the brand panel against its own pane
The wordmark rendered far smaller than the reference. Inside the sealed
visual-markup iframe, `vw` resolves against the iframe — this pane — not the
viewport, so the original design's `clamp(2.6rem, 4.5vw, 3.4rem)` measured
4.5% of 563px and collapsed onto its clamp minimum. The panel is 44% wide,
which makes the equivalent ~10vw.
Worth remembering for any visual-markup content: viewport units inside the
sealed frame are pane units.
The seed script now publishes a new composition version when the panel
changed, rather than silently reusing the old one — published versions are
immutable, and a page stays pinned to what it materialized.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(auth): tolerate refresh response loss without logout
* fix(deps): clear newly reported security advisories
* fix(security): keep TLS validation in seed script
* test(auth): isolate refresh replay clock state
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>