Skip to main content

Application Shell

The root application, layout, and status-page shell is internal to @vef-framework-react/starter. None of App, BaseLayout, Layout, AccessDenied, Error, NotFound, RouterProvider, or NProgress is exported from the package. They are assembled by createApp(), createRouter(), and the route-option helpers documented in Bootstrap and Routing; this page documents the option shapes those functions accept. The login surface (Login, SsoLogin, their hooks, and the challenge renderer) and ThemeConfigProvider are exported for custom application shells.

Page-level containers (Page, FlexCard), form dialogs (FormModal, FormDrawer), table abstractions (ProTable), and the CRUD stack (CrudPage, createCrudKit, etc.) live in @vef-framework-react/components — see Components / CRUD.

Root app​

createApp().render(props) mounts the internal root component, which composes, in order:

AppContextProvider → ApiClientProvider → MotionProvider → ThemeConfigProvider → (NProgress + RouterProvider)

render(props) accepts:

PropTypePurpose
apiClientApiClientprovided to ApiClientProvider
appContextAppContextprovided to AppContextProvider (permissions, code sets, file base URL)
routerRouterthe instance from createRouter(), provided to the internal router provider
appVersionNotificationAppVersionNotificationOptionsoptional; wires setupAppVersionNotification
componentsComponentDefaultsoptional; application-wide defaults for whitelisted component props, forwarded to the components package's ConfigProvider
defaultThemeDefaultThemeoptional; the application's initial color scheme and semantic colors

DefaultTheme accepts colorScheme?: ColorScheme and colors?: Partial<ThemeColors>. The framework starts from system and its built-in semantic colors; application colors are merged over those defaults, so unspecified semantic colors keep their built-in values. A choice the user makes in the theme panel wins, and only that choice is persisted — changing the application default reaches users who have not made their own pick.

Theme provider​

ThemeConfigProvider is exported for applications that assemble a custom shell instead of using createApp().render().

PropTypePurpose
defaultThemeDefaultThemeapplication starting theme
componentsComponentDefaultsapplication-wide component defaults
childrenReactNodeapplication content

The provider also exports:

  • useEffectiveColorScheme() — the user's picked color scheme, otherwise the application default.
  • useEffectiveThemeColors() — the user's picked semantic colors merged over the application defaults.
  • DefaultTheme and ThemeConfigProviderProps types.

Layout​

Assembled by createLayoutRouteOptions() and rendered for every authenticated route. Beyond fetchUserInfo / beforeLoad / loader (see Bootstrap and Routing), it forwards these display options straight through to the internal layout shell:

OptionTypePurpose
titleReactNodeshell title
logoReactNodeshell logo
headerActionsReactNodeextra content in the header
userMenuItemsUserMenuItem[]items appended to the user dropdown, before the built-in logout item
onUserMenuClick(key: string) => voidcalled for clicks on any user menu item except the built-in logout key
onLogout() => Awaitable<void>logout callback
appsAppItem[]sub-applications shown in the app switcher; omit to hide it
currentAppIdstringid of the active app, highlighted in the switcher
onAppChange(appId: string) => Awaitable<void>called when the user picks a different app

AppItem fields:

FieldTypeDefaultPurpose
idstringrequiredstable identifier, handed back to onAppChange
namestringrequireddisplay name
descriptionstring—one-line description (shown as a tooltip)
iconReactNode—render-ready node, rendered as-is in the switcher tile (an <img>, a DynamicIcon, …) — not an icon name; see the v2.5.0 release note

The internal layout shell is a two-layer composition: Layout (menu, user area, tab system, app switcher) wraps a lower-level BaseLayout (header/tabs/sidebar/footer regions). BaseLayout has no independent public configuration surface — it is only reachable through Layout.

Login​

Assembled by createLoginRouteOptions(options), which renders the exported Login component with LoginProps and handles the redirect-aware login flow (?redirect= search param, skip-if-authenticated).

PropTypePurpose
logoReactNodebrand mark in the corner
titlestringpage title
descriptionstringsubtitle
publicKeystringenables RSA encryption of credentials (and sensitive challenge responses via encrypt) when present
onLogin(params: LoginParams) => Promise<LoginResult>login callback
onResolveChallenge(params: ResolveChallengeParams) => Promise<LoginResult>required together with challengeRenderers for backends that issue multi-step login challenges (e.g. TOTP, forced password change)
challengeRenderersLoginChallengeRenderersone renderer per challenge type

onResolveChallenge and challengeRenderers are both-or-neither: a backend that never issues challenges omits both. LoginProps enforces this at the type level.

LoginParams is a discriminated union. The framework ships "password" (PasswordLoginParams) and "trust_code" (TrustCodeLoginParams); projects add mechanisms through Register['loginParams'].

useLoginFlow​

The exported login state machine underlies Login and SsoLogin. It owns credential submission, the challenge loop, RSA encryption, error normalization, the authenticated store write, and post-authentication navigation, so a custom screen can reuse all of it and replace only the presentation.

