@octabits-io/nuxt-ui-kit 0.2.0
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/LICENSE +21 -0
- package/README.md +151 -0
- package/dist/ai/index.d.ts +255 -0
- package/dist/ai/index.js +389 -0
- package/dist/dates/index.d.ts +42 -0
- package/dist/dates/index.js +108 -0
- package/dist/index.d.ts +453 -0
- package/dist/index.js +526 -0
- package/dist/zod/index.d.ts +24 -0
- package/dist/zod/index.js +17 -0
- package/package.json +102 -0
- package/src/components/AiResultReviewCard.vue +67 -0
- package/src/components/ConfirmDialog.vue +60 -0
- package/src/components/DateInput.vue +66 -0
- package/src/components/DateRangeInput.vue +651 -0
- package/src/components/PeriodDisplay.vue +76 -0
- package/src/components/SubSidebar.vue +85 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
import { UserManager, WebStorageStateStore } from "oidc-client-ts";
|
|
2
|
+
import { computed, ref, watch } from "vue";
|
|
3
|
+
import { treaty } from "@elysiajs/eden";
|
|
4
|
+
//#region src/auth/oidc.ts
|
|
5
|
+
/**
|
|
6
|
+
* Lazily-created `UserManager` singleton bound to `window.localStorage`.
|
|
7
|
+
* Call the returned getter from plugins/stores/composables — the manager is
|
|
8
|
+
* constructed on first call (client-side only; requires `window`).
|
|
9
|
+
*/
|
|
10
|
+
function createUserManagerFactory(options) {
|
|
11
|
+
let userManager = null;
|
|
12
|
+
return function getUserManager() {
|
|
13
|
+
if (userManager) return userManager;
|
|
14
|
+
const { issuerUrl, clientId } = options.getConfig();
|
|
15
|
+
if (!issuerUrl || !clientId) (options.onMissingConfig ?? console.error)("Missing OIDC issuer URL or client id in runtime config");
|
|
16
|
+
userManager = new UserManager({
|
|
17
|
+
authority: issuerUrl,
|
|
18
|
+
client_id: clientId,
|
|
19
|
+
redirect_uri: `${window.location.origin}${options.redirectPath ?? "/auth/callback"}`,
|
|
20
|
+
post_logout_redirect_uri: `${window.location.origin}${options.postLogoutRedirectPath ?? "/login"}`,
|
|
21
|
+
response_type: "code",
|
|
22
|
+
scope: options.scope,
|
|
23
|
+
automaticSilentRenew: options.automaticSilentRenew ?? true,
|
|
24
|
+
...options.refreshTokenAllowedScope ? { refreshTokenAllowedScope: options.refreshTokenAllowedScope } : {},
|
|
25
|
+
userStore: new WebStorageStateStore({ store: window.localStorage }),
|
|
26
|
+
...options.settings
|
|
27
|
+
});
|
|
28
|
+
return userManager;
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Remove any `oidc.user:` storage keys that don't belong to the current
|
|
33
|
+
* authority+clientId — leftovers from environment switches would otherwise
|
|
34
|
+
* shadow or bloat the session storage.
|
|
35
|
+
*/
|
|
36
|
+
function removeStaleOidcKeys(authority, clientId, storage = globalThis.localStorage) {
|
|
37
|
+
const currentKey = `oidc.user:${authority}:${clientId}`;
|
|
38
|
+
for (let i = storage.length - 1; i >= 0; i--) {
|
|
39
|
+
const key = storage.key(i);
|
|
40
|
+
if (key && key.startsWith("oidc.user:") && key !== currentKey) storage.removeItem(key);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** Refresh tokens fail unrecoverably with these OIDC error codes — user must re-auth. */
|
|
44
|
+
function isUnrecoverableRenewError(message) {
|
|
45
|
+
return message.includes("login_required") || message.includes("invalid_grant") || message.includes("interaction_required") || message.includes("consent_required");
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Build a `redirectToLogin()` that starts an OIDC signin redirect carrying the
|
|
49
|
+
* current path as returnUrl state, with a plain `/login?redirect=` navigation
|
|
50
|
+
* fallback when the IdP redirect cannot even be started. No-ops on auth routes.
|
|
51
|
+
*/
|
|
52
|
+
function createLoginRedirector(options) {
|
|
53
|
+
const loginPath = options.loginPath ?? "/login";
|
|
54
|
+
const isAuthRoute = options.isAuthRoute ?? ((path) => path === loginPath || path.startsWith("/auth/"));
|
|
55
|
+
return async function redirectToLogin() {
|
|
56
|
+
const path = window.location.pathname;
|
|
57
|
+
if (isAuthRoute(path)) return;
|
|
58
|
+
const returnUrl = path + window.location.search;
|
|
59
|
+
try {
|
|
60
|
+
await options.getUserManager().signinRedirect({ state: returnUrl });
|
|
61
|
+
} catch (err) {
|
|
62
|
+
(options.log ?? console.error)("[oidc] signinRedirect failed, falling back to login navigation", err);
|
|
63
|
+
window.location.href = `${loginPath}?redirect=${encodeURIComponent(returnUrl)}`;
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Wire oidc-client-ts session events to app callbacks:
|
|
69
|
+
*
|
|
70
|
+
* - silent-renew error → `notify` (`renew-failed`, or `session-expired` when the
|
|
71
|
+
* error is unrecoverable — see {@link isUnrecoverableRenewError}); an
|
|
72
|
+
* unrecoverable error also triggers the login redirect
|
|
73
|
+
* - access token expired without renewal → `notify(session-expired)` +
|
|
74
|
+
* `onSessionLost` + login redirect
|
|
75
|
+
* - back-channel signout at the IdP → `onSessionLost` + login redirect (no notice
|
|
76
|
+
* — the user initiated it elsewhere)
|
|
77
|
+
*
|
|
78
|
+
* Returns a detach function.
|
|
79
|
+
*/
|
|
80
|
+
function attachSessionLifecycleHandlers(userManager, handlers) {
|
|
81
|
+
const log = handlers.log ?? console.warn;
|
|
82
|
+
const onSilentRenewError = (error) => {
|
|
83
|
+
log("[oidc] silent token renew failed:", error);
|
|
84
|
+
const unrecoverable = isUnrecoverableRenewError(error.message);
|
|
85
|
+
handlers.notify?.(unrecoverable ? {
|
|
86
|
+
kind: "session-expired",
|
|
87
|
+
error
|
|
88
|
+
} : {
|
|
89
|
+
kind: "renew-failed",
|
|
90
|
+
error
|
|
91
|
+
});
|
|
92
|
+
if (unrecoverable) handlers.redirectToLogin();
|
|
93
|
+
};
|
|
94
|
+
const onAccessTokenExpired = () => {
|
|
95
|
+
log("[oidc] access token expired without silent renewal");
|
|
96
|
+
handlers.notify?.({ kind: "session-expired" });
|
|
97
|
+
handlers.onSessionLost?.();
|
|
98
|
+
handlers.redirectToLogin();
|
|
99
|
+
};
|
|
100
|
+
const onUserSignedOut = () => {
|
|
101
|
+
log("[oidc] user signed out at IdP (back-channel)");
|
|
102
|
+
handlers.onSessionLost?.();
|
|
103
|
+
handlers.redirectToLogin();
|
|
104
|
+
};
|
|
105
|
+
userManager.events.addSilentRenewError(onSilentRenewError);
|
|
106
|
+
userManager.events.addAccessTokenExpired(onAccessTokenExpired);
|
|
107
|
+
userManager.events.addUserSignedOut(onUserSignedOut);
|
|
108
|
+
return () => {
|
|
109
|
+
userManager.events.removeSilentRenewError(onSilentRenewError);
|
|
110
|
+
userManager.events.removeAccessTokenExpired(onAccessTokenExpired);
|
|
111
|
+
userManager.events.removeUserSignedOut(onUserSignedOut);
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
//#endregion
|
|
115
|
+
//#region src/auth/zitadel.ts
|
|
116
|
+
/**
|
|
117
|
+
* Zitadel scope presets for {@link createUserManagerFactory}.
|
|
118
|
+
*
|
|
119
|
+
* The URN scopes request the resource-owner (organization) claim and the
|
|
120
|
+
* project role grants; `offline_access` requests a refresh token.
|
|
121
|
+
*/
|
|
122
|
+
const ZITADEL_ORG_PROJECT_SCOPE = "openid profile email urn:zitadel:iam:user:resourceowner urn:zitadel:iam:org:project:roles offline_access";
|
|
123
|
+
/**
|
|
124
|
+
* Zitadel only accepts standard OIDC scopes on the refresh-token grant —
|
|
125
|
+
* sending `offline_access` or the `urn:zitadel:*` scopes returns
|
|
126
|
+
* `invalid_scope` even though they were granted at the initial auth. The
|
|
127
|
+
* URN-based claims still need to land in the refreshed access token; that
|
|
128
|
+
* depends on "Assert Roles on Authentication" being enabled at the Zitadel
|
|
129
|
+
* project level.
|
|
130
|
+
*/
|
|
131
|
+
const ZITADEL_REFRESH_TOKEN_ALLOWED_SCOPE = "openid profile email";
|
|
132
|
+
//#endregion
|
|
133
|
+
//#region src/auth/bypass.ts
|
|
134
|
+
/**
|
|
135
|
+
* Dev/E2E auth bypass: seed storage with a fake oidc-client-ts user whose
|
|
136
|
+
* `access_token` is the bypass secret, so the app considers the session
|
|
137
|
+
* authenticated and the API client sends the secret as Bearer.
|
|
138
|
+
*
|
|
139
|
+
* Call from a plugin that runs before the OIDC plugin. Skips seeding when a
|
|
140
|
+
* valid (non-expired) session already exists; overwrites corrupt entries.
|
|
141
|
+
* Returns whether a session was seeded.
|
|
142
|
+
*/
|
|
143
|
+
function seedAuthBypassSession(options) {
|
|
144
|
+
if (options.isProductionBuild) return false;
|
|
145
|
+
if (!options.bypassSecret) return false;
|
|
146
|
+
const storage = options.storage ?? globalThis.localStorage;
|
|
147
|
+
const storageKey = `oidc.user:${options.issuerUrl}:${options.clientId}`;
|
|
148
|
+
const existing = storage.getItem(storageKey);
|
|
149
|
+
if (existing) try {
|
|
150
|
+
if ((JSON.parse(existing).expires_at ?? 0) > Date.now() / 1e3) return false;
|
|
151
|
+
} catch {}
|
|
152
|
+
(options.warn ?? console.warn)("[auth-bypass] Seeding storage with bypass token for dev/E2E testing");
|
|
153
|
+
storage.setItem(storageKey, JSON.stringify({
|
|
154
|
+
access_token: options.bypassSecret,
|
|
155
|
+
token_type: "Bearer",
|
|
156
|
+
expires_at: Math.floor(Date.now() / 1e3) + (options.sessionTtlSeconds ?? 86400),
|
|
157
|
+
profile: options.profile ?? {
|
|
158
|
+
sub: "e2e-test-user",
|
|
159
|
+
email: "e2e@example.test",
|
|
160
|
+
name: "E2E Test User"
|
|
161
|
+
},
|
|
162
|
+
scope: "openid profile email"
|
|
163
|
+
}));
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
//#endregion
|
|
167
|
+
//#region src/auth/session.ts
|
|
168
|
+
function defaultAuthUserMapper(profile) {
|
|
169
|
+
return {
|
|
170
|
+
id: profile.sub,
|
|
171
|
+
email: profile.email ?? "",
|
|
172
|
+
name: profile.name ?? null,
|
|
173
|
+
picture: profile.picture ?? null
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Reactive OIDC session state + actions — the setup body of an auth store.
|
|
178
|
+
* Wrap it in the app's own store so naming and registration stay app-owned:
|
|
179
|
+
*
|
|
180
|
+
* ```ts
|
|
181
|
+
* export const useAuthStore = defineStore('auth', () =>
|
|
182
|
+
* createAuthSessionCore({ getUserManager, mapUser: defaultAuthUserMapper }),
|
|
183
|
+
* )
|
|
184
|
+
* ```
|
|
185
|
+
*/
|
|
186
|
+
function createAuthSessionCore(options) {
|
|
187
|
+
const log = options.log ?? console.warn;
|
|
188
|
+
const user = ref(null);
|
|
189
|
+
const initialized = ref(false);
|
|
190
|
+
const loading = ref(false);
|
|
191
|
+
const isAuthenticated = computed(() => !!user.value);
|
|
192
|
+
async function checkAuth() {
|
|
193
|
+
loading.value = true;
|
|
194
|
+
try {
|
|
195
|
+
const um = options.getUserManager();
|
|
196
|
+
let oidcUser = await um.getUser();
|
|
197
|
+
if (oidcUser && oidcUser.expired && oidcUser.refresh_token) try {
|
|
198
|
+
oidcUser = await um.signinSilent();
|
|
199
|
+
} catch (err) {
|
|
200
|
+
log("[auth] signinSilent failed during checkAuth", err);
|
|
201
|
+
oidcUser = null;
|
|
202
|
+
}
|
|
203
|
+
if (oidcUser && !oidcUser.expired) user.value = options.mapUser(oidcUser.profile);
|
|
204
|
+
else user.value = null;
|
|
205
|
+
} catch {
|
|
206
|
+
user.value = null;
|
|
207
|
+
} finally {
|
|
208
|
+
initialized.value = true;
|
|
209
|
+
loading.value = false;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
async function login(returnUrl) {
|
|
213
|
+
await options.getUserManager().signinRedirect({ state: returnUrl ?? "/" });
|
|
214
|
+
}
|
|
215
|
+
async function handleCallback() {
|
|
216
|
+
loading.value = true;
|
|
217
|
+
try {
|
|
218
|
+
const oidcUser = await options.getUserManager().signinRedirectCallback();
|
|
219
|
+
user.value = options.mapUser(oidcUser.profile);
|
|
220
|
+
return oidcUser.state || "/";
|
|
221
|
+
} finally {
|
|
222
|
+
initialized.value = true;
|
|
223
|
+
loading.value = false;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
async function logout() {
|
|
227
|
+
loading.value = true;
|
|
228
|
+
try {
|
|
229
|
+
const manager = options.getUserManager();
|
|
230
|
+
const idTokenHint = (await manager.getUser())?.id_token;
|
|
231
|
+
await manager.removeUser();
|
|
232
|
+
user.value = null;
|
|
233
|
+
await manager.signoutRedirect(idTokenHint ? { id_token_hint: idTokenHint } : void 0);
|
|
234
|
+
} finally {
|
|
235
|
+
loading.value = false;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return {
|
|
239
|
+
user,
|
|
240
|
+
initialized,
|
|
241
|
+
loading,
|
|
242
|
+
isAuthenticated,
|
|
243
|
+
checkAuth,
|
|
244
|
+
login,
|
|
245
|
+
handleCallback,
|
|
246
|
+
logout
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
//#endregion
|
|
250
|
+
//#region src/auth/guard.ts
|
|
251
|
+
/**
|
|
252
|
+
* Build the body of a global auth route-middleware. The returned handler
|
|
253
|
+
* yields a redirect target path or `undefined` to allow navigation; the app's
|
|
254
|
+
* middleware maps that onto its router:
|
|
255
|
+
*
|
|
256
|
+
* ```ts
|
|
257
|
+
* export default defineNuxtRouteMiddleware(async (to) => {
|
|
258
|
+
* const target = await guard(to)
|
|
259
|
+
* if (target) return navigateTo(target)
|
|
260
|
+
* })
|
|
261
|
+
* ```
|
|
262
|
+
*/
|
|
263
|
+
function createAuthGuard(options) {
|
|
264
|
+
const isPublicRoute = options.isPublicRoute ?? ((to) => to.path === "/login" || to.path.startsWith("/auth/"));
|
|
265
|
+
const loginRedirect = options.loginRedirect ?? ((returnTo) => `/login?redirect=${encodeURIComponent(returnTo)}`);
|
|
266
|
+
return async function guard(to) {
|
|
267
|
+
if (isPublicRoute(to)) return void 0;
|
|
268
|
+
if (!await options.ensureAuthenticated()) return loginRedirect(to.fullPath);
|
|
269
|
+
const target = await options.afterAuthenticated?.(to);
|
|
270
|
+
return typeof target === "string" ? target : void 0;
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
//#endregion
|
|
274
|
+
//#region src/api/client.ts
|
|
275
|
+
/**
|
|
276
|
+
* Resolve the API base URL: configured value, else the page origin in
|
|
277
|
+
* production builds (same-host ingress), else a localhost dev port.
|
|
278
|
+
*/
|
|
279
|
+
function resolveApiBaseUrl(options) {
|
|
280
|
+
if (options.configuredUrl) return options.configuredUrl;
|
|
281
|
+
return options.isProductionBuild ? options.origin ?? window.location.origin : `http://localhost:${options.devFallbackPort}`;
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Bearer-token provider backed by the OIDC session: resolves to the current
|
|
285
|
+
* access token, or `null` when there is no non-expired session.
|
|
286
|
+
*/
|
|
287
|
+
function createAccessTokenProvider(getUserManager) {
|
|
288
|
+
return async function getAccessToken() {
|
|
289
|
+
const user = await getUserManager().getUser();
|
|
290
|
+
if (!user || user.expired) return null;
|
|
291
|
+
return user.access_token;
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Lazily-created Eden Treaty client singleton with OIDC bearer injection.
|
|
296
|
+
*
|
|
297
|
+
* ```ts
|
|
298
|
+
* const getClient = createTreatyClientFactory<App>({ getBaseUrl, getAccessToken })
|
|
299
|
+
* export function useApi() {
|
|
300
|
+
* const client = getClient()
|
|
301
|
+
* return { api: client.api, client }
|
|
302
|
+
* }
|
|
303
|
+
* ```
|
|
304
|
+
*/
|
|
305
|
+
function createTreatyClientFactory(options) {
|
|
306
|
+
let client = null;
|
|
307
|
+
return function getClient() {
|
|
308
|
+
if (client) return client;
|
|
309
|
+
client = treaty(options.getBaseUrl(), {
|
|
310
|
+
parseDate: options.parseDate ?? false,
|
|
311
|
+
headers: async () => {
|
|
312
|
+
const token = await options.getAccessToken();
|
|
313
|
+
if (token) return { authorization: `Bearer ${token}` };
|
|
314
|
+
},
|
|
315
|
+
...options.treatyConfig
|
|
316
|
+
});
|
|
317
|
+
return client;
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
//#endregion
|
|
321
|
+
//#region src/org/orgStore.ts
|
|
322
|
+
/**
|
|
323
|
+
* Reactive granted-organizations state + switching — the setup body of an
|
|
324
|
+
* org/tenant store. Wrap it in the app's own store (and alias names there):
|
|
325
|
+
*
|
|
326
|
+
* ```ts
|
|
327
|
+
* export const useTenantStore = defineStore('tenant', () => {
|
|
328
|
+
* const core = createOrgStoreCore<Tenant>({ fetchOrganizations, getSlug: t => t.slug })
|
|
329
|
+
* return { ...core, fetchTenants: core.fetchOrganizations }
|
|
330
|
+
* })
|
|
331
|
+
* ```
|
|
332
|
+
*/
|
|
333
|
+
function createOrgStoreCore(options) {
|
|
334
|
+
const persistenceKey = options.persistenceKey ?? "currentOrgSlug";
|
|
335
|
+
const getStorage = () => options.storage ?? globalThis.localStorage;
|
|
336
|
+
const organizations = ref([]);
|
|
337
|
+
const currentSlug = ref(null);
|
|
338
|
+
const loading = ref(false);
|
|
339
|
+
const fetchError = ref(null);
|
|
340
|
+
const currentOrganization = computed(() => {
|
|
341
|
+
if (!currentSlug.value) return null;
|
|
342
|
+
return organizations.value.find((org) => options.getSlug(org) === currentSlug.value) ?? null;
|
|
343
|
+
});
|
|
344
|
+
async function fetchOrganizations() {
|
|
345
|
+
loading.value = true;
|
|
346
|
+
fetchError.value = null;
|
|
347
|
+
try {
|
|
348
|
+
const result = await options.fetchOrganizations();
|
|
349
|
+
if (result.items === void 0) {
|
|
350
|
+
fetchError.value = result.error;
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
organizations.value = result.items;
|
|
354
|
+
if (currentSlug.value) {
|
|
355
|
+
if (!organizations.value.some((org) => options.getSlug(org) === currentSlug.value)) setCurrent(null);
|
|
356
|
+
}
|
|
357
|
+
} finally {
|
|
358
|
+
loading.value = false;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
function setCurrent(slug) {
|
|
362
|
+
currentSlug.value = slug;
|
|
363
|
+
if (slug) getStorage().setItem(persistenceKey, slug);
|
|
364
|
+
else getStorage().removeItem(persistenceKey);
|
|
365
|
+
}
|
|
366
|
+
function loadPersisted() {
|
|
367
|
+
const stored = getStorage().getItem(persistenceKey);
|
|
368
|
+
if (stored) currentSlug.value = stored;
|
|
369
|
+
}
|
|
370
|
+
return {
|
|
371
|
+
organizations,
|
|
372
|
+
currentSlug,
|
|
373
|
+
currentOrganization,
|
|
374
|
+
loading,
|
|
375
|
+
fetchError,
|
|
376
|
+
fetchOrganizations,
|
|
377
|
+
setCurrent,
|
|
378
|
+
loadPersisted
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
//#endregion
|
|
382
|
+
//#region src/composables/useConfirm.ts
|
|
383
|
+
const isOpen = ref(false);
|
|
384
|
+
const currentOptions = ref({ title: "" });
|
|
385
|
+
let resolvePromise = null;
|
|
386
|
+
/** Promise-based confirmation: `if (await confirm({ title, dangerous: true })) …` */
|
|
387
|
+
function useConfirm() {
|
|
388
|
+
function confirm(options) {
|
|
389
|
+
currentOptions.value = options;
|
|
390
|
+
isOpen.value = true;
|
|
391
|
+
return new Promise((resolve) => {
|
|
392
|
+
resolvePromise = resolve;
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
return { confirm };
|
|
396
|
+
}
|
|
397
|
+
/** State + handlers for the dialog renderer component. */
|
|
398
|
+
function useConfirmState() {
|
|
399
|
+
function handleConfirm() {
|
|
400
|
+
isOpen.value = false;
|
|
401
|
+
resolvePromise?.(true);
|
|
402
|
+
resolvePromise = null;
|
|
403
|
+
}
|
|
404
|
+
function handleCancel() {
|
|
405
|
+
isOpen.value = false;
|
|
406
|
+
resolvePromise?.(false);
|
|
407
|
+
resolvePromise = null;
|
|
408
|
+
}
|
|
409
|
+
return {
|
|
410
|
+
isOpen,
|
|
411
|
+
options: currentOptions,
|
|
412
|
+
handleConfirm,
|
|
413
|
+
handleCancel
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
//#endregion
|
|
417
|
+
//#region src/composables/apiErrorMessenger.ts
|
|
418
|
+
/**
|
|
419
|
+
* Map API error bodies to user-facing i18n strings using a fixed key
|
|
420
|
+
* convention the consumer's locale files fulfil:
|
|
421
|
+
*
|
|
422
|
+
* - `errors.<key>` — one entry per API error key (fallback: the raw
|
|
423
|
+
* server `message`, and `errors.internal_server_error` for non-API errors)
|
|
424
|
+
* - `validation.fields.<path>` — display names for validated fields
|
|
425
|
+
* - `validation.messages.<snake_cased_message>` — validation message texts
|
|
426
|
+
*
|
|
427
|
+
* Framework-free: pass `t`/`te` from your i18n instance (the app-side
|
|
428
|
+
* composable is typically `const { t, te } = useI18n()` + this factory).
|
|
429
|
+
* Eden Treaty error envelopes (`{ value }`) are unwrapped automatically.
|
|
430
|
+
*/
|
|
431
|
+
function createApiErrorMessenger(options) {
|
|
432
|
+
const { t, te } = options;
|
|
433
|
+
const log = options.log ?? ((message, error) => console.error(message, error));
|
|
434
|
+
function isApiError(error) {
|
|
435
|
+
return typeof error === "object" && error !== null && "key" in error && "message" in error;
|
|
436
|
+
}
|
|
437
|
+
function isValidationError(error) {
|
|
438
|
+
return isApiError(error) && error.key === "validation_error";
|
|
439
|
+
}
|
|
440
|
+
function getErrorMessage(error) {
|
|
441
|
+
log("API Error:", error);
|
|
442
|
+
let actualError = error;
|
|
443
|
+
if (typeof error === "object" && error !== null && "value" in error) actualError = error.value;
|
|
444
|
+
if (isValidationError(actualError)) return actualError.fields.map((f) => {
|
|
445
|
+
const fieldKey = `validation.fields.${f.path}`;
|
|
446
|
+
const fieldName = te(fieldKey) ? t(fieldKey) : f.path;
|
|
447
|
+
const messageKey = `validation.messages.${f.message.toLowerCase().replace(/\s+/g, "_")}`;
|
|
448
|
+
return `${fieldName}: ${te(messageKey) ? t(messageKey) : f.message}`;
|
|
449
|
+
}).join(", ") || t("errors.validation_error");
|
|
450
|
+
if (!isApiError(actualError)) return t("errors.internal_server_error");
|
|
451
|
+
const errorKey = `errors.${actualError.key}`;
|
|
452
|
+
if (te(errorKey)) return t(errorKey);
|
|
453
|
+
return actualError.message;
|
|
454
|
+
}
|
|
455
|
+
return {
|
|
456
|
+
getErrorMessage,
|
|
457
|
+
isValidationError
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
//#endregion
|
|
461
|
+
//#region src/composables/useDirtyTracking.ts
|
|
462
|
+
function deepClone(obj) {
|
|
463
|
+
return JSON.parse(JSON.stringify(obj));
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* Form change-detection over a reactive state object via JSON deep-compare:
|
|
467
|
+
* `isDirty` flips when any field differs from the snapshot; `resetInitial()`
|
|
468
|
+
* re-snapshots after load/save (optionally assigning new values first);
|
|
469
|
+
* `getDirtyFields()` yields a minimal PATCH payload.
|
|
470
|
+
*/
|
|
471
|
+
function useDirtyTracking(state) {
|
|
472
|
+
const initial = ref(deepClone(state));
|
|
473
|
+
const isDirty = computed(() => JSON.stringify(state) !== JSON.stringify(initial.value));
|
|
474
|
+
function getDirtyFields() {
|
|
475
|
+
const dirty = {};
|
|
476
|
+
for (const key of Object.keys(state)) if (JSON.stringify(state[key]) !== JSON.stringify(initial.value[key])) dirty[key] = state[key];
|
|
477
|
+
return dirty;
|
|
478
|
+
}
|
|
479
|
+
function resetInitial(values) {
|
|
480
|
+
if (values) Object.assign(state, values);
|
|
481
|
+
initial.value = deepClone(state);
|
|
482
|
+
}
|
|
483
|
+
return {
|
|
484
|
+
isDirty,
|
|
485
|
+
getDirtyFields,
|
|
486
|
+
resetInitial
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
//#endregion
|
|
490
|
+
//#region src/composables/usePagination.ts
|
|
491
|
+
/**
|
|
492
|
+
* Offset-based table pagination: `page`/`itemsPerPage`/`total` state with a
|
|
493
|
+
* derived `offset` and ready-to-spread `queryParams { limit, offset }`.
|
|
494
|
+
* `onPaginationChange` fires whenever page or page size changes (refetch hook).
|
|
495
|
+
*/
|
|
496
|
+
function usePagination(options = {}) {
|
|
497
|
+
const { defaultLimit = 50, onPaginationChange } = options;
|
|
498
|
+
const page = ref(1);
|
|
499
|
+
const itemsPerPage = ref(defaultLimit);
|
|
500
|
+
const total = ref(0);
|
|
501
|
+
const offset = computed(() => (page.value - 1) * itemsPerPage.value);
|
|
502
|
+
const queryParams = computed(() => ({
|
|
503
|
+
limit: itemsPerPage.value,
|
|
504
|
+
offset: offset.value
|
|
505
|
+
}));
|
|
506
|
+
function setTotal(value) {
|
|
507
|
+
total.value = value;
|
|
508
|
+
}
|
|
509
|
+
function resetPagination() {
|
|
510
|
+
page.value = 1;
|
|
511
|
+
}
|
|
512
|
+
watch([page, itemsPerPage], () => {
|
|
513
|
+
onPaginationChange?.();
|
|
514
|
+
});
|
|
515
|
+
return {
|
|
516
|
+
page,
|
|
517
|
+
itemsPerPage,
|
|
518
|
+
total,
|
|
519
|
+
offset,
|
|
520
|
+
queryParams,
|
|
521
|
+
setTotal,
|
|
522
|
+
resetPagination
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
//#endregion
|
|
526
|
+
export { ZITADEL_ORG_PROJECT_SCOPE, ZITADEL_REFRESH_TOKEN_ALLOWED_SCOPE, attachSessionLifecycleHandlers, createAccessTokenProvider, createApiErrorMessenger, createAuthGuard, createAuthSessionCore, createLoginRedirector, createOrgStoreCore, createTreatyClientFactory, createUserManagerFactory, defaultAuthUserMapper, isUnrecoverableRenewError, removeStaleOidcKeys, resolveApiBaseUrl, seedAuthBypassSession, useConfirm, useConfirmState, useDirtyTracking, usePagination };
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import * as z from "zod";
|
|
2
|
+
//#region src/zod/index.d.ts
|
|
3
|
+
type ZodLocaleFactory = () => Parameters<typeof z.config>[0];
|
|
4
|
+
interface ZodLocaleSyncOptions {
|
|
5
|
+
/** Locale code → zod locale factory (from `zod/locales`), e.g. `{ de, en }`. */
|
|
6
|
+
locales: Record<string, ZodLocaleFactory>;
|
|
7
|
+
/** Applied when the active locale has no entry in `locales`. */
|
|
8
|
+
defaultLocale: string;
|
|
9
|
+
/** Read the active UI locale code. */
|
|
10
|
+
getLocale: () => string;
|
|
11
|
+
/**
|
|
12
|
+
* Wire locale-change reactivity — call `apply` with the new code whenever
|
|
13
|
+
* the UI locale changes (e.g. `apply => watch(() => i18n.locale.value, apply)`).
|
|
14
|
+
*/
|
|
15
|
+
onLocaleChange: (apply: (code: string) => void) => void;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Keep Zod's built-in error messages in the user's language: applies the
|
|
19
|
+
* matching `zod/locales` config immediately and re-applies on every locale
|
|
20
|
+
* change. Call once from an app plugin.
|
|
21
|
+
*/
|
|
22
|
+
declare function setupZodLocaleSync(options: ZodLocaleSyncOptions): void;
|
|
23
|
+
//#endregion
|
|
24
|
+
export { ZodLocaleSyncOptions, setupZodLocaleSync };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import * as z from "zod";
|
|
2
|
+
//#region src/zod/index.ts
|
|
3
|
+
/**
|
|
4
|
+
* Keep Zod's built-in error messages in the user's language: applies the
|
|
5
|
+
* matching `zod/locales` config immediately and re-applies on every locale
|
|
6
|
+
* change. Call once from an app plugin.
|
|
7
|
+
*/
|
|
8
|
+
function setupZodLocaleSync(options) {
|
|
9
|
+
const apply = (code) => {
|
|
10
|
+
const factory = options.locales[code] ?? options.locales[options.defaultLocale];
|
|
11
|
+
if (factory) z.config(factory());
|
|
12
|
+
};
|
|
13
|
+
apply(options.getLocale());
|
|
14
|
+
options.onLocaleChange(apply);
|
|
15
|
+
}
|
|
16
|
+
//#endregion
|
|
17
|
+
export { setupZodLocaleSync };
|
package/package.json
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@octabits-io/nuxt-ui-kit",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Frontend kit for Nuxt/Vue admin SPAs: OIDC session harness (oidc-client-ts), Eden Treaty client factory, auth/org store cores, and a route-guard builder — factory-style seams the app wires into its own plugins, stores, and middleware",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"import": "./dist/index.js",
|
|
11
|
+
"default": "./dist/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./zod": {
|
|
14
|
+
"types": "./dist/zod/index.d.ts",
|
|
15
|
+
"import": "./dist/zod/index.js",
|
|
16
|
+
"default": "./dist/zod/index.js"
|
|
17
|
+
},
|
|
18
|
+
"./dates": {
|
|
19
|
+
"types": "./dist/dates/index.d.ts",
|
|
20
|
+
"import": "./dist/dates/index.js",
|
|
21
|
+
"default": "./dist/dates/index.js"
|
|
22
|
+
},
|
|
23
|
+
"./ai": {
|
|
24
|
+
"types": "./dist/ai/index.d.ts",
|
|
25
|
+
"import": "./dist/ai/index.js",
|
|
26
|
+
"default": "./dist/ai/index.js"
|
|
27
|
+
},
|
|
28
|
+
"./components/*": "./src/components/*"
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"dist",
|
|
32
|
+
"src/components",
|
|
33
|
+
"README.md",
|
|
34
|
+
"LICENSE"
|
|
35
|
+
],
|
|
36
|
+
"publishConfig": {
|
|
37
|
+
"access": "public"
|
|
38
|
+
},
|
|
39
|
+
"repository": {
|
|
40
|
+
"type": "git",
|
|
41
|
+
"url": "https://github.com/octabits-io/platform.git",
|
|
42
|
+
"directory": "packages/nuxt-ui-kit"
|
|
43
|
+
},
|
|
44
|
+
"homepage": "https://github.com/octabits-io/platform/tree/main/packages/nuxt-ui-kit",
|
|
45
|
+
"bugs": {
|
|
46
|
+
"url": "https://github.com/octabits-io/platform/issues"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@elysiajs/eden": "^1.4.9",
|
|
50
|
+
"date-fns": "^4.4.0",
|
|
51
|
+
"elysia": "^1.4.29",
|
|
52
|
+
"oidc-client-ts": "^3.5.0",
|
|
53
|
+
"vitest": "^4.1.10",
|
|
54
|
+
"vue": "^3.5.39",
|
|
55
|
+
"zod": "^4.4.3"
|
|
56
|
+
},
|
|
57
|
+
"peerDependencies": {
|
|
58
|
+
"@elysiajs/eden": "^1.4.0",
|
|
59
|
+
"@internationalized/date": "^3",
|
|
60
|
+
"@nuxt/ui": "^4",
|
|
61
|
+
"date-fns": "^3 || ^4",
|
|
62
|
+
"elysia": ">=1.4 <3",
|
|
63
|
+
"oidc-client-ts": "^3.3.0",
|
|
64
|
+
"typescript": "^5 || ^6 || ^7",
|
|
65
|
+
"vue": "^3.5.0",
|
|
66
|
+
"vue-i18n": "^11",
|
|
67
|
+
"vue-router": "^4",
|
|
68
|
+
"zod": "^4"
|
|
69
|
+
},
|
|
70
|
+
"peerDependenciesMeta": {
|
|
71
|
+
"@internationalized/date": {
|
|
72
|
+
"optional": true
|
|
73
|
+
},
|
|
74
|
+
"@nuxt/ui": {
|
|
75
|
+
"optional": true
|
|
76
|
+
},
|
|
77
|
+
"date-fns": {
|
|
78
|
+
"optional": true
|
|
79
|
+
},
|
|
80
|
+
"elysia": {
|
|
81
|
+
"optional": true
|
|
82
|
+
},
|
|
83
|
+
"typescript": {
|
|
84
|
+
"optional": true
|
|
85
|
+
},
|
|
86
|
+
"vue-i18n": {
|
|
87
|
+
"optional": true
|
|
88
|
+
},
|
|
89
|
+
"vue-router": {
|
|
90
|
+
"optional": true
|
|
91
|
+
},
|
|
92
|
+
"zod": {
|
|
93
|
+
"optional": true
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
"scripts": {
|
|
97
|
+
"build": "tsdown",
|
|
98
|
+
"clean": "rm -rf dist",
|
|
99
|
+
"test": "vitest run",
|
|
100
|
+
"typecheck": "tsc --noEmit"
|
|
101
|
+
}
|
|
102
|
+
}
|