@octabits-io/nuxt-ui-kit
v0.22.0
Published
Frontend kit for Nuxt/Vue admin SPAs: OIDC session harness (oidc-client-ts), API-client seams (base URL + OIDC bearer), auth/org store cores, and a route-guard builder — factory-style seams the app wires into its own plugins, stores, and middleware
Readme
@octabits-io/nuxt-ui-kit
Frontend kit for Nuxt/Vue admin SPAs. Ships factory-style seams — the app
keeps thin plugin/store/middleware files and wires the kit into them; the kit
never touches Nuxt APIs (defineNuxtPlugin, navigateTo, useRuntimeConfig)
itself, so it has no Nuxt dependency, only vue.
What's inside
- OIDC session harness (
oidc-client-tspeer)createUserManagerFactory({ getConfig, scope, … })— lazyUserManagersingleton bound tolocalStorageremoveStaleOidcKeys,isUnrecoverableRenewErrorcreateLoginRedirector— signin redirect carrying the current path as returnUrl, with a/login?redirect=fallbackattachSessionLifecycleHandlers— classifies silent-renew failures / token expiry / back-channel signout intonotify+onSessionLost+ login-redirect callbacks; copy and toasts stay in the appZITADEL_ORG_PROJECT_SCOPE/ZITADEL_REFRESH_TOKEN_ALLOWED_SCOPEpresets (Zitadel restricts refresh-grant scopes — see the constant's docs)seedAuthBypassSession— dev/E2E bypass with an unconditional production-build refusal (isProductionBuildis a required argument)
- Auth session store core —
createAuthSessionCorereturns the setup body (user/checkAuth/login/handleCallback/logout, silent-renew retry,id_token_hintlogout) for the app's owndefineStore('auth', …) - Route guard builder —
createAuthGuardreturns a handler yielding a redirect target orundefined; per-app policy (org validation, role gates) goes in theafterAuthenticatedhook - API-client seams (
./api, transport-agnostic) —resolveApiBaseUrl(configured URL → page origin in production → localhost dev port) andcreateAccessTokenProvider(bearer from the OIDC session). Build the client with Hono'shcand inject the token through its own asyncheadersthunk - Org store core —
createOrgStoreCore<TOrg>(granted orgs, slug-keyed selection, persistence, lost-access revocation) for the app's own store - API error → i18n —
createApiErrorMessenger({ t, te }): maps{ key, message }bodies and validation errors to user-facing strings via theerrors.*/validation.fields.*/validation.messages.*key convention; unwraps{ value }error envelopes - Confirm dialog — promise-based
useConfirm()+ singleton state, with a./components/ConfirmDialog.vuerenderer (common.cancel/common.confirmi18n defaults,zIndexClassprop for stacking above slideovers) - Generic primitives —
useDirtyTracking(deep-compare form dirty state),usePagination(offset pagination withqueryParams),./components/SubSidebar.vue(responsive list/detail layout — desktop column, mobile slideover,selectionQueryKeyauto-close) ./zod(zodpeer) —setupZodLocaleSync: keep Zod's built-in error messages in the active UI language./dates(date-fnspeer) —Period/calculateDays/shiftIso,useDateRangeInput, andcreateDateFormatter({ getLocale })(the engine of an app-sideuseDateFormat), plus source-shipped./components/DateInput.vue,DateRangeInput.vue(travel/booking end-date semantics, blocked dates via props, injectedavailabilityCheck), andPeriodDisplay.vue./events— the browser side of@octabits-io/framework/events:createEventStreamClient, a fetch-based SSE reader (the stream is authenticated with anAuthorizationheader, which nativeEventSourcecannot set — so reconnect,Last-Event-IDreplay, and full-jitter backoff live here), with a durable-only watermark, bounded seen-id dedupe, adegradedstate for honest fallback-polling UX, and a content-type guard (a 200text/htmlSPA fallback is a failure, not a stream);createSseFrameParser;useEventStream(reactive connection state + scope-bound lifecycle)./ai— frontend AI-workflow engine:useAiWorkflow/useAiWorkflowGuard(poll-driven state over injected transport),createAiProgressCore(cross-page tracking + completion/applied signals — the setup body of the app's progress store),useAiCardState,useActiveAiWorkflowProbe,createWorkflowRegistry, and./components/AiResultReviewCard.vueand./components/ProposalReviewCard.vue(the generic review surface for a@octabits-io/framework/proposalProposal: every operation kind, a decision out, arichtextslot for the host's editor and aformatValueprop for the host's one-line rendering of structured values); dialog/float shells stay in the app (thin views over this state, registry- and router-coupled)
Components ship as source
./components/*.vue files are published as .vue source — the consumer's
Vite compiles them. They use only explicit imports (@nuxt/ui/components/*.vue,
vue-i18n, vue-router), so no auto-import configuration is required, and
they import kit composables from the package root (self-reference) so
module-scoped singleton state (the confirm dialog) is shared with feature
code. Register them under your app's own names with one-line re-exports:
// app/components/AppSubSidebar.ts
export { default } from '@octabits-io/nuxt-ui-kit/components/SubSidebar.vue'Tailwind setup (required): the kit's SFCs live in node_modules, which
Tailwind v4's automatic source detection skips — utility classes used only in
kit markup (e.g. SubSidebar's default w-[240px]) would silently be missing
from your build. Import the kit's stylesheet, which registers the components
via @source (same mechanism @nuxt/ui uses):
/* app/assets/css/main.css */
@import "tailwindcss";
@import "@nuxt/ui";
@import "@octabits-io/nuxt-ui-kit/styles.css";Wiring examples (Nuxt)
Auth + API client
// app/plugins/10.oidc.client.ts
export const getUserManager = createUserManagerFactory({
getConfig: () => ({ issuerUrl, clientId }), // runtime config lookup
scope: ZITADEL_ORG_PROJECT_SCOPE,
refreshTokenAllowedScope: ZITADEL_REFRESH_TOKEN_ALLOWED_SCOPE,
})
export default defineNuxtPlugin((nuxtApp) => {
attachSessionLifecycleHandlers(getUserManager(), {
redirectToLogin: createLoginRedirector({ getUserManager }),
onSessionLost: () => useAuthStore().$patch({ user: null }),
notify: (notice) => toast.add(/* map notice.kind to your copy */),
})
})
// app/composables/useApi.ts — `hcWithType` is the API package's pre-compiled client type
const getAccessToken = createAccessTokenProvider(getUserManager)
const client = hcWithType(
resolveApiBaseUrl({ configuredUrl, isProductionBuild: import.meta.env.PROD, devFallbackPort: 3002 }),
{
headers: async () => {
const token = await getAccessToken()
return token ? { authorization: `Bearer ${token}` } : {}
},
},
)
// app/stores/auth.ts
export const useAuthStore = defineStore('auth', () =>
createAuthSessionCore({ getUserManager, mapUser: defaultAuthUserMapper }))
// app/middleware/auth.global.ts
const guard = createAuthGuard({ ensureAuthenticated, afterAuthenticated: appPolicy })
export default defineNuxtRouteMiddleware(async (to) => {
const target = await guard(to)
if (target) return navigateTo(target)
})Errors, confirm, formatting
// app/composables/useApiError.ts — bind your i18n instance
export function useApiError() {
const { t, te } = useI18n()
return createApiErrorMessenger({ t: key => t(key), te: key => te(key) })
}
// anywhere — the ConfirmDialog.vue renderer must be mounted once in a layout
const { confirm } = useConfirm()
if (await confirm({ title: t('owners.delete.title'), dangerous: true })) { /* … */ }
// app/composables/useDateFormat.ts
export function useDateFormat() {
const { locale } = useI18n()
return createDateFormatter({ getLocale: () => locale.value })
}AI workflows
// app/stores/aiProgress.ts — transport injected, signals consumed by pages
export const useAiProgressStore = defineStore('ai-progress', () =>
createAiProgressCore<AiDialogRequest>({
fetchWorkflowStatus: async (id) => {
const { data, error } = await api.ai.workflows({ id }).get()
return error || !data ? null : data
},
}))
// a page — rehydrate on mount, refuse duplicate triggers
const ai = useAiWorkflowGuard<MyOutput>({
checkFn: fetchLatestWorkflow,
pollFn: fetchLatestWorkflow,
onCompleted: (wf) => showReview(wf.output),
})
await ai.trigger(() => api.ai.workflows.post({ type: 'listing-fields' }))