OptionTypePurpose
onLogin(params: LoginParams) => Promise<LoginResult>submit credentials or a trust-code handoff
onResolveChallenge?(params: ResolveChallengeParams) => Promise<LoginResult>answer challenges; wire it whenever the backend can issue one
publicKey?stringenables the returned encrypt helper
redirectTo?stringpost-authentication target; defaults to the route's redirect search parameter, then the index route
onAuthenticated?(result: LoginResult) => void | Promise<void>replaces the default navigation and welcome notification; the authenticated store write always runs
autoResolve?LoginChallengeAutoResolverssilently answers selected challenge types from data the page already holds; each type is attempted once and a rejected silent answer falls back to its renderer
onError?(error: unknown) => voidreceives the original rejection for reporting

The returned LoginFlow exposes login, challenge, resolve, cancel, pending, error, clearError, and optional encrypt.

Single sign-on​

SsoLogin is the default landing page for a trust-login gateway redirect. createSsoRouteOptions(options) mounts it at /sso; SSO_ROUTE_ID, SSO_ROUTE_PATH, SSO_APP_ID_PARAM (app_id), and SSO_CODE_PARAM (code) are exported.

useSsoLogin(options) reads the handoff once, removes it from the address bar before spending the one-time code, and hands the resulting LoginParams to the shared login flow. The exchange runs even when another session exists, because the handoff may name a different user. Its options (UseSsoLoginOptions) are the login flow's, plus readHandoff:

OptionTypePurpose
onLogin(params: LoginParams) => Promise<LoginResult>exchanges the handoff for a session — the ordinary login call, since single sign-on is a credential type rather than a separate endpoint
onResolveChallenge?(params: ResolveChallengeParams) => Promise<LoginResult>answers challenges; a handoff runs the same challenge chain a password login does
publicKey?stringencrypts what a challenge collects — a password_change reached through a handoff would otherwise submit the new password in clear text
readHandoff?(search: SsoHandoffSearch) => LoginParams | undefinedbuilds the exchange request from the handoff's query parameters; returning undefined reports the link as invalid without calling the server. Defaults to readTrustCodeHandoff
redirectTo?stringpost-authentication target; defaults to the redirect search parameter, then the index route
onAuthenticated?(result: LoginResult) => void | Promise<void>replaces the built-in post-authentication effect
autoResolve?LoginChallengeAutoResolversanswers selected challenges from the handoff's own parameters — typically a department the originating system already picked
onError?(error: unknown) => voidreceives the original rejection behind error

SsoHandoffSearch is Record<string, unknown> — the query parameters exactly as the gateway left them, so a reader narrows a value before using it. readTrustCodeHandoff(search) builds the default trust_code payload from app_id and code.

The returned SsoLoginFlow is a LoginFlow plus:

  • status — "exchanging" (which also covers the frame before the exchange starts, so a landing page never renders empty), "challenge", or "failed". "failed" is terminal: an invalid link, a rejected exchange, or a spent handoff that produced neither a session nor a pending challenge (the user cancelled the challenge) cannot be retried from the page, because the one-time code is gone.
  • search — the handoff's query parameters, snapshotted at mount. A custom landing page and a challenge auto-resolver read them from here: clearing the URL empties the router's live search.

SsoLoginProps takes the same options, except that the challenge wiring is both-or-neither, as on Login: onResolveChallenge together with challengeRenderers, or neither.

Login challenges​

The challenge pipeline is useLoginFlow plus LoginChallengeOutlet (below). Login and SsoLogin compose the two, and a custom login screen uses the same pair, so every entry point behaves alike:

  1. onLogin returns a LoginResult. When it carries challenge + challengeToken instead of tokens, the flow holds the challenge and the outlet renders the renderer registered under challenge.type (Login swaps its credentials form for it).
  2. The renderer calls resolve(response); the flow forwards it as onResolveChallenge({ challengeToken, type, response }). The returned LoginResult either completes authentication (tokens — the store is updated and the ?redirect= navigation runs) or chains the next challenge.
  3. A rejected resolve keeps the challenge on screen and surfaces the error message through the renderer's error prop (a BusinessError message verbatim; anything else falls back to a generic prompt). The error clears automatically on the next resolve and on cancel.
  4. cancel() discards the held challenge token and returns to the credentials form — the user must log in again.
  5. A challenge type with no registered renderer shows a built-in "unsupported challenge" notice with a back-to-login button.

LoginChallengeOutlet​

The renderer lookup every entry point shares (props: LoginChallengeOutletProps). It renders nothing while the flow holds no challenge, so it can sit unconditionally in a tree.

PropTypePurpose
flowLoginFlowthe flow whose pending challenge is presented — from useLoginFlow or useSsoLogin
renderers?LoginChallengeRenderersone renderer per challenge type the backend may issue
fallback?(challenge: LoginChallenge) => ReactNodepresents a type no renderer is registered for; defaults to the "unsupported challenge" notice

