Auth SPA — Kratos Custom UI
Vue 3 + Vuetify library providing custom UI for Ory Kratos self-service browser flows.
This document describes the intended architecture. When code diverges from what is described here, treat the document as correct and the code as needing a fix.
Package exports
| Entry | Purpose |
|---|---|
@saflib/ory-kratos-spa | configureAuthApp, auth fallback inject |
./router | createKratosAuthRouter — logged-out flows + logout only |
./session-routes | kratosSessionRouteRecords() — settings + verify-wall (mount on auth or account) |
./settings | SettingsSectionAsync for embedding settings in account (or another SPA) |
./registration | Registration override building blocks |
./verification | VerificationAsync / NewVerificationAsync for account embeds |
./fixtures | Playwright fixtures (login, logout, registration, verify-wall) |
./strings, ./i18n, ./test-app | i18n and vitest helpers |
There is no catch-all export. Prefer the entries above over deep pages/... paths.
Auth vs session placement
auth SPA → createKratosAuthRouter (login, registration, recovery, verification, logout)
account → SettingsSectionAsync on product routes (/email, /password, /mfa, …)
optional → ...kratosSessionRouteRecords() on auth when you still want /settings + /verify-wallA product may mount session routes on auth for security e2e / recovery path-only redirects, and embed settings on account for day-to-day UX. Configure recovery → settings via configureAuthApp({ postRecoverySettingsHref }).
Page structure
Each Kratos flow gets three route-level files:
| File | Role |
|---|---|
*Async.vue | Lazy wrapper (defineAsyncComponent) |
*.vue (page) | Fetches the flow, handles error states (expired, CSRF, unhandled) |
*FlowForm.vue | Renders the live flow form; owns submit logic |
A page fetches the flow via a *.loader.ts composable and hands the resulting flow object to the form component. The form component configures KratosFlowUi (the universal renderer) with props and listens for @submit.
Routes are defined in router.ts using link constants from @saflib/ory-kratos-sdk.
File conventions
| Suffix | Purpose |
|---|---|
.vue | Vue SFC (component or page) |
.logic.ts | Pure, framework-free helper functions. Must be unit-testable without DOM or Vue. |
.logic.test.ts | Tests for the adjacent .logic.ts |
.test.ts | Component / integration tests (may use Vue Test Utils or Playwright) |
.loader.ts | TanStack Query composable that fetches a Kratos flow |
.strings.ts | UI copy as plain exported objects (keys are logical names, values are English strings) |
Naming rules
- Files in
common/use akratosprefix for pure helpers (e.g.kratosNodeUtils.ts) and auseKratosprefix for Vue composables (e.g.useKratosFieldModelsForNodes.ts). Ory-specific browser glue uses anoryprefix (e.g.oryWebAuthnWindow.ts). - Flow-specific files live in their flow directory (
login/,registration/, etc.). - General-purpose Kratos node utilities (
isKratosInputNode,kratosEffectiveInputType, etc.) belong incommon/, not in a flow-specific logic file.
KratosFlowUi — the universal node renderer
common/KratosFlowUi.vue is the single component that turns a Kratos UiContainer into Vuetify form fields. Every flow form uses it.
Props (should be limited to concerns used by multiple consumers)
| Prop | Type | Default | Purpose |
|---|---|---|---|
flow | KratosFlowUiModel | required | The Kratos flow whose ui.nodes / ui.messages to render |
nodes | UiNode[] | flow.ui.nodes | Override node list (settings uses this for per-group subsets) |
submitting | boolean | required | Loading state — disables fields, hides stale errors |
idPrefix | string | "kratos-flow" | Prefix for element id attributes |
hideSubmitNames | string[] | [] | Omit specific submit buttons by name (recovery / verification hide email) |
messageFilter | function | — | Return false to suppress a Kratos message (registration hides "Property password is missing" on step 1) |
interceptOryProgrammaticSubmit | boolean | false | Patch form.submit() so Ory's webauthn.js triggers the SPA submit handler instead of a full navigation |
identityPasskeyDisplayFallback | string | — | Fallback label for "unnamed" passkeys in settings remove buttons |
Props that only a single consumer needs (e.g. login-specific passkey merge, MFA tab layout) should not live on KratosFlowUi. Instead, the consuming page should use slots, KratosFlowUiNodeAt props, or its own composable to own that behavior.
Slots
KratosFlowUi exposes two scoped slots for flow-specific customization. Use the narrowest one that fits.
#node — per-node override
For tweaking how individual nodes render (custom icons, extra wrappers, hiding a specific field):
<KratosFlowUi :flow="flow" :submitting="submitting" @submit="onSubmit">
<template #node="{ node, idx }">
<!-- Custom rendering for a specific node, e.g.: -->
<MyCustomField v-if="node.attributes.name === 'special'" :node="node" />
<!-- Fall back to default for everything else -->
<KratosFlowUiNodeAt v-else :idx="idx" />
</template>
</KratosFlowUi>#fieldset — full layout override
For restructuring the entire field layout (e.g. splitting nodes into tabs, reordering groups). The login page uses this for MFA second-factor tabs:
<KratosFlowUi :flow="flow" :submitting="submitting" @submit="onSubmit">
<template #fieldset="{ displayNodes, allNodeIndices }">
<!-- Full control over layout; render KratosFlowUiNodeAt for each node -->
<KratosFlowUiNodeAt v-for="idx in allNodeIndices" :key="idx" :idx="idx" />
</template>
</KratosFlowUi>The #fieldset slot receives:
displayNodes— the resolved node list (readonly UiNode[])allNodeIndices— index array for iteration (readonly number[])
When #fieldset is provided, #node is not used (the fieldset consumer renders KratosFlowUiNodeAt directly and controls the full structure).
Default node order is whatever Kratos returns in flow.ui.nodes. Flows that need a different order should pass a sorted copy via the nodes prop (see RegistrationFlowForm.vue, which uses sortRegistrationFlowNodes in kratosRegistrationNodeOrder.logic.ts for first → last → email → phone → password).
KratosFlowUiNodeAt
common/KratosFlowUiNodeAt.vue is the default per-node renderer. It receives context from KratosFlowUi via Vue provide/inject (see kratosFlowUiInject.ts). It can also be used directly by consumers who override #fieldset.
Props:
| Prop | Type | Required | Purpose |
|---|---|---|---|
idx | number | yes | Index into the display node list |
passkeyLoginTrigger | UiNode | null | no | When set, adds a passkey cloud-key icon to the identifier field and wires it to invoke the Ory WebAuthn ceremony. Login passes this for the identifier node only. |
The passkeyLoginTrigger prop pattern demonstrates how to add flow-specific node behavior: the generic component (KratosFlowUiNodeAt) gets an optional prop, and only the consuming page that needs the behavior provides it. The component's default behavior (no prop → no passkey icon) is unchanged for all other consumers.
The inject interface should contain only members that KratosFlowUiNodeAt actually reads. Internal computed values that KratosFlowUi uses for its own logic stay as local variables — they do not go on the inject.
Events
| Event | Payload | Notes |
|---|---|---|
submit | (form: HTMLFormElement, submitter: HTMLElement | null) | The page's *FlowForm.vue builds FormData, calls the Kratos update mutation, and handles the response |
Submit-body construction is a pure function in the flow's .logic.ts file (e.g. buildLoginUpdateBodyFromFormData), making it easy to test without a DOM.
Example: LoginFlowForm
login/LoginFlowForm.vue is the most customized consumer of KratosFlowUi and demonstrates both extension patterns:
- Passkey-in-identifier merge: Filters the passkey trigger button out of the node list (
:nodes="filteredLoginNodes"), then passesKratosFlowUiNodeAtapasskeyLoginTriggerprop on the identifier field so the cloud-key icon appears. Uses helpers fromloginPasskeyInIdentifier.ts. - MFA tabs: Overrides
#fieldsetto split non-default groups intov-tabs/v-windowfor AAL2 login. Tab logic lives inuseKratosMfaGroupTabs.ts. - Passkey-specific CSS: The
.kratos-flow-form__identifier-with-passkeystyle lives inLoginFlowForm.vue's<style scoped>, not in the shared component.
Other flow forms (recovery, verification, settings) use KratosFlowUi with just props — no slots needed. Registration passes nodes to fix field order without using slots.
Ory WebAuthn / passkey integration
Kratos sends type: "script" nodes containing webauthn.js. Since we use a custom UI (not Kratos's default HTML), we must:
- Inject the scripts —
useKratosOryWebAuthnScriptsappends Kratos script nodes todocument.bodywhen the flow nodes change, and removes them on unmount. - Wire window aliases — Ory registers implementations under
__oryPasskeyLoginetc., whileonclickTriggerattributes reference the unprefixed name.oryWebAuthnWindow.tsbridges the two. - Patch
form.submit()— After a passkey ceremony, Ory callsform.submit()directly (bypassingsubmitevents).kratosFormSubmitOryPatch.tsintercepts this so the SPA's@submit.preventhandler runs instead of a full-page navigation. - Trigger the ceremony —
kratosWebAuthnInputClick.tsinvokes the Ory window function named in a node'sonclickTriggerattribute.
When a flow contains passkey or WebAuthn nodes, the consuming *FlowForm.vue should set intercept-ory-programmatic-submit to true.
Adding a new auth method
- Enable the method in
hub/dev/kratos/kratos.yml. - If the method has its own submit body shape, add a builder to the relevant
.logic.tsand cover it in.logic.test.ts. - Update
buildLoginUpdateBodyFromFormData(or the corresponding flow's builder) to detect the new method fromFormData. - If the method needs per-node custom rendering (special icons, merged fields), use the
#nodeslot in the flow's*FlowForm.vue, or add an optional prop toKratosFlowUiNodeAt(seepasskeyLoginTriggerfor the pattern). Do not add boolean flags toKratosFlowUi. - If the method needs structural layout changes (tabs, reordered groups), use the
#fieldsetslot in the flow's*FlowForm.vue(seeLoginFlowForm.vuefor the MFA tabs pattern). Extract layout logic into a composable in the flow's directory (seeuseKratosMfaGroupTabs.ts). - If the method introduces new Vuetify field icons, add them to
kratosVuetifyFieldIcons.ts. - Add UI copy to the appropriate
.strings.tsfile.
Testing
- Pure logic (
*.logic.ts): tested with Vitest in*.logic.test.ts. No DOM or Vue required. - Composables (
use*.ts): tested inuse*.test.tswith Vue Test Utils where needed. - Components (
*.vue): integration tests in*.test.ts. - E2E: Playwright config in
playwright.config.ts.
Run unit/component tests:
npx vitest run --config hub/clients/auth/vitest.config.ts