@octabits-io/nuxt-ui-kit 0.2.1 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -72,6 +72,19 @@ code. Register them under your app's own names with one-line re-exports:
72
72
  export { default } from '@octabits-io/nuxt-ui-kit/components/SubSidebar.vue'
73
73
  ```
74
74
 
75
+ **Tailwind setup (required):** the kit's SFCs live in `node_modules`, which
76
+ Tailwind v4's automatic source detection skips — utility classes used only in
77
+ kit markup (e.g. `SubSidebar`'s default `w-[240px]`) would silently be missing
78
+ from your build. Import the kit's stylesheet, which registers the components
79
+ via `@source` (same mechanism `@nuxt/ui` uses):
80
+
81
+ ```css
82
+ /* app/assets/css/main.css */
83
+ @import "tailwindcss";
84
+ @import "@nuxt/ui";
85
+ @import "@octabits-io/nuxt-ui-kit/styles.css";
86
+ ```
87
+
75
88
  ## Wiring examples (Nuxt)
76
89
 
77
90
  ### Auth + API client
@@ -0,0 +1,65 @@
1
+ import { UserManager } from "oidc-client-ts";
2
+ import { Treaty } from "@elysiajs/eden";
3
+ import { Elysia } from "elysia";
4
+ //#region src/api/client.d.ts
5
+ type AnyElysia = Elysia<any, any, any, any, any, any, any>;
6
+ interface ResolveApiBaseUrlOptions {
7
+ /**
8
+ * The explicitly configured URL, first-match-wins — e.g.
9
+ * `__APP_CONFIG__.API_URL || runtimeConfig.public.apiUrl`. Falsy → fallback.
10
+ */
11
+ configuredUrl: string | null | undefined;
12
+ /** Build-time production flag (`import.meta.env.PROD`). */
13
+ isProductionBuild: boolean;
14
+ /** Dev fallback becomes `http://localhost:<port>`. */
15
+ devFallbackPort: number;
16
+ /** Production fallback origin. Default `window.location.origin`. */
17
+ origin?: string;
18
+ }
19
+ /**
20
+ * Resolve the API base URL: configured value, else the page origin in
21
+ * production builds (same-host ingress), else a localhost dev port.
22
+ */
23
+ declare function resolveApiBaseUrl(options: ResolveApiBaseUrlOptions): string;
24
+ /**
25
+ * Bearer-token provider backed by the OIDC session: resolves to the current
26
+ * access token, or `null` when there is no non-expired session.
27
+ */
28
+ declare function createAccessTokenProvider(getUserManager: () => UserManager): () => Promise<string | null>;
29
+ interface TreatyClientFactoryOptions {
30
+ /** Resolve (and memoize, if desired) the base URL at first client use. */
31
+ getBaseUrl: () => string;
32
+ /** Bearer token per request; `null` sends no Authorization header. */
33
+ getAccessToken: () => Promise<string | null>;
34
+ /**
35
+ * Eden Treaty's auto-Date parsing on responses. Default `false`: with the
36
+ * default `true`, any `YYYY-MM-DD` string in a response is silently
37
+ * converted to a `Date` object, which then JSON-serializes back as a full
38
+ * ISO datetime on the next request — breaking server-side "plain ISO date
39
+ * string" validation. Keep the wire contract string-typed unless the API
40
+ * genuinely round-trips Date objects.
41
+ */
42
+ parseDate?: boolean;
43
+ /**
44
+ * Additional header source(s), applied after the bearer injector — a later
45
+ * entry wins on key collision, so consumers can add or override headers
46
+ * without losing the Authorization injection.
47
+ */
48
+ headers?: Treaty.Config['headers'];
49
+ /** Extra Treaty config (fetcher, onRequest, …), applied last. */
50
+ treatyConfig?: Omit<Treaty.Config, 'headers' | 'parseDate'>;
51
+ }
52
+ /**
53
+ * Lazily-created Eden Treaty client singleton with OIDC bearer injection.
54
+ *
55
+ * ```ts
56
+ * const getClient = createTreatyClientFactory<App>({ getBaseUrl, getAccessToken })
57
+ * export function useApi() {
58
+ * const client = getClient()
59
+ * return { api: client.api, client }
60
+ * }
61
+ * ```
62
+ */
63
+ declare function createTreatyClientFactory<App extends AnyElysia>(options: TreatyClientFactoryOptions): () => Treaty.Create<App>;
64
+ //#endregion
65
+ export { type ResolveApiBaseUrlOptions, type TreatyClientFactoryOptions, createAccessTokenProvider, createTreatyClientFactory, resolveApiBaseUrl };
@@ -0,0 +1,51 @@
1
+ import { treaty } from "@elysiajs/eden";
2
+ //#region src/api/client.ts
3
+ /**
4
+ * Resolve the API base URL: configured value, else the page origin in
5
+ * production builds (same-host ingress), else a localhost dev port.
6
+ */
7
+ function resolveApiBaseUrl(options) {
8
+ if (options.configuredUrl) return options.configuredUrl;
9
+ return options.isProductionBuild ? options.origin ?? window.location.origin : `http://localhost:${options.devFallbackPort}`;
10
+ }
11
+ /**
12
+ * Bearer-token provider backed by the OIDC session: resolves to the current
13
+ * access token, or `null` when there is no non-expired session.
14
+ */
15
+ function createAccessTokenProvider(getUserManager) {
16
+ return async function getAccessToken() {
17
+ const user = await getUserManager().getUser();
18
+ if (!user || user.expired) return null;
19
+ return user.access_token;
20
+ };
21
+ }
22
+ /**
23
+ * Lazily-created Eden Treaty client singleton with OIDC bearer injection.
24
+ *
25
+ * ```ts
26
+ * const getClient = createTreatyClientFactory<App>({ getBaseUrl, getAccessToken })
27
+ * export function useApi() {
28
+ * const client = getClient()
29
+ * return { api: client.api, client }
30
+ * }
31
+ * ```
32
+ */
33
+ function createTreatyClientFactory(options) {
34
+ let client = null;
35
+ return function getClient() {
36
+ if (client) return client;
37
+ const bearerInjector = async () => {
38
+ const token = await options.getAccessToken();
39
+ if (token) return { authorization: `Bearer ${token}` };
40
+ };
41
+ const extraHeaders = options.headers === void 0 ? [] : Array.isArray(options.headers) ? options.headers : [options.headers];
42
+ client = treaty(options.getBaseUrl(), {
43
+ parseDate: options.parseDate ?? false,
44
+ headers: [bearerInjector, ...extraHeaders],
45
+ ...options.treatyConfig
46
+ });
47
+ return client;
48
+ };
49
+ }
50
+ //#endregion
51
+ export { createAccessTokenProvider, createTreatyClientFactory, resolveApiBaseUrl };
@@ -0,0 +1,240 @@
1
+ import { ComputedRef, Ref } from "vue";
2
+ import { UserManager, UserManagerSettings, UserProfile } from "oidc-client-ts";
3
+ //#region src/auth/oidc.d.ts
4
+ /** Issuer + client id, resolved at first use (client-side runtime config). */
5
+ interface OidcClientConfig {
6
+ issuerUrl: string;
7
+ clientId: string;
8
+ }
9
+ interface UserManagerFactoryOptions {
10
+ /**
11
+ * Resolve the issuer/client pair lazily — typically from a runtime-injected
12
+ * config object (e.g. a K8s-entrypoint `window.__APP_CONFIG__`) falling back
13
+ * to the app's build-time config.
14
+ */
15
+ getConfig: () => OidcClientConfig;
16
+ /** OAuth scopes requested on the initial signin. */
17
+ scope: string;
18
+ /**
19
+ * Scopes sent on the refresh-token grant when the IdP restricts them to a
20
+ * subset of the signin scopes (see `ZITADEL_REFRESH_TOKEN_ALLOWED_SCOPE`).
21
+ */
22
+ refreshTokenAllowedScope?: string;
23
+ /** Path the IdP redirects back to after signin. Default `/auth/callback`. */
24
+ redirectPath?: string;
25
+ /** Path the IdP redirects to after logout. Default `/login`. */
26
+ postLogoutRedirectPath?: string;
27
+ /** Default `true`. */
28
+ automaticSilentRenew?: boolean;
29
+ /** Raw oidc-client-ts settings escape hatch, applied last. */
30
+ settings?: Partial<UserManagerSettings>;
31
+ /** Called when issuer/client resolve empty. Default `console.error`. */
32
+ onMissingConfig?: (message: string) => void;
33
+ }
34
+ /**
35
+ * Lazily-created `UserManager` singleton bound to `window.localStorage`.
36
+ * Call the returned getter from plugins/stores/composables — the manager is
37
+ * constructed on first call (client-side only; requires `window`).
38
+ */
39
+ declare function createUserManagerFactory(options: UserManagerFactoryOptions): () => UserManager;
40
+ type StaleKeyStorage = Pick<Storage, 'length' | 'key' | 'removeItem'>;
41
+ /**
42
+ * Remove any `oidc.user:` storage keys that don't belong to the current
43
+ * authority+clientId — leftovers from environment switches would otherwise
44
+ * shadow or bloat the session storage.
45
+ */
46
+ declare function removeStaleOidcKeys(authority: string, clientId: string, storage?: StaleKeyStorage): void;
47
+ /** Refresh tokens fail unrecoverably with these OIDC error codes — user must re-auth. */
48
+ declare function isUnrecoverableRenewError(message: string): boolean;
49
+ interface LoginRedirectorOptions {
50
+ getUserManager: () => UserManager;
51
+ /** Fallback login page for when `signinRedirect` itself fails. Default `/login`. */
52
+ loginPath?: string;
53
+ /**
54
+ * Paths where redirecting to login would loop (the login/callback pages
55
+ * themselves). Default: `loginPath` and anything under `/auth/`.
56
+ */
57
+ isAuthRoute?: (path: string) => boolean;
58
+ /** Default `console.error`. */
59
+ log?: (message: string, detail?: unknown) => void;
60
+ }
61
+ /**
62
+ * Build a `redirectToLogin()` that starts an OIDC signin redirect carrying the
63
+ * current path as returnUrl state, with a plain `/login?redirect=` navigation
64
+ * fallback when the IdP redirect cannot even be started. No-ops on auth routes.
65
+ */
66
+ declare function createLoginRedirector(options: LoginRedirectorOptions): () => Promise<void>;
67
+ /**
68
+ * A user-facing session event the app should surface (toast/banner). The kit
69
+ * classifies; presentation and copy stay in the app.
70
+ *
71
+ * - `renew-failed` — silent renew failed recoverably (next renew may succeed).
72
+ * - `session-expired` — the session is gone; a login redirect follows.
73
+ */
74
+ type SessionNotice = {
75
+ kind: 'renew-failed';
76
+ error: Error;
77
+ } | {
78
+ kind: 'session-expired';
79
+ error?: Error;
80
+ };
81
+ interface SessionLifecycleHandlers {
82
+ /** Start re-authentication (typically from {@link createLoginRedirector}). */
83
+ redirectToLogin: () => void | Promise<void>;
84
+ /** Surface a notice to the user (toast). Optional — omit for headless use. */
85
+ notify?: (notice: SessionNotice) => void;
86
+ /** Clear app-side session state (e.g. reset the auth store's user). */
87
+ onSessionLost?: () => void;
88
+ /** Default `console.warn`. */
89
+ log?: (message: string, detail?: unknown) => void;
90
+ }
91
+ /**
92
+ * Wire oidc-client-ts session events to app callbacks:
93
+ *
94
+ * - silent-renew error → `notify` (`renew-failed`, or `session-expired` when the
95
+ * error is unrecoverable — see {@link isUnrecoverableRenewError}); an
96
+ * unrecoverable error also triggers the login redirect
97
+ * - access token expired without renewal → `notify(session-expired)` +
98
+ * `onSessionLost` + login redirect
99
+ * - back-channel signout at the IdP → `onSessionLost` + login redirect (no notice
100
+ * — the user initiated it elsewhere)
101
+ *
102
+ * Returns a detach function.
103
+ */
104
+ declare function attachSessionLifecycleHandlers(userManager: UserManager, handlers: SessionLifecycleHandlers): () => void;
105
+ //#endregion
106
+ //#region src/auth/zitadel.d.ts
107
+ /**
108
+ * Zitadel scope presets for {@link createUserManagerFactory}.
109
+ *
110
+ * The URN scopes request the resource-owner (organization) claim and the
111
+ * project role grants; `offline_access` requests a refresh token.
112
+ */
113
+ declare const ZITADEL_ORG_PROJECT_SCOPE = "openid profile email urn:zitadel:iam:user:resourceowner urn:zitadel:iam:org:project:roles offline_access";
114
+ /**
115
+ * Zitadel only accepts standard OIDC scopes on the refresh-token grant —
116
+ * sending `offline_access` or the `urn:zitadel:*` scopes returns
117
+ * `invalid_scope` even though they were granted at the initial auth. The
118
+ * URN-based claims still need to land in the refreshed access token; that
119
+ * depends on "Assert Roles on Authentication" being enabled at the Zitadel
120
+ * project level.
121
+ */
122
+ declare const ZITADEL_REFRESH_TOKEN_ALLOWED_SCOPE = "openid profile email";
123
+ //#endregion
124
+ //#region src/auth/bypass.d.ts
125
+ type BypassStorage = Pick<Storage, 'getItem' | 'setItem'>;
126
+ interface AuthBypassProfile {
127
+ sub: string;
128
+ email: string;
129
+ name: string;
130
+ }
131
+ interface SeedAuthBypassOptions {
132
+ /** The shared secret the API accepts as a Bearer token. Falsy → no-op. */
133
+ bypassSecret: string | null | undefined;
134
+ issuerUrl: string;
135
+ clientId: string;
136
+ /**
137
+ * MUST be the build-time production flag (e.g. `import.meta.env.PROD`).
138
+ * When `true` the seed is refused unconditionally — a leaked runtime env var
139
+ * cannot enable the bypass in a production build.
140
+ */
141
+ isProductionBuild: boolean;
142
+ /** Identity baked into the fake session. */
143
+ profile?: AuthBypassProfile;
144
+ /** Fake session lifetime. Default 86400 (24h). */
145
+ sessionTtlSeconds?: number;
146
+ storage?: BypassStorage;
147
+ /** Default `console.warn`. */
148
+ warn?: (message: string) => void;
149
+ }
150
+ /**
151
+ * Dev/E2E auth bypass: seed storage with a fake oidc-client-ts user whose
152
+ * `access_token` is the bypass secret, so the app considers the session
153
+ * authenticated and the API client sends the secret as Bearer.
154
+ *
155
+ * Call from a plugin that runs before the OIDC plugin. Skips seeding when a
156
+ * valid (non-expired) session already exists; overwrites corrupt entries.
157
+ * Returns whether a session was seeded.
158
+ */
159
+ declare function seedAuthBypassSession(options: SeedAuthBypassOptions): boolean;
160
+ //#endregion
161
+ //#region src/auth/session.d.ts
162
+ /** Default mapped shape of the authenticated user. */
163
+ interface AuthSessionUser {
164
+ id: string;
165
+ email: string;
166
+ name: string | null;
167
+ picture: string | null;
168
+ }
169
+ interface AuthSessionCoreOptions<TUser> {
170
+ getUserManager: () => UserManager;
171
+ /** Map OIDC profile claims to the app's user shape. */
172
+ mapUser: (profile: UserProfile) => TUser;
173
+ /** Default `console.warn`. */
174
+ log?: (message: string, detail?: unknown) => void;
175
+ }
176
+ interface AuthSessionCore<TUser> {
177
+ user: Ref<TUser | null>;
178
+ initialized: Ref<boolean>;
179
+ loading: Ref<boolean>;
180
+ isAuthenticated: ComputedRef<boolean>;
181
+ /** Restore the session from storage, silently renewing an expired token. */
182
+ checkAuth: () => Promise<void>;
183
+ /** Start the signin redirect, carrying `returnUrl` as state. */
184
+ login: (returnUrl?: string) => Promise<void>;
185
+ /** Complete the signin redirect; returns the returnUrl state (default `/`). */
186
+ handleCallback: () => Promise<string>;
187
+ /** Clear the local session and redirect to the IdP's logout endpoint. */
188
+ logout: () => Promise<void>;
189
+ }
190
+ declare function defaultAuthUserMapper(profile: UserProfile): AuthSessionUser;
191
+ /**
192
+ * Reactive OIDC session state + actions — the setup body of an auth store.
193
+ * Wrap it in the app's own store so naming and registration stay app-owned:
194
+ *
195
+ * ```ts
196
+ * export const useAuthStore = defineStore('auth', () =>
197
+ * createAuthSessionCore({ getUserManager, mapUser: defaultAuthUserMapper }),
198
+ * )
199
+ * ```
200
+ */
201
+ declare function createAuthSessionCore<TUser = AuthSessionUser>(options: AuthSessionCoreOptions<TUser>): AuthSessionCore<TUser>;
202
+ //#endregion
203
+ //#region src/auth/guard.d.ts
204
+ /** The route fields the guard needs — structurally satisfied by a Nuxt route. */
205
+ interface GuardRoute {
206
+ path: string;
207
+ fullPath: string;
208
+ }
209
+ interface AuthGuardOptions<TRoute extends GuardRoute = GuardRoute> {
210
+ /**
211
+ * Ensure the session is restored and report whether the user is
212
+ * authenticated — typically `checkAuth()` once, then `isAuthenticated`.
213
+ */
214
+ ensureAuthenticated: () => Promise<boolean> | boolean;
215
+ /** Routes reachable without auth. Default: `/login` and `/auth/*`. */
216
+ isPublicRoute?: (to: TRoute) => boolean;
217
+ /** Build the login redirect target. Default `/login?redirect=<returnTo>`. */
218
+ loginRedirect?: (returnTo: string) => string;
219
+ /**
220
+ * Per-app policy hook that runs once the user is authenticated — tenant/org
221
+ * validation, role gates, acceptance gates. Return a path to redirect to,
222
+ * or nothing to let the navigation through.
223
+ */
224
+ afterAuthenticated?: (to: TRoute) => Promise<string | undefined | void> | string | undefined | void;
225
+ }
226
+ /**
227
+ * Build the body of a global auth route-middleware. The returned handler
228
+ * yields a redirect target path or `undefined` to allow navigation; the app's
229
+ * middleware maps that onto its router:
230
+ *
231
+ * ```ts
232
+ * export default defineNuxtRouteMiddleware(async (to) => {
233
+ * const target = await guard(to)
234
+ * if (target) return navigateTo(target)
235
+ * })
236
+ * ```
237
+ */
238
+ declare function createAuthGuard<TRoute extends GuardRoute = GuardRoute>(options: AuthGuardOptions<TRoute>): (to: TRoute) => Promise<string | undefined>;
239
+ //#endregion
240
+ export { type AuthBypassProfile, type AuthGuardOptions, type AuthSessionCore, type AuthSessionCoreOptions, type AuthSessionUser, type GuardRoute, type LoginRedirectorOptions, type OidcClientConfig, type SeedAuthBypassOptions, type SessionLifecycleHandlers, type SessionNotice, type UserManagerFactoryOptions, ZITADEL_ORG_PROJECT_SCOPE, ZITADEL_REFRESH_TOKEN_ALLOWED_SCOPE, attachSessionLifecycleHandlers, createAuthGuard, createAuthSessionCore, createLoginRedirector, createUserManagerFactory, defaultAuthUserMapper, isUnrecoverableRenewError, removeStaleOidcKeys, seedAuthBypassSession };
@@ -0,0 +1,273 @@
1
+ import { computed, ref } from "vue";
2
+ import { UserManager, WebStorageStateStore } from "oidc-client-ts";
3
+ //#region src/auth/oidc.ts
4
+ /**
5
+ * Lazily-created `UserManager` singleton bound to `window.localStorage`.
6
+ * Call the returned getter from plugins/stores/composables — the manager is
7
+ * constructed on first call (client-side only; requires `window`).
8
+ */
9
+ function createUserManagerFactory(options) {
10
+ let userManager = null;
11
+ return function getUserManager() {
12
+ if (userManager) return userManager;
13
+ const { issuerUrl, clientId } = options.getConfig();
14
+ if (!issuerUrl || !clientId) (options.onMissingConfig ?? console.error)("Missing OIDC issuer URL or client id in runtime config");
15
+ userManager = new UserManager({
16
+ authority: issuerUrl,
17
+ client_id: clientId,
18
+ redirect_uri: `${window.location.origin}${options.redirectPath ?? "/auth/callback"}`,
19
+ post_logout_redirect_uri: `${window.location.origin}${options.postLogoutRedirectPath ?? "/login"}`,
20
+ response_type: "code",
21
+ scope: options.scope,
22
+ automaticSilentRenew: options.automaticSilentRenew ?? true,
23
+ ...options.refreshTokenAllowedScope ? { refreshTokenAllowedScope: options.refreshTokenAllowedScope } : {},
24
+ userStore: new WebStorageStateStore({ store: window.localStorage }),
25
+ ...options.settings
26
+ });
27
+ return userManager;
28
+ };
29
+ }
30
+ /**
31
+ * Remove any `oidc.user:` storage keys that don't belong to the current
32
+ * authority+clientId — leftovers from environment switches would otherwise
33
+ * shadow or bloat the session storage.
34
+ */
35
+ function removeStaleOidcKeys(authority, clientId, storage = globalThis.localStorage) {
36
+ const currentKey = `oidc.user:${authority}:${clientId}`;
37
+ for (let i = storage.length - 1; i >= 0; i--) {
38
+ const key = storage.key(i);
39
+ if (key && key.startsWith("oidc.user:") && key !== currentKey) storage.removeItem(key);
40
+ }
41
+ }
42
+ /** Refresh tokens fail unrecoverably with these OIDC error codes — user must re-auth. */
43
+ function isUnrecoverableRenewError(message) {
44
+ return message.includes("login_required") || message.includes("invalid_grant") || message.includes("interaction_required") || message.includes("consent_required");
45
+ }
46
+ /**
47
+ * Build a `redirectToLogin()` that starts an OIDC signin redirect carrying the
48
+ * current path as returnUrl state, with a plain `/login?redirect=` navigation
49
+ * fallback when the IdP redirect cannot even be started. No-ops on auth routes.
50
+ */
51
+ function createLoginRedirector(options) {
52
+ const loginPath = options.loginPath ?? "/login";
53
+ const isAuthRoute = options.isAuthRoute ?? ((path) => path === loginPath || path.startsWith("/auth/"));
54
+ return async function redirectToLogin() {
55
+ const path = window.location.pathname;
56
+ if (isAuthRoute(path)) return;
57
+ const returnUrl = path + window.location.search;
58
+ try {
59
+ await options.getUserManager().signinRedirect({ state: returnUrl });
60
+ } catch (err) {
61
+ (options.log ?? console.error)("[oidc] signinRedirect failed, falling back to login navigation", err);
62
+ window.location.href = `${loginPath}?redirect=${encodeURIComponent(returnUrl)}`;
63
+ }
64
+ };
65
+ }
66
+ /**
67
+ * Wire oidc-client-ts session events to app callbacks:
68
+ *
69
+ * - silent-renew error → `notify` (`renew-failed`, or `session-expired` when the
70
+ * error is unrecoverable — see {@link isUnrecoverableRenewError}); an
71
+ * unrecoverable error also triggers the login redirect
72
+ * - access token expired without renewal → `notify(session-expired)` +
73
+ * `onSessionLost` + login redirect
74
+ * - back-channel signout at the IdP → `onSessionLost` + login redirect (no notice
75
+ * — the user initiated it elsewhere)
76
+ *
77
+ * Returns a detach function.
78
+ */
79
+ function attachSessionLifecycleHandlers(userManager, handlers) {
80
+ const log = handlers.log ?? console.warn;
81
+ const onSilentRenewError = (error) => {
82
+ log("[oidc] silent token renew failed:", error);
83
+ const unrecoverable = isUnrecoverableRenewError(error.message);
84
+ handlers.notify?.(unrecoverable ? {
85
+ kind: "session-expired",
86
+ error
87
+ } : {
88
+ kind: "renew-failed",
89
+ error
90
+ });
91
+ if (unrecoverable) handlers.redirectToLogin();
92
+ };
93
+ const onAccessTokenExpired = () => {
94
+ log("[oidc] access token expired without silent renewal");
95
+ handlers.notify?.({ kind: "session-expired" });
96
+ handlers.onSessionLost?.();
97
+ handlers.redirectToLogin();
98
+ };
99
+ const onUserSignedOut = () => {
100
+ log("[oidc] user signed out at IdP (back-channel)");
101
+ handlers.onSessionLost?.();
102
+ handlers.redirectToLogin();
103
+ };
104
+ userManager.events.addSilentRenewError(onSilentRenewError);
105
+ userManager.events.addAccessTokenExpired(onAccessTokenExpired);
106
+ userManager.events.addUserSignedOut(onUserSignedOut);
107
+ return () => {
108
+ userManager.events.removeSilentRenewError(onSilentRenewError);
109
+ userManager.events.removeAccessTokenExpired(onAccessTokenExpired);
110
+ userManager.events.removeUserSignedOut(onUserSignedOut);
111
+ };
112
+ }
113
+ //#endregion
114
+ //#region src/auth/zitadel.ts
115
+ /**
116
+ * Zitadel scope presets for {@link createUserManagerFactory}.
117
+ *
118
+ * The URN scopes request the resource-owner (organization) claim and the
119
+ * project role grants; `offline_access` requests a refresh token.
120
+ */
121
+ const ZITADEL_ORG_PROJECT_SCOPE = "openid profile email urn:zitadel:iam:user:resourceowner urn:zitadel:iam:org:project:roles offline_access";
122
+ /**
123
+ * Zitadel only accepts standard OIDC scopes on the refresh-token grant —
124
+ * sending `offline_access` or the `urn:zitadel:*` scopes returns
125
+ * `invalid_scope` even though they were granted at the initial auth. The
126
+ * URN-based claims still need to land in the refreshed access token; that
127
+ * depends on "Assert Roles on Authentication" being enabled at the Zitadel
128
+ * project level.
129
+ */
130
+ const ZITADEL_REFRESH_TOKEN_ALLOWED_SCOPE = "openid profile email";
131
+ //#endregion
132
+ //#region src/auth/bypass.ts
133
+ /**
134
+ * Dev/E2E auth bypass: seed storage with a fake oidc-client-ts user whose
135
+ * `access_token` is the bypass secret, so the app considers the session
136
+ * authenticated and the API client sends the secret as Bearer.
137
+ *
138
+ * Call from a plugin that runs before the OIDC plugin. Skips seeding when a
139
+ * valid (non-expired) session already exists; overwrites corrupt entries.
140
+ * Returns whether a session was seeded.
141
+ */
142
+ function seedAuthBypassSession(options) {
143
+ if (options.isProductionBuild) return false;
144
+ if (!options.bypassSecret) return false;
145
+ const storage = options.storage ?? globalThis.localStorage;
146
+ const storageKey = `oidc.user:${options.issuerUrl}:${options.clientId}`;
147
+ const existing = storage.getItem(storageKey);
148
+ if (existing) try {
149
+ if ((JSON.parse(existing).expires_at ?? 0) > Date.now() / 1e3) return false;
150
+ } catch {}
151
+ (options.warn ?? console.warn)("[auth-bypass] Seeding storage with bypass token for dev/E2E testing");
152
+ storage.setItem(storageKey, JSON.stringify({
153
+ access_token: options.bypassSecret,
154
+ token_type: "Bearer",
155
+ expires_at: Math.floor(Date.now() / 1e3) + (options.sessionTtlSeconds ?? 86400),
156
+ profile: options.profile ?? {
157
+ sub: "e2e-test-user",
158
+ email: "e2e@example.test",
159
+ name: "E2E Test User"
160
+ },
161
+ scope: "openid profile email"
162
+ }));
163
+ return true;
164
+ }
165
+ //#endregion
166
+ //#region src/auth/session.ts
167
+ function defaultAuthUserMapper(profile) {
168
+ return {
169
+ id: profile.sub,
170
+ email: profile.email ?? "",
171
+ name: profile.name ?? null,
172
+ picture: profile.picture ?? null
173
+ };
174
+ }
175
+ /**
176
+ * Reactive OIDC session state + actions — the setup body of an auth store.
177
+ * Wrap it in the app's own store so naming and registration stay app-owned:
178
+ *
179
+ * ```ts
180
+ * export const useAuthStore = defineStore('auth', () =>
181
+ * createAuthSessionCore({ getUserManager, mapUser: defaultAuthUserMapper }),
182
+ * )
183
+ * ```
184
+ */
185
+ function createAuthSessionCore(options) {
186
+ const log = options.log ?? console.warn;
187
+ const user = ref(null);
188
+ const initialized = ref(false);
189
+ const loading = ref(false);
190
+ const isAuthenticated = computed(() => !!user.value);
191
+ async function checkAuth() {
192
+ loading.value = true;
193
+ try {
194
+ const um = options.getUserManager();
195
+ let oidcUser = await um.getUser();
196
+ if (oidcUser && oidcUser.expired && oidcUser.refresh_token) try {
197
+ oidcUser = await um.signinSilent();
198
+ } catch (err) {
199
+ log("[auth] signinSilent failed during checkAuth", err);
200
+ oidcUser = null;
201
+ }
202
+ if (oidcUser && !oidcUser.expired) user.value = options.mapUser(oidcUser.profile);
203
+ else user.value = null;
204
+ } catch {
205
+ user.value = null;
206
+ } finally {
207
+ initialized.value = true;
208
+ loading.value = false;
209
+ }
210
+ }
211
+ async function login(returnUrl) {
212
+ await options.getUserManager().signinRedirect({ state: returnUrl ?? "/" });
213
+ }
214
+ async function handleCallback() {
215
+ loading.value = true;
216
+ try {
217
+ const oidcUser = await options.getUserManager().signinRedirectCallback();
218
+ user.value = options.mapUser(oidcUser.profile);
219
+ return oidcUser.state || "/";
220
+ } finally {
221
+ initialized.value = true;
222
+ loading.value = false;
223
+ }
224
+ }
225
+ async function logout() {
226
+ loading.value = true;
227
+ try {
228
+ const manager = options.getUserManager();
229
+ const idTokenHint = (await manager.getUser())?.id_token;
230
+ await manager.removeUser();
231
+ user.value = null;
232
+ await manager.signoutRedirect(idTokenHint ? { id_token_hint: idTokenHint } : void 0);
233
+ } finally {
234
+ loading.value = false;
235
+ }
236
+ }
237
+ return {
238
+ user,
239
+ initialized,
240
+ loading,
241
+ isAuthenticated,
242
+ checkAuth,
243
+ login,
244
+ handleCallback,
245
+ logout
246
+ };
247
+ }
248
+ //#endregion
249
+ //#region src/auth/guard.ts
250
+ /**
251
+ * Build the body of a global auth route-middleware. The returned handler
252
+ * yields a redirect target path or `undefined` to allow navigation; the app's
253
+ * middleware maps that onto its router:
254
+ *
255
+ * ```ts
256
+ * export default defineNuxtRouteMiddleware(async (to) => {
257
+ * const target = await guard(to)
258
+ * if (target) return navigateTo(target)
259
+ * })
260
+ * ```
261
+ */
262
+ function createAuthGuard(options) {
263
+ const isPublicRoute = options.isPublicRoute ?? ((to) => to.path === "/login" || to.path.startsWith("/auth/"));
264
+ const loginRedirect = options.loginRedirect ?? ((returnTo) => `/login?redirect=${encodeURIComponent(returnTo)}`);
265
+ return async function guard(to) {
266
+ if (isPublicRoute(to)) return void 0;
267
+ if (!await options.ensureAuthenticated()) return loginRedirect(to.fullPath);
268
+ const target = await options.afterAuthenticated?.(to);
269
+ return typeof target === "string" ? target : void 0;
270
+ };
271
+ }
272
+ //#endregion
273
+ export { ZITADEL_ORG_PROJECT_SCOPE, ZITADEL_REFRESH_TOKEN_ALLOWED_SCOPE, attachSessionLifecycleHandlers, createAuthGuard, createAuthSessionCore, createLoginRedirector, createUserManagerFactory, defaultAuthUserMapper, isUnrecoverableRenewError, removeStaleOidcKeys, seedAuthBypassSession };