@dev.smartpricing/platform-layer 0.0.2 → 0.0.4

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 ADDED
@@ -0,0 +1,572 @@
1
+ # @dev.smartpricing/platform-layer
2
+
3
+ Nuxt layer providing authentication, API client, and data-fetching composables for the Smartness platform. Built on [`ofetch`](https://github.com/unjs/ofetch) and [`@pinia/colada`](https://pinia-colada.esm.dev). Designed for any Nuxt application that needs to interact with the Smartness backend.
4
+
5
+ ## Table of Contents
6
+
7
+ - [Installation](#installation)
8
+ - [Setup](#setup)
9
+ - [What's Included](#whats-included)
10
+ - [API Client](#api-client)
11
+ - [`createApiClient()`](#createapiclientoptions)
12
+ - [`useApiClient()`](#useapiclient)
13
+ - [Authentication](#authentication)
14
+ - [`useAuth()`](#useauth)
15
+ - [Queries](#queries)
16
+ - [Mutations](#mutations)
17
+ - [Overriding Layer Behavior](#overriding-layer-behavior)
18
+ - [Development](#development)
19
+
20
+ ## Installation
21
+
22
+ ```bash
23
+ pnpm add @dev.smartpricing/platform-layer
24
+ ```
25
+
26
+ ### Peer Dependencies
27
+
28
+ The layer requires the Smartness UI layer:
29
+
30
+ ```bash
31
+ pnpm add nuxt-ui-layer@"github:smartpricing/smartness-nuxt-ui#v1.6.3"
32
+ ```
33
+
34
+ ## Setup
35
+
36
+ ### 1. Extend the Layer
37
+
38
+ ```ts
39
+ // nuxt.config.ts
40
+ export default defineNuxtConfig({
41
+ extends: ['@dev.smartpricing/platform-layer'],
42
+ })
43
+ ```
44
+
45
+ ### 2. Configure Runtime Environment
46
+
47
+ Set these environment variables (or define them in `nuxt.config.ts` under `runtimeConfig.public`):
48
+
49
+ | Variable | Description | Default |
50
+ |----------|-------------|---------|
51
+ | `NUXT_PUBLIC_BACKEND_BASE_URL` | Backend API base URL | `''` |
52
+ | `NUXT_PUBLIC_ENV_CSRF_COOKIE_NAME` | CSRF cookie name | `smt_csrf` |
53
+ | `NUXT_PUBLIC_PLATFORM_URL` | Platform URL for auth redirects | `''` |
54
+
55
+ ```bash
56
+ # .env
57
+ NUXT_PUBLIC_BACKEND_BASE_URL=https://api.smartpricing.com
58
+ NUXT_PUBLIC_ENV_CSRF_COOKIE_NAME=smt_csrf
59
+ NUXT_PUBLIC_PLATFORM_URL=https://app.smartpricing.com
60
+ ```
61
+
62
+ ## What's Included
63
+
64
+ The layer auto-registers these [Nuxt modules](https://nuxt.com/docs/guide/concepts/modules):
65
+
66
+ | Module | Purpose | Docs |
67
+ |--------|---------|------|
68
+ | [`@pinia/nuxt`](https://pinia.vuejs.org/ssr/nuxt.html) | State management | [Pinia docs](https://pinia.vuejs.org) |
69
+ | [`@pinia/colada-nuxt`](https://pinia-colada.esm.dev) | Async data caching, queries & mutations | [Pinia Colada docs](https://pinia-colada.esm.dev) |
70
+ | [`@nuxtjs/i18n`](https://i18n.nuxtjs.org) | Internationalization (en, it, de, es) | [Nuxt i18n docs](https://i18n.nuxtjs.org) |
71
+ | [`@vueuse/nuxt`](https://vueuse.org/nuxt/README.html) | Utility composables | [VueUse docs](https://vueuse.org) |
72
+ | [`nuxt-ui-layer`](https://github.com/smartpricing/smartness-nuxt-ui) | Smartness design system | — |
73
+
74
+ All composables, queries, and mutations are **auto-imported** — no manual imports needed.
75
+
76
+ ---
77
+
78
+ ## API Client
79
+
80
+ ### `createApiClient(options)`
81
+
82
+ > Factory function that creates a configured [`$fetch`](https://github.com/unjs/ofetch) instance using [`ofetch.create()`](https://github.com/unjs/ofetch#%EF%B8%8F-create-fetch-with-default-options). This is a low-level utility — most apps should use [`useApiClient()`](#useapiclient) instead.
83
+
84
+ #### Options
85
+
86
+ ```ts
87
+ interface ApiClientOptions {
88
+ baseURL: string
89
+ csrf?: ApiClientCsrfConfig
90
+ auth?: ApiClientAuthConfig
91
+ errorSerializer?: (context: { status: number; statusText: string; data: unknown }) => unknown
92
+ onRequest?: FetchHook<FetchContext>
93
+ onResponseError?: FetchHook<FetchContext & { response: Response }>
94
+ }
95
+ ```
96
+
97
+ | Option | Type | Description |
98
+ |--------|------|-------------|
99
+ | `baseURL` | `string` | Base URL prepended to all requests. Slash handling is automatic ([ofetch docs](https://github.com/unjs/ofetch#baseurl)). |
100
+ | `csrf` | `ApiClientCsrfConfig` | CSRF token injection config. |
101
+ | `auth` | `ApiClientAuthConfig` | Auth config for token refresh and MFA. When omitted, returns a raw `$fetch` instance with no auth handling. |
102
+ | `errorSerializer` | `(context) => unknown` | Custom error serializer. Receives `{ status, statusText, data }` from [`FetchError`](https://github.com/unjs/ofetch#%EF%B8%8F-access-to-raw-response). |
103
+ | `onRequest` | `FetchHook<FetchContext>` | Custom [`onRequest` interceptor](https://github.com/unjs/ofetch#%EF%B8%8F-interceptors) appended after built-in hooks (cookie forwarding, CSRF). |
104
+ | `onResponseError` | `FetchHook<FetchContext & { response: Response }>` | Custom [`onResponseError` interceptor](https://github.com/unjs/ofetch#%EF%B8%8F-interceptors) called after auth/MFA handling. |
105
+
106
+ #### CSRF Config
107
+
108
+ ```ts
109
+ interface ApiClientCsrfConfig {
110
+ cookieName: string
111
+ headerName?: string // default: 'X-CSRF-Token'
112
+ }
113
+ ```
114
+
115
+ The client reads the CSRF token from the specified cookie via Nuxt's [`useCookie()`](https://nuxt.com/docs/api/composables/use-cookie) and injects it as a request header on every request using an [`onRequest` interceptor](https://github.com/unjs/ofetch#%EF%B8%8F-interceptors).
116
+
117
+ #### Auth Config
118
+
119
+ ```ts
120
+ interface ApiClientAuthConfig {
121
+ refreshBaseURL: string
122
+ refreshEndpoint?: string // default: '/api/auth/refresh'
123
+ platformURL: string
124
+ shouldRefresh?: (status: number, data: unknown) => boolean
125
+ onRefreshFailed?: () => void
126
+ mfa?: ApiClientMfaConfig
127
+ }
128
+
129
+ interface ApiClientMfaConfig {
130
+ shouldChallenge: (status: number, data: unknown) => boolean
131
+ challengePath?: string // default: '/auth/mfa-challenge'
132
+ }
133
+ ```
134
+
135
+ When `auth` is provided, the client wraps the raw `$fetch` instance with error-handling logic:
136
+
137
+ 1. **MFA challenge** — If `mfa.shouldChallenge()` returns `true`, redirects the user to `{platformURL}{challengePath}?redirect={currentUrl}`.
138
+ 2. **Token refresh** — If `shouldRefresh()` returns `true` (default: `401` with `session.expired` or `session.missing` error code), calls `POST {refreshEndpoint}` with [`credentials: 'include'`](https://github.com/unjs/ofetch#%EF%B8%8F-adding-headers). On success, retries the original request once.
139
+ 3. **Refresh dedup** — Concurrent 401s share a single refresh call (module-level promise deduplication).
140
+ 4. **Refresh failure** — Calls `onRefreshFailed()` if the refresh itself fails.
141
+ 5. **Error serialization** — If `errorSerializer` is set, transforms [`FetchError`](https://github.com/unjs/ofetch#%EF%B8%8F-access-to-raw-response) before rethrowing.
142
+
143
+ #### SSR Cookie Forwarding
144
+
145
+ On the server, the client reads the incoming request cookies via Nuxt's [`useRequestHeaders()`](https://nuxt.com/docs/api/composables/use-request-headers) and forwards them to the backend, ensuring cookie-based auth works during SSR.
146
+
147
+ #### Example: Custom Client
148
+
149
+ ```ts
150
+ const client = createApiClient({
151
+ baseURL: 'https://api.example.com',
152
+ csrf: { cookieName: 'my_csrf' },
153
+ auth: {
154
+ refreshBaseURL: 'https://api.example.com',
155
+ platformURL: 'https://app.example.com',
156
+ onRefreshFailed: () => navigateTo('/login'),
157
+ },
158
+ errorSerializer: ({ status, data }) => ({
159
+ code: status,
160
+ message: (data as any)?.error?.message ?? 'Unknown error',
161
+ }),
162
+ })
163
+
164
+ const users = await client<User[]>('/users', { query: { active: true } })
165
+ ```
166
+
167
+ ---
168
+
169
+ ### `useApiClient()`
170
+
171
+ > [Vue composable](https://vuejs.org/guide/reusability/composables.html) that returns a pre-configured `$fetch` instance using the layer's [runtime config](https://nuxt.com/docs/guide/going-further/runtime-config). This is the primary way to make API calls in consuming apps.
172
+
173
+ Internally calls [`createApiClient()`](#createapiclientoptions) with the runtime config values (`BACKEND_BASE_URL`, `ENV_CSRF_COOKIE_NAME`, `PLATFORM_URL`).
174
+
175
+ #### Features
176
+
177
+ - **CSRF token injection** — Reads from the `smt_csrf` cookie, sends as `X-CSRF-Token` header.
178
+ - **Auto token refresh** — On 401 with `session.expired`/`session.missing`, refreshes and retries once.
179
+ - **MFA challenge** — On `otp_required` error code, redirects to `/auth/mfa-challenge`.
180
+ - **SSR cookie forwarding** — Forwards request cookies during server-side rendering.
181
+ - **Auth redirect** — Redirects to `{PLATFORM_URL}/auth/login` when refresh fails.
182
+
183
+ #### Usage
184
+
185
+ ```vue
186
+ <script setup lang="ts">
187
+ // Auto-imported — no import needed
188
+ const client = useApiClient()
189
+
190
+ // GET with typed response
191
+ const users = await client<User[]>('/api/users')
192
+
193
+ // POST with body (auto-serialized as JSON)
194
+ const created = await client<User>('/api/users', {
195
+ method: 'POST',
196
+ body: { name: 'Alice', email: 'alice@example.com' },
197
+ })
198
+
199
+ // GET with query parameters
200
+ const filtered = await client<User[]>('/api/users', {
201
+ query: { role: 'admin', active: true },
202
+ })
203
+ ```
204
+
205
+ #### Using with Pinia Colada Queries
206
+
207
+ ```vue
208
+ <script setup lang="ts">
209
+ const client = useApiClient()
210
+
211
+ const { data, status, error } = useQuery({
212
+ key: ['custom-data'],
213
+ query: () => client<MyData>('/api/custom-endpoint'),
214
+ })
215
+ </script>
216
+ ```
217
+
218
+ #### Using with Pinia Colada Mutations
219
+
220
+ ```vue
221
+ <script setup lang="ts">
222
+ const client = useApiClient()
223
+
224
+ const { mutateAsync, status } = useMutation({
225
+ mutation: (body: CreateItemRequest) =>
226
+ client<CreateItemResponse>('/api/items', {
227
+ method: 'POST',
228
+ body,
229
+ }),
230
+ })
231
+
232
+ async function handleSubmit(data: CreateItemRequest) {
233
+ try {
234
+ const result = await mutateAsync(data)
235
+ }
236
+ catch (error) {
237
+ // FetchError with parsed body in error.data
238
+ }
239
+ }
240
+ </script>
241
+ ```
242
+
243
+ ---
244
+
245
+ ## Authentication
246
+
247
+ ### `useAuth()`
248
+
249
+ > [Vue composable](https://vuejs.org/guide/reusability/composables.html) that wraps the [`useLogoutMutation()`](#authentication-mutations) and provides a sign-out flow with redirect.
250
+
251
+ #### Return Values
252
+
253
+ | Property | Type | Description |
254
+ |----------|------|-------------|
255
+ | `signOut` | `() => Promise<void>` | Calls `POST /api/auth/logout` then redirects to `{PLATFORM_URL}/auth/login` |
256
+ | `logoutStatus` | `Ref<'idle' \| 'pending' \| 'success' \| 'error'>` | [Mutation status](https://pinia-colada.esm.dev/guide/mutations.html) |
257
+ | `logoutError` | `Ref<Error \| null>` | Error if logout failed |
258
+
259
+ #### Usage
260
+
261
+ ```vue
262
+ <script setup lang="ts">
263
+ const { signOut, logoutStatus } = useAuth()
264
+ </script>
265
+
266
+ <template>
267
+ <button :disabled="logoutStatus === 'pending'" @click="signOut">
268
+ Sign Out
269
+ </button>
270
+ </template>
271
+ ```
272
+
273
+ ---
274
+
275
+ ## Queries
276
+
277
+ All queries use [`useQuery()`](https://pinia-colada.esm.dev/guide/queries.html) from Pinia Colada. Each returns reactive `data`, `status`, `error`, `asyncStatus`, `refetch`, and `refresh`.
278
+
279
+ Each query file also exports a **key factory** (e.g., `sessionKeys`, `billingOverviewKeys`) for use with [`queryCache.invalidateQueries()`](https://pinia-colada.esm.dev/guide/mutations.html#query-invalidation).
280
+
281
+ ### Session & User
282
+
283
+ | Composable | Endpoint | Response Type | Key Factory |
284
+ |------------|----------|---------------|-------------|
285
+ | `useSessionQuery()` | `GET /api/me` | `MeContextResponse` | `sessionKeys.me()` |
286
+ | `usePermissionsQuery()` | `GET /api/me/permissions` | `PermissionsResponse` | `permissionsKeys.all()` |
287
+ | `useCrossSellingQuery()` | `GET /api/me/cross-selling` | `GetCrossSellingResponse` | `crossSellingKeys.all()` |
288
+
289
+ ### Organization
290
+
291
+ | Composable | Endpoint | Response Type | Key Factory |
292
+ |------------|----------|---------------|-------------|
293
+ | `useOrganizationQuery(orgId)` | `GET /api/orgs/{orgId}` | `OrganizationContextResponse` | `organizationKeys.byId(orgId)` |
294
+
295
+ **Params:** `orgId: MaybeRefOrGetter<string>` — enabled only when `orgId` is truthy.
296
+
297
+ ### Billing
298
+
299
+ | Composable | Endpoint | Response Type | Key Factory |
300
+ |------------|----------|---------------|-------------|
301
+ | `useBillingOverviewQuery(orgId)` | `GET /api/orgs/{orgId}/billing` | `BillingOverviewResponse` | `billingOverviewKeys.byOrg(orgId)` |
302
+ | `useBillingDocumentsQuery(orgId, params?)` | `GET /api/orgs/{orgId}/billing/documents` | `ListBillingDocumentsResponse` | `billingDocumentsKeys.byOrg(orgId, params)` |
303
+ | `useLegalEntityQuery(orgId, id)` | `GET /api/orgs/{orgId}/billing/legal-entities/{id}` | `LegalEntityDetail` | `legalEntityKeys.byId(orgId, id)` |
304
+ | `useBillingDocumentPdfQuery(orgId, type, id)` | `GET /api/orgs/{orgId}/billing/documents/{type}/{id}/pdf` | `BillingDocumentPdfResponse` | `billingDocumentPdfKeys.byId(orgId, type, id)` |
305
+
306
+ #### Billing Documents Filter Params
307
+
308
+ ```ts
309
+ interface BillingDocumentsQueryParams {
310
+ legalEntityId?: string
311
+ customerId?: string
312
+ limit?: number
313
+ after?: string
314
+ before?: string
315
+ subscriptionIds?: string[]
316
+ type?: string
317
+ status?: string
318
+ search?: string
319
+ }
320
+ ```
321
+
322
+ ### Query Usage Examples
323
+
324
+ ```vue
325
+ <script setup lang="ts">
326
+ const route = useRoute()
327
+
328
+ // Static query — fetches immediately
329
+ const { data: session, status } = useSessionQuery()
330
+
331
+ // Dynamic query — re-fetches when orgId changes
332
+ const { data: org } = useOrganizationQuery(() => route.params.orgId as string)
333
+
334
+ // Conditional query with filter params
335
+ const filters = ref<BillingDocumentsQueryParams>({ limit: 20 })
336
+ const { data: docs } = useBillingDocumentsQuery(
337
+ () => route.params.orgId as string,
338
+ filters,
339
+ )
340
+ </script>
341
+
342
+ <template>
343
+ <div v-if="status === 'pending'">Loading...</div>
344
+ <div v-else-if="status === 'error'">Error loading session</div>
345
+ <div v-else>Welcome, {{ session.name }}</div>
346
+ </template>
347
+ ```
348
+
349
+ ### Invalidating Queries After Mutations
350
+
351
+ Use the exported key factories with [`useQueryCache()`](https://pinia-colada.esm.dev/guide/mutations.html#query-invalidation):
352
+
353
+ ```vue
354
+ <script setup lang="ts">
355
+ import { useQueryCache } from '@pinia/colada'
356
+
357
+ const queryCache = useQueryCache()
358
+
359
+ const { mutateAsync: updateProfile } = useUpdateProfileMutation()
360
+
361
+ async function handleSave(data: UpdateProfileRequest) {
362
+ await updateProfile(data)
363
+ // Invalidate session to refetch updated user data
364
+ queryCache.invalidateQueries({ key: sessionKeys.me() })
365
+ }
366
+ </script>
367
+ ```
368
+
369
+ ---
370
+
371
+ ## Mutations
372
+
373
+ All mutations use [`useMutation()`](https://pinia-colada.esm.dev/guide/mutations.html) from Pinia Colada. Each returns:
374
+
375
+ | Property | Type | Description |
376
+ |----------|------|-------------|
377
+ | `mutate` | `(params) => void` | Fire-and-forget — errors handled via `onError` hook |
378
+ | `mutateAsync` | `(params) => Promise<TResult>` | Returns promise — use `try/catch` for errors |
379
+ | `status` | `Ref<'idle' \| 'pending' \| 'success' \| 'error'>` | Overall [mutation status](https://pinia-colada.esm.dev/guide/mutations.html) |
380
+ | `asyncStatus` | `Ref<'idle' \| 'loading'>` | Whether a request is in-flight |
381
+ | `data` | `Ref<TResult \| undefined>` | Last successful response |
382
+ | `error` | `Ref<Error \| null>` | Last error |
383
+ | `variables` | `Ref<TParams \| undefined>` | Last mutation parameters |
384
+ | `reset` | `() => void` | Reset to initial state |
385
+
386
+ ### Authentication Mutations
387
+
388
+ | Composable | Method | Endpoint | Request | Response |
389
+ |------------|--------|----------|---------|----------|
390
+ | `useLoginMutation()` | `POST` | `/api/auth/login` | `LoginRequest` | `void` |
391
+ | `useLogoutMutation()` | `POST` | `/api/auth/logout` | — | `void` |
392
+ | `useRefreshMutation()` | `POST` | `/api/auth/refresh` | — | `void` |
393
+ | `useActivateMutation()` | `POST` | `/api/auth/activate` | `ActivateRequest` | `void` |
394
+ | `useAccountingLoginMutation()` | `POST` | `/api/auth/accounting-login` | `AccountingLoginRequest` | `void` |
395
+
396
+ ### MFA Mutations
397
+
398
+ | Composable | Method | Endpoint | Request | Response |
399
+ |------------|--------|----------|---------|----------|
400
+ | `useMfaSetupMutation()` | `POST` | `/api/auth/mfa/setup` | — | `MfaSetupResponse` |
401
+ | `useMfaStepUpMutation()` | `POST` | `/api/auth/otp` | `MfaStepUpRequest` | `MfaStepUpResponse` |
402
+ | `useMfaFinalizeMutation()` | `POST` | `/api/auth/mfa/finalize` | `MfaFinalizeRequest` | `MfaFinalizeResponse` |
403
+ | `useMfaDisableMutation()` | `POST` | `/api/auth/mfa/disable` | `MfaDisableRequest` | `MfaDisableResponse` |
404
+ | `useMfaRegenerateRecoveryCodesMutation()` | `POST` | `/api/auth/mfa/regenerate-recovery-codes` | `MfaRegenerateRecoveryCodesRequest` | `MfaRegenerateRecoveryCodesResponse` |
405
+
406
+ ### Profile Mutations
407
+
408
+ | Composable | Method | Endpoint | Request | Response |
409
+ |------------|--------|----------|---------|----------|
410
+ | `useUpdateProfileMutation()` | `PATCH` | `/api/me` | `UpdateProfileRequest` | `UpdateProfileResponse` |
411
+ | `useChangePasswordMutation()` | `POST` | `/api/me/change-password` | `ChangePasswordRequest` | `{ ok: true }` |
412
+ | `useRequestPasswordResetMutation()` | `POST` | `/api/auth/request-password-reset` | `ForgotPasswordRequest` | `void` |
413
+ | `useResetPasswordMutation()` | `POST` | `/api/auth/reset-password` | `ResetPasswordRequest` | `void` |
414
+
415
+ ### Billing Mutations
416
+
417
+ | Composable | Method | Endpoint | Request | Response |
418
+ |------------|--------|----------|---------|----------|
419
+ | `useCreatePaymentMethodMutation()` | `POST` | `/api/orgs/{orgId}/billing/payment-methods` | `{ orgId, body: CreatePaymentMethodRequest }` | `CreatePaymentMethodsResponse` |
420
+ | `useSetPrimaryPaymentMethodMutation()` | `PATCH` | `/api/orgs/{orgId}/billing/payment-methods/primary` | `{ orgId, body: SetPrimaryPaymentMethodRequest }` | `SetPrimaryPaymentMethodResponse` |
421
+ | `useRemovePaymentMethodMutation()` | `DELETE` | `/api/orgs/{orgId}/billing/payment-methods/{paymentMethodId}` | `{ orgId, paymentMethodId }` | `void` |
422
+ | `useCreateSetupIntentMutation()` | `POST` | `/api/orgs/{orgId}/billing/payment-methods/setup-intent` | `{ orgId, body: CreateSetupIntentRequest }` | `CreateSetupIntentResponse` |
423
+
424
+ ### Organization Mutations
425
+
426
+ | Composable | Method | Endpoint | Request | Response |
427
+ |------------|--------|----------|---------|----------|
428
+ | `useUpdateLegalEntityMutation()` | `PATCH` | `/api/orgs/{orgId}/billing/legal-entities/{id}` | `{ orgId, id, body: UpdateLegalEntityRequest }` | `LegalEntityDetail` |
429
+ | `useRequestLegalEntityChangeMutation()` | `POST` | `/api/orgs/{orgId}/billing/legal-entities/{id}/change-request` | `{ orgId, id, body: RequestLegalEntityChangeRequest }` | `{ emails_status: 'sent' \| 'failed' }` |
430
+ | `useRequestSubscriptionCancellationMutation()` | `POST` | `/api/orgs/{orgId}/subscriptions/request-cancellation` | `{ orgId, body: SubscriptionCancellationRequest }` | `SubscriptionCancellationResponse` |
431
+
432
+ ### Mutation Usage Examples
433
+
434
+ #### Login Flow
435
+
436
+ ```vue
437
+ <script setup lang="ts">
438
+ const { mutateAsync: login, status, error } = useLoginMutation()
439
+
440
+ async function handleLogin(email: string, password: string) {
441
+ try {
442
+ await login({ email, password })
443
+ await navigateTo('/dashboard')
444
+ }
445
+ catch (err) {
446
+ // error.value is also set reactively
447
+ }
448
+ }
449
+ </script>
450
+
451
+ <template>
452
+ <form @submit.prevent="handleLogin(email, password)">
453
+ <p v-if="error">{{ error.message }}</p>
454
+ <button :disabled="status === 'pending'" type="submit">
455
+ {{ status === 'pending' ? 'Signing in...' : 'Sign In' }}
456
+ </button>
457
+ </form>
458
+ </template>
459
+ ```
460
+
461
+ #### MFA Setup Flow
462
+
463
+ ```vue
464
+ <script setup lang="ts">
465
+ const { mutateAsync: setupMfa } = useMfaSetupMutation()
466
+ const { mutateAsync: finalizeMfa } = useMfaFinalizeMutation()
467
+
468
+ // Step 1: Get QR code / secret
469
+ const setupData = await setupMfa()
470
+
471
+ // Step 2: User enters TOTP code, finalize
472
+ await finalizeMfa({ otp: userCode })
473
+ </script>
474
+ ```
475
+
476
+ #### Billing Operations with Query Invalidation
477
+
478
+ ```vue
479
+ <script setup lang="ts">
480
+ import { useQueryCache } from '@pinia/colada'
481
+
482
+ const queryCache = useQueryCache()
483
+ const orgId = computed(() => route.params.orgId as string)
484
+
485
+ const { mutateAsync: removeMethod, status } = useRemovePaymentMethodMutation()
486
+
487
+ async function handleRemove(paymentMethodId: string) {
488
+ try {
489
+ await removeMethod({ orgId: orgId.value, paymentMethodId })
490
+
491
+ // Refetch billing data after removing payment method
492
+ queryCache.invalidateQueries({ key: billingOverviewKeys.byOrg(orgId.value) })
493
+ }
494
+ catch (err) {
495
+ // Handle error
496
+ }
497
+ }
498
+ </script>
499
+ ```
500
+
501
+ #### Fire-and-Forget vs Async
502
+
503
+ ```vue
504
+ <script setup lang="ts">
505
+ const { mutate, mutateAsync, status, error } = useUpdateProfileMutation()
506
+
507
+ // Fire-and-forget — errors are swallowed, check error ref reactively
508
+ mutate({ name: 'New Name' })
509
+
510
+ // Async — errors rethrown, use try/catch
511
+ try {
512
+ const result = await mutateAsync({ name: 'New Name' })
513
+ }
514
+ catch (err) {
515
+ console.error('Update failed:', err)
516
+ }
517
+ </script>
518
+ ```
519
+
520
+ > All request/response types are imported from `@package/platform-shared`. See the shared package for type definitions.
521
+
522
+ ---
523
+
524
+ ## Overriding Layer Behavior
525
+
526
+ ### Disable Bundled Modules
527
+
528
+ If your app already provides a module the layer includes, [disable it](https://nuxt.com/docs/getting-started/layers#disable-layer-modules):
529
+
530
+ ```ts
531
+ // nuxt.config.ts
532
+ export default defineNuxtConfig({
533
+ extends: ['@dev.smartpricing/platform-layer'],
534
+ i18n: false, // disable layer's i18n
535
+ pinia: false, // disable layer's pinia
536
+ })
537
+ ```
538
+
539
+ ### Override i18n Locales
540
+
541
+ The layer ships with `en`, `it`, `de`, `es`. Your app's [i18n config](https://i18n.nuxtjs.org/docs/options) takes precedence — add or override locales in your own `nuxt.config.ts`.
542
+
543
+ ### Override Layer Components/Composables
544
+
545
+ Your project files always take [priority over layer files](https://nuxt.com/docs/getting-started/layers#layer-priority). Create a file with the same name in your app to override.
546
+
547
+ ---
548
+
549
+ ## Development
550
+
551
+ ```bash
552
+ # Install dependencies
553
+ pnpm install
554
+
555
+ # Start playground dev server
556
+ pnpm --filter @dev.smartpricing/platform-layer dev
557
+
558
+ # Prepare TypeScript
559
+ pnpm --filter @dev.smartpricing/platform-layer dev:prepare
560
+
561
+ # Build playground
562
+ pnpm --filter @dev.smartpricing/platform-layer build
563
+ ```
564
+
565
+ ## Related Resources
566
+
567
+ - [Nuxt Layers](https://nuxt.com/docs/getting-started/layers) — How Nuxt layers work
568
+ - [ofetch](https://github.com/unjs/ofetch) — Universal fetch library powering `$fetch`
569
+ - [Pinia Colada](https://pinia-colada.esm.dev) — Async data caching for Vue/Nuxt
570
+ - [Pinia](https://pinia.vuejs.org) — State management for Vue
571
+ - [Nuxt i18n](https://i18n.nuxtjs.org) — Internationalization module
572
+ - [VueUse](https://vueuse.org) — Collection of Vue composables
package/_shared/mfa.ts CHANGED
@@ -58,6 +58,30 @@ export const MfaRegenerateRecoveryCodesResponseSchema = z.object({
58
58
  recovery_codes: z.array(z.string()),
59
59
  })
60
60
 
61
+ // ── Send email code ────────────────────────────────────────────────
62
+
63
+ export const MfaSendEmailCodeRequestSchema = z.object({
64
+ enrollmentId: z.string(),
65
+ })
66
+
67
+ export const MfaSendEmailCodeResponseSchema = z.object({
68
+ sent: z.literal(true),
69
+ email_masked: z.string(),
70
+ expires_in_seconds: z.number(),
71
+ retry_after_seconds: z.number(),
72
+ })
73
+
74
+ // ── Verify email ───────────────────────────────────────────────────
75
+
76
+ export const MfaVerifyEmailRequestSchema = z.object({
77
+ enrollmentId: z.string(),
78
+ emailCode: z.string(),
79
+ })
80
+
81
+ export const MfaVerifyEmailResponseSchema = z.object({
82
+ verified: z.literal(true),
83
+ })
84
+
61
85
  // ── Types ───────────────────────────────────────────────────────────
62
86
 
63
87
  export type MfaStepUpRequest = z.infer<typeof MfaStepUpRequestSchema>
@@ -73,3 +97,9 @@ export type MfaDisableResponse = z.infer<typeof MfaDisableResponseSchema>
73
97
 
74
98
  export type MfaRegenerateRecoveryCodesRequest = z.infer<typeof MfaRegenerateRecoveryCodesRequestSchema>
75
99
  export type MfaRegenerateRecoveryCodesResponse = z.infer<typeof MfaRegenerateRecoveryCodesResponseSchema>
100
+
101
+ export type MfaSendEmailCodeRequest = z.infer<typeof MfaSendEmailCodeRequestSchema>
102
+ export type MfaSendEmailCodeResponse = z.infer<typeof MfaSendEmailCodeResponseSchema>
103
+
104
+ export type MfaVerifyEmailRequest = z.infer<typeof MfaVerifyEmailRequestSchema>
105
+ export type MfaVerifyEmailResponse = z.infer<typeof MfaVerifyEmailResponseSchema>
@@ -0,0 +1,10 @@
1
+ import type { MfaSendEmailCodeRequest, MfaSendEmailCodeResponse } from '@package/platform-shared'
2
+
3
+ export function useMfaSendEmailCodeMutation() {
4
+ const client = useApiClient()
5
+
6
+ return useMutation({
7
+ mutation: (body: MfaSendEmailCodeRequest) =>
8
+ client<MfaSendEmailCodeResponse>('/api/auth/mfa/send-email-code', { method: 'POST', body }),
9
+ })
10
+ }
@@ -0,0 +1,10 @@
1
+ import type { MfaVerifyEmailRequest, MfaVerifyEmailResponse } from '@package/platform-shared'
2
+
3
+ export function useMfaVerifyEmailMutation() {
4
+ const client = useApiClient()
5
+
6
+ return useMutation({
7
+ mutation: (body: MfaVerifyEmailRequest) =>
8
+ client<MfaVerifyEmailResponse>('/api/auth/mfa/verify-email', { method: 'POST', body }),
9
+ })
10
+ }
@@ -1,5 +1,9 @@
1
+ import type { FetchContext, FetchHook } from 'ofetch'
1
2
  import { FetchError } from 'ofetch'
2
3
 
4
+ type OnRequestHook = FetchHook<FetchContext>
5
+ type OnResponseErrorHook = FetchHook<FetchContext & { response: Response }>
6
+
3
7
  export interface ApiClientCsrfConfig {
4
8
  cookieName: string
5
9
  headerName?: string
@@ -24,6 +28,8 @@ export interface ApiClientOptions {
24
28
  csrf?: ApiClientCsrfConfig
25
29
  auth?: ApiClientAuthConfig
26
30
  errorSerializer?: (context: { status: number; statusText: string; data: unknown }) => unknown
31
+ onRequest?: OnRequestHook
32
+ onResponseError?: OnResponseErrorHook
27
33
  }
28
34
 
29
35
  function defaultShouldRefresh(status: number, data: unknown): boolean {
@@ -36,26 +42,33 @@ function defaultShouldRefresh(status: number, data: unknown): boolean {
36
42
  let refreshPromise: Promise<boolean> | null = null
37
43
 
38
44
  export function createApiClient(options: ApiClientOptions) {
39
- const client = $fetch.create({
40
- baseURL: options.baseURL,
41
- credentials: 'include',
42
- onRequest({ options: reqOpts }) {
43
- // SSR: forward cookies from incoming request
44
- if (import.meta.server) {
45
- const { cookie } = useRequestHeaders(['cookie'])
46
- if (cookie) {
47
- reqOpts.headers.set('cookie', cookie)
48
- }
45
+ const builtInOnRequest: OnRequestHook = ({ options: reqOpts }) => {
46
+ // SSR: forward cookies from incoming request
47
+ if (import.meta.server) {
48
+ const { cookie } = useRequestHeaders(['cookie'])
49
+ if (cookie) {
50
+ reqOpts.headers.set('cookie', cookie)
49
51
  }
52
+ }
50
53
 
51
- // CSRF: inject token header
52
- if (options.csrf) {
53
- const token = useCookie(options.csrf.cookieName).value
54
- if (token) {
55
- reqOpts.headers.set(options.csrf.headerName ?? 'X-CSRF-Token', token)
56
- }
54
+ // CSRF: inject token header
55
+ if (options.csrf) {
56
+ const token = useCookie(options.csrf.cookieName).value
57
+ if (token) {
58
+ reqOpts.headers.set(options.csrf.headerName ?? 'X-CSRF-Token', token)
57
59
  }
58
- },
60
+ }
61
+ }
62
+
63
+ const onRequestHooks: OnRequestHook[] = [builtInOnRequest]
64
+ if (options.onRequest) {
65
+ onRequestHooks.push(...(Array.isArray(options.onRequest) ? options.onRequest : [options.onRequest]))
66
+ }
67
+
68
+ const client = $fetch.create({
69
+ baseURL: options.baseURL,
70
+ credentials: 'include',
71
+ onRequest: onRequestHooks,
59
72
  })
60
73
 
61
74
  // No auth handling needed — return raw $fetch instance
@@ -126,6 +139,14 @@ export function createApiClient(options: ApiClientOptions) {
126
139
  auth.onRefreshFailed?.()
127
140
  }
128
141
 
142
+ // Call consumer's onResponseError hooks
143
+ if (options.onResponseError) {
144
+ const hooks = Array.isArray(options.onResponseError) ? options.onResponseError : [options.onResponseError]
145
+ for (const hook of hooks) {
146
+ await hook({ request: url, response: error.response!, options: fetchOptions ?? {} } as FetchContext & { response: Response })
147
+ }
148
+ }
149
+
129
150
  serializeAndThrow(error)
130
151
  }
131
152
  }
package/nuxt.config.ts CHANGED
@@ -1,13 +1,16 @@
1
1
  import { dirname, join } from 'node:path'
2
+ import { existsSync } from 'node:fs'
2
3
  import { fileURLToPath } from 'node:url'
3
4
 
4
5
  const currentDir = dirname(fileURLToPath(import.meta.url))
6
+ const sharedDir = join(currentDir, '_shared')
5
7
 
6
8
  export default defineNuxtConfig({
7
9
  extends: ['nuxt-ui-layer'],
8
10
 
9
11
  alias: {
10
- '@package/platform-shared': join(currentDir, '_shared'),
12
+ // _shared exists only in published tarball (created by prepack script)
13
+ ...(existsSync(sharedDir) && { '@package/platform-shared': sharedDir }),
11
14
  },
12
15
 
13
16
  modules: [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dev.smartpricing/platform-layer",
3
- "version": "0.0.2",
3
+ "version": "0.0.4",
4
4
  "type": "module",
5
5
  "private": false,
6
6
  "main": "./nuxt.config.ts",