Each renderer is mounted as an element keyed by its challenge type, never called as a function: a chain such as department selection followed by a forced password change moves between renderers, and calling them would run their hooks in one shared hook list.

Each renderer is a LoginChallengeRenderer — a plain render function receiving LoginChallengeRendererProps (parameterised by K, the challenge type the renderer is bound to):

PropTypePurpose
challengeExtract<LoginChallenge, { type: K }>the pending challenge: type, challenge-specific data, and required
resolve(response: ResolvedChallenges[K]["response"]) => Promise<void>submit the user's answer; resolves once the resulting LoginResult has been applied
cancel() => voidabandon the challenge and return to the credentials form
pendingbooleanwhether a resolve call is in flight (disable submit buttons)
errorstring | null | undefinedmessage from the most recent failed resolve, for inline display
encrypt((plaintext: string) => string) | undefinedpresent only when publicKey is configured; encrypts sensitive responses with the same scheme as the credentials. May throw — catch in the submit handler

When a project augments Register['challenges'] (see Stores and Types), challenge.data and the resolve argument narrow per challenge type, and LoginChallengeRenderers enforces exhaustiveness — every declared challenge type must have a renderer.

Built-in renderer: PasswordChangeChallenge​

The starter ships one built-in renderer, for the backend's forced password-change challenge. Exports:

ExportKindPurpose
PasswordChangeChallengecomponentthe renderer: collects and confirms the new password, validates locally (both fields non-empty and matching), encrypts through encrypt when present, and surfaces resolve failures (e.g. a password-policy violation) and local validation errors inline
PASSWORD_CHANGE_CHALLENGE_TYPE"password_change" constantthe challenge type identifier, aligned with the backend's security.ChallengeTypePasswordChange
PasswordChangeChallengeDatatypethe challenge data payload: { reason: PasswordChangeReason; meta?: Record<string, unknown> }
PasswordChangeReasonLiteralUnion<"first_login" | "expired", string>why the change is forced; the two literals are the backend's predefined reasons and drive a reason-specific subtitle, any other string falls back to a generic one
PasswordChangeChallengeSpectypethe Register['challenges'] entry: { data: PasswordChangeChallengeData; response: string } — the response is the new password, encrypted when a publicKey is configured, plaintext otherwise
PasswordChangeChallengePropstypethe component's props (below)

PasswordChangeChallengeProps — shaped to stay assignable to LoginChallengeRenderers entries both with and without a Register['challenges'] augmentation (challenge.data stays wide; resolve stays narrow):

PropTypeDefaultPurpose
challenge{ data?: unknown }requiredthe server-provided PasswordChangeChallengeData; treated as untrusted wire data and read defensively
resolve(response: string) => Promise<void>requiredsubmits the (possibly encrypted) new password
cancel() => voidrequiredback to the credentials form; disabled while pending
pendingbooleanrequiredin-flight state of the resolve call
errorstring | nullundefinedresolve failure shown inline (alongside local validation errors)
encrypt(plaintext: string) => stringundefinedprovided by the Login component when a publicKey is configured; the new password is encrypted before submission

Wiring it up is the host's job — the renderer is exported, not auto-registered:

src/pages/_common/login.tsx
import { createFileRoute } from "@tanstack/react-router";
import {
createLoginRouteOptions,
LOGIN_ROUTE_ID,
PASSWORD_CHANGE_CHALLENGE_TYPE,
PasswordChangeChallenge,
type LoginChallengeRenderers
} from "@vef-framework-react/starter";

const challengeRenderers: LoginChallengeRenderers = {
[PASSWORD_CHANGE_CHALLENGE_TYPE]: PasswordChangeChallenge
};

export const Route = createFileRoute(LOGIN_ROUTE_ID)(
createLoginRouteOptions({
onLogin: handleLogin,
onResolveChallenge: handleResolveChallenge,
challengeRenderers
})
);
src/types/augmentation.ts
import type { PASSWORD_CHANGE_CHALLENGE_TYPE, PasswordChangeChallengeSpec } from "@vef-framework-react/starter";

declare module "@vef-framework-react/starter" {
interface Register {
challenges: {
[PASSWORD_CHANGE_CHALLENGE_TYPE]: PasswordChangeChallengeSpec;
};
}
}

To extend the set, add project-defined renderer components under their own challenge types (each receives LoginChallengeRendererProps); to replace the built-in UI, register a different renderer under PASSWORD_CHANGE_CHALLENGE_TYPE.

Routing status pages​

Internal componentWired by
AccessDeniedcreateAccessDeniedRouteOptions()
ErrorcreateRouter() (defaultErrorComponent) and createLayoutRouteOptions() (errorComponent)
NotFoundcreateRouter() (defaultNotFoundComponent) and createLayoutRouteOptions() (notFoundComponent)

None of the three take public configuration options.

Route loading indicator​

NProgress (a top-edge progress bar) is mounted automatically by the root app and driven by createRouter()'s onBeforeLoad / onLoad router events. It has no public configuration surface.