@dev.smartpricing/platform-layer 0.0.2 → 0.0.3
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 +568 -0
- package/app/utils/apiClient.utils.ts +38 -17
- package/package.json +1 -1
package/README.md
ADDED
|
@@ -0,0 +1,568 @@
|
|
|
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
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
| Option | Type | Description |
|
|
96
|
+
|--------|------|-------------|
|
|
97
|
+
| `baseURL` | `string` | Base URL prepended to all requests. Slash handling is automatic ([ofetch docs](https://github.com/unjs/ofetch#baseurl)). |
|
|
98
|
+
| `csrf` | `ApiClientCsrfConfig` | CSRF token injection config. |
|
|
99
|
+
| `auth` | `ApiClientAuthConfig` | Auth config for token refresh and MFA. When omitted, returns a raw `$fetch` instance with no auth handling. |
|
|
100
|
+
| `errorSerializer` | `(context) => unknown` | Custom error serializer. Receives `{ status, statusText, data }` from [`FetchError`](https://github.com/unjs/ofetch#%EF%B8%8F-access-to-raw-response). |
|
|
101
|
+
|
|
102
|
+
#### CSRF Config
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
interface ApiClientCsrfConfig {
|
|
106
|
+
cookieName: string
|
|
107
|
+
headerName?: string // default: 'X-CSRF-Token'
|
|
108
|
+
}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
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).
|
|
112
|
+
|
|
113
|
+
#### Auth Config
|
|
114
|
+
|
|
115
|
+
```ts
|
|
116
|
+
interface ApiClientAuthConfig {
|
|
117
|
+
refreshBaseURL: string
|
|
118
|
+
refreshEndpoint?: string // default: '/api/auth/refresh'
|
|
119
|
+
platformURL: string
|
|
120
|
+
shouldRefresh?: (status: number, data: unknown) => boolean
|
|
121
|
+
onRefreshFailed?: () => void
|
|
122
|
+
mfa?: ApiClientMfaConfig
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
interface ApiClientMfaConfig {
|
|
126
|
+
shouldChallenge: (status: number, data: unknown) => boolean
|
|
127
|
+
challengePath?: string // default: '/auth/mfa-challenge'
|
|
128
|
+
}
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
When `auth` is provided, the client wraps the raw `$fetch` instance with error-handling logic:
|
|
132
|
+
|
|
133
|
+
1. **MFA challenge** — If `mfa.shouldChallenge()` returns `true`, redirects the user to `{platformURL}{challengePath}?redirect={currentUrl}`.
|
|
134
|
+
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.
|
|
135
|
+
3. **Refresh dedup** — Concurrent 401s share a single refresh call (module-level promise deduplication).
|
|
136
|
+
4. **Refresh failure** — Calls `onRefreshFailed()` if the refresh itself fails.
|
|
137
|
+
5. **Error serialization** — If `errorSerializer` is set, transforms [`FetchError`](https://github.com/unjs/ofetch#%EF%B8%8F-access-to-raw-response) before rethrowing.
|
|
138
|
+
|
|
139
|
+
#### SSR Cookie Forwarding
|
|
140
|
+
|
|
141
|
+
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.
|
|
142
|
+
|
|
143
|
+
#### Example: Custom Client
|
|
144
|
+
|
|
145
|
+
```ts
|
|
146
|
+
const client = createApiClient({
|
|
147
|
+
baseURL: 'https://api.example.com',
|
|
148
|
+
csrf: { cookieName: 'my_csrf' },
|
|
149
|
+
auth: {
|
|
150
|
+
refreshBaseURL: 'https://api.example.com',
|
|
151
|
+
platformURL: 'https://app.example.com',
|
|
152
|
+
onRefreshFailed: () => navigateTo('/login'),
|
|
153
|
+
},
|
|
154
|
+
errorSerializer: ({ status, data }) => ({
|
|
155
|
+
code: status,
|
|
156
|
+
message: (data as any)?.error?.message ?? 'Unknown error',
|
|
157
|
+
}),
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
const users = await client<User[]>('/users', { query: { active: true } })
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
---
|
|
164
|
+
|
|
165
|
+
### `useApiClient()`
|
|
166
|
+
|
|
167
|
+
> [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.
|
|
168
|
+
|
|
169
|
+
Internally calls [`createApiClient()`](#createapiclientoptions) with the runtime config values (`BACKEND_BASE_URL`, `ENV_CSRF_COOKIE_NAME`, `PLATFORM_URL`).
|
|
170
|
+
|
|
171
|
+
#### Features
|
|
172
|
+
|
|
173
|
+
- **CSRF token injection** — Reads from the `smt_csrf` cookie, sends as `X-CSRF-Token` header.
|
|
174
|
+
- **Auto token refresh** — On 401 with `session.expired`/`session.missing`, refreshes and retries once.
|
|
175
|
+
- **MFA challenge** — On `otp_required` error code, redirects to `/auth/mfa-challenge`.
|
|
176
|
+
- **SSR cookie forwarding** — Forwards request cookies during server-side rendering.
|
|
177
|
+
- **Auth redirect** — Redirects to `{PLATFORM_URL}/auth/login` when refresh fails.
|
|
178
|
+
|
|
179
|
+
#### Usage
|
|
180
|
+
|
|
181
|
+
```vue
|
|
182
|
+
<script setup lang="ts">
|
|
183
|
+
// Auto-imported — no import needed
|
|
184
|
+
const client = useApiClient()
|
|
185
|
+
|
|
186
|
+
// GET with typed response
|
|
187
|
+
const users = await client<User[]>('/api/users')
|
|
188
|
+
|
|
189
|
+
// POST with body (auto-serialized as JSON)
|
|
190
|
+
const created = await client<User>('/api/users', {
|
|
191
|
+
method: 'POST',
|
|
192
|
+
body: { name: 'Alice', email: 'alice@example.com' },
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
// GET with query parameters
|
|
196
|
+
const filtered = await client<User[]>('/api/users', {
|
|
197
|
+
query: { role: 'admin', active: true },
|
|
198
|
+
})
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
#### Using with Pinia Colada Queries
|
|
202
|
+
|
|
203
|
+
```vue
|
|
204
|
+
<script setup lang="ts">
|
|
205
|
+
const client = useApiClient()
|
|
206
|
+
|
|
207
|
+
const { data, status, error } = useQuery({
|
|
208
|
+
key: ['custom-data'],
|
|
209
|
+
query: () => client<MyData>('/api/custom-endpoint'),
|
|
210
|
+
})
|
|
211
|
+
</script>
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
#### Using with Pinia Colada Mutations
|
|
215
|
+
|
|
216
|
+
```vue
|
|
217
|
+
<script setup lang="ts">
|
|
218
|
+
const client = useApiClient()
|
|
219
|
+
|
|
220
|
+
const { mutateAsync, status } = useMutation({
|
|
221
|
+
mutation: (body: CreateItemRequest) =>
|
|
222
|
+
client<CreateItemResponse>('/api/items', {
|
|
223
|
+
method: 'POST',
|
|
224
|
+
body,
|
|
225
|
+
}),
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
async function handleSubmit(data: CreateItemRequest) {
|
|
229
|
+
try {
|
|
230
|
+
const result = await mutateAsync(data)
|
|
231
|
+
}
|
|
232
|
+
catch (error) {
|
|
233
|
+
// FetchError with parsed body in error.data
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
</script>
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
---
|
|
240
|
+
|
|
241
|
+
## Authentication
|
|
242
|
+
|
|
243
|
+
### `useAuth()`
|
|
244
|
+
|
|
245
|
+
> [Vue composable](https://vuejs.org/guide/reusability/composables.html) that wraps the [`useLogoutMutation()`](#authentication-mutations) and provides a sign-out flow with redirect.
|
|
246
|
+
|
|
247
|
+
#### Return Values
|
|
248
|
+
|
|
249
|
+
| Property | Type | Description |
|
|
250
|
+
|----------|------|-------------|
|
|
251
|
+
| `signOut` | `() => Promise<void>` | Calls `POST /api/auth/logout` then redirects to `{PLATFORM_URL}/auth/login` |
|
|
252
|
+
| `logoutStatus` | `Ref<'idle' \| 'pending' \| 'success' \| 'error'>` | [Mutation status](https://pinia-colada.esm.dev/guide/mutations.html) |
|
|
253
|
+
| `logoutError` | `Ref<Error \| null>` | Error if logout failed |
|
|
254
|
+
|
|
255
|
+
#### Usage
|
|
256
|
+
|
|
257
|
+
```vue
|
|
258
|
+
<script setup lang="ts">
|
|
259
|
+
const { signOut, logoutStatus } = useAuth()
|
|
260
|
+
</script>
|
|
261
|
+
|
|
262
|
+
<template>
|
|
263
|
+
<button :disabled="logoutStatus === 'pending'" @click="signOut">
|
|
264
|
+
Sign Out
|
|
265
|
+
</button>
|
|
266
|
+
</template>
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
---
|
|
270
|
+
|
|
271
|
+
## Queries
|
|
272
|
+
|
|
273
|
+
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`.
|
|
274
|
+
|
|
275
|
+
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).
|
|
276
|
+
|
|
277
|
+
### Session & User
|
|
278
|
+
|
|
279
|
+
| Composable | Endpoint | Response Type | Key Factory |
|
|
280
|
+
|------------|----------|---------------|-------------|
|
|
281
|
+
| `useSessionQuery()` | `GET /api/me` | `MeContextResponse` | `sessionKeys.me()` |
|
|
282
|
+
| `usePermissionsQuery()` | `GET /api/me/permissions` | `PermissionsResponse` | `permissionsKeys.all()` |
|
|
283
|
+
| `useCrossSellingQuery()` | `GET /api/me/cross-selling` | `GetCrossSellingResponse` | `crossSellingKeys.all()` |
|
|
284
|
+
|
|
285
|
+
### Organization
|
|
286
|
+
|
|
287
|
+
| Composable | Endpoint | Response Type | Key Factory |
|
|
288
|
+
|------------|----------|---------------|-------------|
|
|
289
|
+
| `useOrganizationQuery(orgId)` | `GET /api/orgs/{orgId}` | `OrganizationContextResponse` | `organizationKeys.byId(orgId)` |
|
|
290
|
+
|
|
291
|
+
**Params:** `orgId: MaybeRefOrGetter<string>` — enabled only when `orgId` is truthy.
|
|
292
|
+
|
|
293
|
+
### Billing
|
|
294
|
+
|
|
295
|
+
| Composable | Endpoint | Response Type | Key Factory |
|
|
296
|
+
|------------|----------|---------------|-------------|
|
|
297
|
+
| `useBillingOverviewQuery(orgId)` | `GET /api/orgs/{orgId}/billing` | `BillingOverviewResponse` | `billingOverviewKeys.byOrg(orgId)` |
|
|
298
|
+
| `useBillingDocumentsQuery(orgId, params?)` | `GET /api/orgs/{orgId}/billing/documents` | `ListBillingDocumentsResponse` | `billingDocumentsKeys.byOrg(orgId, params)` |
|
|
299
|
+
| `useLegalEntityQuery(orgId, id)` | `GET /api/orgs/{orgId}/billing/legal-entities/{id}` | `LegalEntityDetail` | `legalEntityKeys.byId(orgId, id)` |
|
|
300
|
+
| `useBillingDocumentPdfQuery(orgId, type, id)` | `GET /api/orgs/{orgId}/billing/documents/{type}/{id}/pdf` | `BillingDocumentPdfResponse` | `billingDocumentPdfKeys.byId(orgId, type, id)` |
|
|
301
|
+
|
|
302
|
+
#### Billing Documents Filter Params
|
|
303
|
+
|
|
304
|
+
```ts
|
|
305
|
+
interface BillingDocumentsQueryParams {
|
|
306
|
+
legalEntityId?: string
|
|
307
|
+
customerId?: string
|
|
308
|
+
limit?: number
|
|
309
|
+
after?: string
|
|
310
|
+
before?: string
|
|
311
|
+
subscriptionIds?: string[]
|
|
312
|
+
type?: string
|
|
313
|
+
status?: string
|
|
314
|
+
search?: string
|
|
315
|
+
}
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
### Query Usage Examples
|
|
319
|
+
|
|
320
|
+
```vue
|
|
321
|
+
<script setup lang="ts">
|
|
322
|
+
const route = useRoute()
|
|
323
|
+
|
|
324
|
+
// Static query — fetches immediately
|
|
325
|
+
const { data: session, status } = useSessionQuery()
|
|
326
|
+
|
|
327
|
+
// Dynamic query — re-fetches when orgId changes
|
|
328
|
+
const { data: org } = useOrganizationQuery(() => route.params.orgId as string)
|
|
329
|
+
|
|
330
|
+
// Conditional query with filter params
|
|
331
|
+
const filters = ref<BillingDocumentsQueryParams>({ limit: 20 })
|
|
332
|
+
const { data: docs } = useBillingDocumentsQuery(
|
|
333
|
+
() => route.params.orgId as string,
|
|
334
|
+
filters,
|
|
335
|
+
)
|
|
336
|
+
</script>
|
|
337
|
+
|
|
338
|
+
<template>
|
|
339
|
+
<div v-if="status === 'pending'">Loading...</div>
|
|
340
|
+
<div v-else-if="status === 'error'">Error loading session</div>
|
|
341
|
+
<div v-else>Welcome, {{ session.name }}</div>
|
|
342
|
+
</template>
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
### Invalidating Queries After Mutations
|
|
346
|
+
|
|
347
|
+
Use the exported key factories with [`useQueryCache()`](https://pinia-colada.esm.dev/guide/mutations.html#query-invalidation):
|
|
348
|
+
|
|
349
|
+
```vue
|
|
350
|
+
<script setup lang="ts">
|
|
351
|
+
import { useQueryCache } from '@pinia/colada'
|
|
352
|
+
|
|
353
|
+
const queryCache = useQueryCache()
|
|
354
|
+
|
|
355
|
+
const { mutateAsync: updateProfile } = useUpdateProfileMutation()
|
|
356
|
+
|
|
357
|
+
async function handleSave(data: UpdateProfileRequest) {
|
|
358
|
+
await updateProfile(data)
|
|
359
|
+
// Invalidate session to refetch updated user data
|
|
360
|
+
queryCache.invalidateQueries({ key: sessionKeys.me() })
|
|
361
|
+
}
|
|
362
|
+
</script>
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
---
|
|
366
|
+
|
|
367
|
+
## Mutations
|
|
368
|
+
|
|
369
|
+
All mutations use [`useMutation()`](https://pinia-colada.esm.dev/guide/mutations.html) from Pinia Colada. Each returns:
|
|
370
|
+
|
|
371
|
+
| Property | Type | Description |
|
|
372
|
+
|----------|------|-------------|
|
|
373
|
+
| `mutate` | `(params) => void` | Fire-and-forget — errors handled via `onError` hook |
|
|
374
|
+
| `mutateAsync` | `(params) => Promise<TResult>` | Returns promise — use `try/catch` for errors |
|
|
375
|
+
| `status` | `Ref<'idle' \| 'pending' \| 'success' \| 'error'>` | Overall [mutation status](https://pinia-colada.esm.dev/guide/mutations.html) |
|
|
376
|
+
| `asyncStatus` | `Ref<'idle' \| 'loading'>` | Whether a request is in-flight |
|
|
377
|
+
| `data` | `Ref<TResult \| undefined>` | Last successful response |
|
|
378
|
+
| `error` | `Ref<Error \| null>` | Last error |
|
|
379
|
+
| `variables` | `Ref<TParams \| undefined>` | Last mutation parameters |
|
|
380
|
+
| `reset` | `() => void` | Reset to initial state |
|
|
381
|
+
|
|
382
|
+
### Authentication Mutations
|
|
383
|
+
|
|
384
|
+
| Composable | Method | Endpoint | Request | Response |
|
|
385
|
+
|------------|--------|----------|---------|----------|
|
|
386
|
+
| `useLoginMutation()` | `POST` | `/api/auth/login` | `LoginRequest` | `void` |
|
|
387
|
+
| `useLogoutMutation()` | `POST` | `/api/auth/logout` | — | `void` |
|
|
388
|
+
| `useRefreshMutation()` | `POST` | `/api/auth/refresh` | — | `void` |
|
|
389
|
+
| `useActivateMutation()` | `POST` | `/api/auth/activate` | `ActivateRequest` | `void` |
|
|
390
|
+
| `useAccountingLoginMutation()` | `POST` | `/api/auth/accounting-login` | `AccountingLoginRequest` | `void` |
|
|
391
|
+
|
|
392
|
+
### MFA Mutations
|
|
393
|
+
|
|
394
|
+
| Composable | Method | Endpoint | Request | Response |
|
|
395
|
+
|------------|--------|----------|---------|----------|
|
|
396
|
+
| `useMfaSetupMutation()` | `POST` | `/api/auth/mfa/setup` | — | `MfaSetupResponse` |
|
|
397
|
+
| `useMfaStepUpMutation()` | `POST` | `/api/auth/otp` | `MfaStepUpRequest` | `MfaStepUpResponse` |
|
|
398
|
+
| `useMfaFinalizeMutation()` | `POST` | `/api/auth/mfa/finalize` | `MfaFinalizeRequest` | `MfaFinalizeResponse` |
|
|
399
|
+
| `useMfaDisableMutation()` | `POST` | `/api/auth/mfa/disable` | `MfaDisableRequest` | `MfaDisableResponse` |
|
|
400
|
+
| `useMfaRegenerateRecoveryCodesMutation()` | `POST` | `/api/auth/mfa/regenerate-recovery-codes` | `MfaRegenerateRecoveryCodesRequest` | `MfaRegenerateRecoveryCodesResponse` |
|
|
401
|
+
|
|
402
|
+
### Profile Mutations
|
|
403
|
+
|
|
404
|
+
| Composable | Method | Endpoint | Request | Response |
|
|
405
|
+
|------------|--------|----------|---------|----------|
|
|
406
|
+
| `useUpdateProfileMutation()` | `PATCH` | `/api/me` | `UpdateProfileRequest` | `UpdateProfileResponse` |
|
|
407
|
+
| `useChangePasswordMutation()` | `POST` | `/api/me/change-password` | `ChangePasswordRequest` | `{ ok: true }` |
|
|
408
|
+
| `useRequestPasswordResetMutation()` | `POST` | `/api/auth/request-password-reset` | `ForgotPasswordRequest` | `void` |
|
|
409
|
+
| `useResetPasswordMutation()` | `POST` | `/api/auth/reset-password` | `ResetPasswordRequest` | `void` |
|
|
410
|
+
|
|
411
|
+
### Billing Mutations
|
|
412
|
+
|
|
413
|
+
| Composable | Method | Endpoint | Request | Response |
|
|
414
|
+
|------------|--------|----------|---------|----------|
|
|
415
|
+
| `useCreatePaymentMethodMutation()` | `POST` | `/api/orgs/{orgId}/billing/payment-methods` | `{ orgId, body: CreatePaymentMethodRequest }` | `CreatePaymentMethodsResponse` |
|
|
416
|
+
| `useSetPrimaryPaymentMethodMutation()` | `PATCH` | `/api/orgs/{orgId}/billing/payment-methods/primary` | `{ orgId, body: SetPrimaryPaymentMethodRequest }` | `SetPrimaryPaymentMethodResponse` |
|
|
417
|
+
| `useRemovePaymentMethodMutation()` | `DELETE` | `/api/orgs/{orgId}/billing/payment-methods/{paymentMethodId}` | `{ orgId, paymentMethodId }` | `void` |
|
|
418
|
+
| `useCreateSetupIntentMutation()` | `POST` | `/api/orgs/{orgId}/billing/payment-methods/setup-intent` | `{ orgId, body: CreateSetupIntentRequest }` | `CreateSetupIntentResponse` |
|
|
419
|
+
|
|
420
|
+
### Organization Mutations
|
|
421
|
+
|
|
422
|
+
| Composable | Method | Endpoint | Request | Response |
|
|
423
|
+
|------------|--------|----------|---------|----------|
|
|
424
|
+
| `useUpdateLegalEntityMutation()` | `PATCH` | `/api/orgs/{orgId}/billing/legal-entities/{id}` | `{ orgId, id, body: UpdateLegalEntityRequest }` | `LegalEntityDetail` |
|
|
425
|
+
| `useRequestLegalEntityChangeMutation()` | `POST` | `/api/orgs/{orgId}/billing/legal-entities/{id}/change-request` | `{ orgId, id, body: RequestLegalEntityChangeRequest }` | `{ emails_status: 'sent' \| 'failed' }` |
|
|
426
|
+
| `useRequestSubscriptionCancellationMutation()` | `POST` | `/api/orgs/{orgId}/subscriptions/request-cancellation` | `{ orgId, body: SubscriptionCancellationRequest }` | `SubscriptionCancellationResponse` |
|
|
427
|
+
|
|
428
|
+
### Mutation Usage Examples
|
|
429
|
+
|
|
430
|
+
#### Login Flow
|
|
431
|
+
|
|
432
|
+
```vue
|
|
433
|
+
<script setup lang="ts">
|
|
434
|
+
const { mutateAsync: login, status, error } = useLoginMutation()
|
|
435
|
+
|
|
436
|
+
async function handleLogin(email: string, password: string) {
|
|
437
|
+
try {
|
|
438
|
+
await login({ email, password })
|
|
439
|
+
await navigateTo('/dashboard')
|
|
440
|
+
}
|
|
441
|
+
catch (err) {
|
|
442
|
+
// error.value is also set reactively
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
</script>
|
|
446
|
+
|
|
447
|
+
<template>
|
|
448
|
+
<form @submit.prevent="handleLogin(email, password)">
|
|
449
|
+
<p v-if="error">{{ error.message }}</p>
|
|
450
|
+
<button :disabled="status === 'pending'" type="submit">
|
|
451
|
+
{{ status === 'pending' ? 'Signing in...' : 'Sign In' }}
|
|
452
|
+
</button>
|
|
453
|
+
</form>
|
|
454
|
+
</template>
|
|
455
|
+
```
|
|
456
|
+
|
|
457
|
+
#### MFA Setup Flow
|
|
458
|
+
|
|
459
|
+
```vue
|
|
460
|
+
<script setup lang="ts">
|
|
461
|
+
const { mutateAsync: setupMfa } = useMfaSetupMutation()
|
|
462
|
+
const { mutateAsync: finalizeMfa } = useMfaFinalizeMutation()
|
|
463
|
+
|
|
464
|
+
// Step 1: Get QR code / secret
|
|
465
|
+
const setupData = await setupMfa()
|
|
466
|
+
|
|
467
|
+
// Step 2: User enters TOTP code, finalize
|
|
468
|
+
await finalizeMfa({ otp: userCode })
|
|
469
|
+
</script>
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
#### Billing Operations with Query Invalidation
|
|
473
|
+
|
|
474
|
+
```vue
|
|
475
|
+
<script setup lang="ts">
|
|
476
|
+
import { useQueryCache } from '@pinia/colada'
|
|
477
|
+
|
|
478
|
+
const queryCache = useQueryCache()
|
|
479
|
+
const orgId = computed(() => route.params.orgId as string)
|
|
480
|
+
|
|
481
|
+
const { mutateAsync: removeMethod, status } = useRemovePaymentMethodMutation()
|
|
482
|
+
|
|
483
|
+
async function handleRemove(paymentMethodId: string) {
|
|
484
|
+
try {
|
|
485
|
+
await removeMethod({ orgId: orgId.value, paymentMethodId })
|
|
486
|
+
|
|
487
|
+
// Refetch billing data after removing payment method
|
|
488
|
+
queryCache.invalidateQueries({ key: billingOverviewKeys.byOrg(orgId.value) })
|
|
489
|
+
}
|
|
490
|
+
catch (err) {
|
|
491
|
+
// Handle error
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
</script>
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
#### Fire-and-Forget vs Async
|
|
498
|
+
|
|
499
|
+
```vue
|
|
500
|
+
<script setup lang="ts">
|
|
501
|
+
const { mutate, mutateAsync, status, error } = useUpdateProfileMutation()
|
|
502
|
+
|
|
503
|
+
// Fire-and-forget — errors are swallowed, check error ref reactively
|
|
504
|
+
mutate({ name: 'New Name' })
|
|
505
|
+
|
|
506
|
+
// Async — errors rethrown, use try/catch
|
|
507
|
+
try {
|
|
508
|
+
const result = await mutateAsync({ name: 'New Name' })
|
|
509
|
+
}
|
|
510
|
+
catch (err) {
|
|
511
|
+
console.error('Update failed:', err)
|
|
512
|
+
}
|
|
513
|
+
</script>
|
|
514
|
+
```
|
|
515
|
+
|
|
516
|
+
> All request/response types are imported from `@package/platform-shared`. See the shared package for type definitions.
|
|
517
|
+
|
|
518
|
+
---
|
|
519
|
+
|
|
520
|
+
## Overriding Layer Behavior
|
|
521
|
+
|
|
522
|
+
### Disable Bundled Modules
|
|
523
|
+
|
|
524
|
+
If your app already provides a module the layer includes, [disable it](https://nuxt.com/docs/getting-started/layers#disable-layer-modules):
|
|
525
|
+
|
|
526
|
+
```ts
|
|
527
|
+
// nuxt.config.ts
|
|
528
|
+
export default defineNuxtConfig({
|
|
529
|
+
extends: ['@dev.smartpricing/platform-layer'],
|
|
530
|
+
i18n: false, // disable layer's i18n
|
|
531
|
+
pinia: false, // disable layer's pinia
|
|
532
|
+
})
|
|
533
|
+
```
|
|
534
|
+
|
|
535
|
+
### Override i18n Locales
|
|
536
|
+
|
|
537
|
+
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`.
|
|
538
|
+
|
|
539
|
+
### Override Layer Components/Composables
|
|
540
|
+
|
|
541
|
+
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.
|
|
542
|
+
|
|
543
|
+
---
|
|
544
|
+
|
|
545
|
+
## Development
|
|
546
|
+
|
|
547
|
+
```bash
|
|
548
|
+
# Install dependencies
|
|
549
|
+
pnpm install
|
|
550
|
+
|
|
551
|
+
# Start playground dev server
|
|
552
|
+
pnpm --filter @dev.smartpricing/platform-layer dev
|
|
553
|
+
|
|
554
|
+
# Prepare TypeScript
|
|
555
|
+
pnpm --filter @dev.smartpricing/platform-layer dev:prepare
|
|
556
|
+
|
|
557
|
+
# Build playground
|
|
558
|
+
pnpm --filter @dev.smartpricing/platform-layer build
|
|
559
|
+
```
|
|
560
|
+
|
|
561
|
+
## Related Resources
|
|
562
|
+
|
|
563
|
+
- [Nuxt Layers](https://nuxt.com/docs/getting-started/layers) — How Nuxt layers work
|
|
564
|
+
- [ofetch](https://github.com/unjs/ofetch) — Universal fetch library powering `$fetch`
|
|
565
|
+
- [Pinia Colada](https://pinia-colada.esm.dev) — Async data caching for Vue/Nuxt
|
|
566
|
+
- [Pinia](https://pinia.vuejs.org) — State management for Vue
|
|
567
|
+
- [Nuxt i18n](https://i18n.nuxtjs.org) — Internationalization module
|
|
568
|
+
- [VueUse](https://vueuse.org) — Collection of Vue composables
|
|
@@ -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
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
}
|