@dev.smartpricing/platform-layer 0.0.4 → 0.0.6
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 +167 -163
- package/_shared/billingGeo.ts +158 -0
- package/_shared/billingLegalEntities.ts +38 -5
- package/_shared/billingPaymentMethods.ts +7 -1
- package/_shared/index.ts +1 -0
- package/_shared/permissions.ts +7 -0
- package/_shared/products.ts +1 -1
- package/app/components/LayerProductSwitcher.vue +25 -0
- package/app/composables/apiClient.composable.ts +12 -3
- package/app/composables/useProductSwitcher.composable.ts +114 -0
- package/app/queries/useBillingDocumentPdf.query.ts +1 -0
- package/app/queries/useBillingDocuments.query.ts +1 -0
- package/app/queries/useBillingOverview.query.ts +1 -0
- package/app/queries/useCrossSelling.query.ts +1 -0
- package/app/queries/useLegalEntity.query.ts +1 -0
- package/app/queries/useOrganization.query.ts +1 -0
- package/app/queries/usePermissions.query.ts +1 -0
- package/app/queries/useSession.query.ts +1 -0
- package/app/utils/apiClient.utils.ts +26 -1
- package/app/utils/auth.utils.ts +18 -0
- package/nuxt.config.ts +8 -1
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -38,24 +38,26 @@ pnpm add nuxt-ui-layer@"github:smartpricing/smartness-nuxt-ui#v1.6.3"
|
|
|
38
38
|
```ts
|
|
39
39
|
// nuxt.config.ts
|
|
40
40
|
export default defineNuxtConfig({
|
|
41
|
-
extends: [
|
|
42
|
-
})
|
|
41
|
+
extends: ["@dev.smartpricing/platform-layer"],
|
|
42
|
+
});
|
|
43
43
|
```
|
|
44
44
|
|
|
45
45
|
### 2. Configure Runtime Environment
|
|
46
46
|
|
|
47
47
|
Set these environment variables (or define them in `nuxt.config.ts` under `runtimeConfig.public`):
|
|
48
48
|
|
|
49
|
-
| Variable
|
|
50
|
-
|
|
51
|
-
| `NUXT_PUBLIC_BACKEND_BASE_URL` | Backend API base URL
|
|
52
|
-
| `
|
|
53
|
-
| `
|
|
49
|
+
| Variable | Description | Default |
|
|
50
|
+
| ------------------------------ | ------------------------------- | ---------- |
|
|
51
|
+
| `NUXT_PUBLIC_BACKEND_BASE_URL` | Backend API base URL | `''` |
|
|
52
|
+
| `NUXT_PUBLIC_CSRF_COOKIE_NAME` | CSRF cookie name | `smt_csrf` |
|
|
53
|
+
| `NUXT_PUBLIC_ENVIRONMENT` | Environment | `local` |
|
|
54
|
+
| `NUXT_PUBLIC_PLATFORM_URL` | Platform URL for auth redirects | `''` |
|
|
54
55
|
|
|
55
56
|
```bash
|
|
56
57
|
# .env
|
|
57
58
|
NUXT_PUBLIC_BACKEND_BASE_URL=https://api.smartpricing.com
|
|
58
|
-
|
|
59
|
+
NUXT_PUBLIC_CSRF_COOKIE_NAME=smt_csrf
|
|
60
|
+
ENVIRONMENT=local
|
|
59
61
|
NUXT_PUBLIC_PLATFORM_URL=https://app.smartpricing.com
|
|
60
62
|
```
|
|
61
63
|
|
|
@@ -63,13 +65,13 @@ NUXT_PUBLIC_PLATFORM_URL=https://app.smartpricing.com
|
|
|
63
65
|
|
|
64
66
|
The layer auto-registers these [Nuxt modules](https://nuxt.com/docs/guide/concepts/modules):
|
|
65
67
|
|
|
66
|
-
| Module
|
|
67
|
-
|
|
68
|
-
| [`@pinia/nuxt`](https://pinia.vuejs.org/ssr/nuxt.html)
|
|
69
|
-
| [`@pinia/colada-nuxt`](https://pinia-colada.esm.dev)
|
|
70
|
-
| [`@nuxtjs/i18n`](https://i18n.nuxtjs.org)
|
|
71
|
-
| [`@vueuse/nuxt`](https://vueuse.org/nuxt/README.html)
|
|
72
|
-
| [`nuxt-ui-layer`](https://github.com/smartpricing/smartness-nuxt-ui) | Smartness design system
|
|
68
|
+
| Module | Purpose | Docs |
|
|
69
|
+
| -------------------------------------------------------------------- | --------------------------------------- | ------------------------------------------------- |
|
|
70
|
+
| [`@pinia/nuxt`](https://pinia.vuejs.org/ssr/nuxt.html) | State management | [Pinia docs](https://pinia.vuejs.org) |
|
|
71
|
+
| [`@pinia/colada-nuxt`](https://pinia-colada.esm.dev) | Async data caching, queries & mutations | [Pinia Colada docs](https://pinia-colada.esm.dev) |
|
|
72
|
+
| [`@nuxtjs/i18n`](https://i18n.nuxtjs.org) | Internationalization (en, it, de, es) | [Nuxt i18n docs](https://i18n.nuxtjs.org) |
|
|
73
|
+
| [`@vueuse/nuxt`](https://vueuse.org/nuxt/README.html) | Utility composables | [VueUse docs](https://vueuse.org) |
|
|
74
|
+
| [`nuxt-ui-layer`](https://github.com/smartpricing/smartness-nuxt-ui) | Smartness design system | — |
|
|
73
75
|
|
|
74
76
|
All composables, queries, and mutations are **auto-imported** — no manual imports needed.
|
|
75
77
|
|
|
@@ -85,30 +87,34 @@ All composables, queries, and mutations are **auto-imported** — no manual impo
|
|
|
85
87
|
|
|
86
88
|
```ts
|
|
87
89
|
interface ApiClientOptions {
|
|
88
|
-
baseURL: string
|
|
89
|
-
csrf?: ApiClientCsrfConfig
|
|
90
|
-
auth?: ApiClientAuthConfig
|
|
91
|
-
errorSerializer?: (context: {
|
|
92
|
-
|
|
93
|
-
|
|
90
|
+
baseURL: string;
|
|
91
|
+
csrf?: ApiClientCsrfConfig;
|
|
92
|
+
auth?: ApiClientAuthConfig;
|
|
93
|
+
errorSerializer?: (context: {
|
|
94
|
+
status: number;
|
|
95
|
+
statusText: string;
|
|
96
|
+
data: unknown;
|
|
97
|
+
}) => unknown;
|
|
98
|
+
onRequest?: FetchHook<FetchContext>;
|
|
99
|
+
onResponseError?: FetchHook<FetchContext & { response: Response }>;
|
|
94
100
|
}
|
|
95
101
|
```
|
|
96
102
|
|
|
97
|
-
| Option
|
|
98
|
-
|
|
99
|
-
| `baseURL`
|
|
100
|
-
| `csrf`
|
|
101
|
-
| `auth`
|
|
102
|
-
| `errorSerializer` | `(context) => unknown`
|
|
103
|
-
| `onRequest`
|
|
104
|
-
| `onResponseError` | `FetchHook<FetchContext & { response: Response }>` | Custom [`onResponseError` interceptor](https://github.com/unjs/ofetch#%EF%B8%8F-interceptors) called after auth/MFA handling.
|
|
103
|
+
| Option | Type | Description |
|
|
104
|
+
| ----------------- | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
105
|
+
| `baseURL` | `string` | Base URL prepended to all requests. Slash handling is automatic ([ofetch docs](https://github.com/unjs/ofetch#baseurl)). |
|
|
106
|
+
| `csrf` | `ApiClientCsrfConfig` | CSRF token injection config. |
|
|
107
|
+
| `auth` | `ApiClientAuthConfig` | Auth config for token refresh and MFA. When omitted, returns a raw `$fetch` instance with no auth handling. |
|
|
108
|
+
| `errorSerializer` | `(context) => unknown` | Custom error serializer. Receives `{ status, statusText, data }` from [`FetchError`](https://github.com/unjs/ofetch#%EF%B8%8F-access-to-raw-response). |
|
|
109
|
+
| `onRequest` | `FetchHook<FetchContext>` | Custom [`onRequest` interceptor](https://github.com/unjs/ofetch#%EF%B8%8F-interceptors) appended after built-in hooks (cookie forwarding, CSRF). |
|
|
110
|
+
| `onResponseError` | `FetchHook<FetchContext & { response: Response }>` | Custom [`onResponseError` interceptor](https://github.com/unjs/ofetch#%EF%B8%8F-interceptors) called after auth/MFA handling. |
|
|
105
111
|
|
|
106
112
|
#### CSRF Config
|
|
107
113
|
|
|
108
114
|
```ts
|
|
109
115
|
interface ApiClientCsrfConfig {
|
|
110
|
-
cookieName: string
|
|
111
|
-
headerName?: string // default: 'X-CSRF-Token'
|
|
116
|
+
cookieName: string;
|
|
117
|
+
headerName?: string; // default: 'X-CSRF-Token'
|
|
112
118
|
}
|
|
113
119
|
```
|
|
114
120
|
|
|
@@ -118,17 +124,17 @@ The client reads the CSRF token from the specified cookie via Nuxt's [`useCookie
|
|
|
118
124
|
|
|
119
125
|
```ts
|
|
120
126
|
interface ApiClientAuthConfig {
|
|
121
|
-
refreshBaseURL: string
|
|
122
|
-
refreshEndpoint?: string
|
|
123
|
-
platformURL: string
|
|
124
|
-
shouldRefresh?: (status: number, data: unknown) => boolean
|
|
125
|
-
onRefreshFailed?: () => void
|
|
126
|
-
mfa?: ApiClientMfaConfig
|
|
127
|
+
refreshBaseURL: string;
|
|
128
|
+
refreshEndpoint?: string; // default: '/api/auth/refresh'
|
|
129
|
+
platformURL: string;
|
|
130
|
+
shouldRefresh?: (status: number, data: unknown) => boolean;
|
|
131
|
+
onRefreshFailed?: () => void;
|
|
132
|
+
mfa?: ApiClientMfaConfig;
|
|
127
133
|
}
|
|
128
134
|
|
|
129
135
|
interface ApiClientMfaConfig {
|
|
130
|
-
shouldChallenge: (status: number, data: unknown) => boolean
|
|
131
|
-
challengePath?: string
|
|
136
|
+
shouldChallenge: (status: number, data: unknown) => boolean;
|
|
137
|
+
challengePath?: string; // default: '/auth/mfa-challenge'
|
|
132
138
|
}
|
|
133
139
|
```
|
|
134
140
|
|
|
@@ -148,20 +154,20 @@ On the server, the client reads the incoming request cookies via Nuxt's [`useReq
|
|
|
148
154
|
|
|
149
155
|
```ts
|
|
150
156
|
const client = createApiClient({
|
|
151
|
-
baseURL:
|
|
152
|
-
csrf: { cookieName:
|
|
157
|
+
baseURL: "https://api.example.com",
|
|
158
|
+
csrf: { cookieName: "my_csrf" },
|
|
153
159
|
auth: {
|
|
154
|
-
refreshBaseURL:
|
|
155
|
-
platformURL:
|
|
156
|
-
onRefreshFailed: () => navigateTo(
|
|
160
|
+
refreshBaseURL: "https://api.example.com",
|
|
161
|
+
platformURL: "https://app.example.com",
|
|
162
|
+
onRefreshFailed: () => navigateTo("/login"),
|
|
157
163
|
},
|
|
158
164
|
errorSerializer: ({ status, data }) => ({
|
|
159
165
|
code: status,
|
|
160
|
-
message: (data as any)?.error?.message ??
|
|
166
|
+
message: (data as any)?.error?.message ?? "Unknown error",
|
|
161
167
|
}),
|
|
162
|
-
})
|
|
168
|
+
});
|
|
163
169
|
|
|
164
|
-
const users = await client<User[]>(
|
|
170
|
+
const users = await client<User[]>("/users", { query: { active: true } });
|
|
165
171
|
```
|
|
166
172
|
|
|
167
173
|
---
|
|
@@ -206,12 +212,12 @@ const filtered = await client<User[]>('/api/users', {
|
|
|
206
212
|
|
|
207
213
|
```vue
|
|
208
214
|
<script setup lang="ts">
|
|
209
|
-
const client = useApiClient()
|
|
215
|
+
const client = useApiClient();
|
|
210
216
|
|
|
211
217
|
const { data, status, error } = useQuery({
|
|
212
|
-
key: [
|
|
213
|
-
query: () => client<MyData>(
|
|
214
|
-
})
|
|
218
|
+
key: ["custom-data"],
|
|
219
|
+
query: () => client<MyData>("/api/custom-endpoint"),
|
|
220
|
+
});
|
|
215
221
|
</script>
|
|
216
222
|
```
|
|
217
223
|
|
|
@@ -219,21 +225,20 @@ const { data, status, error } = useQuery({
|
|
|
219
225
|
|
|
220
226
|
```vue
|
|
221
227
|
<script setup lang="ts">
|
|
222
|
-
const client = useApiClient()
|
|
228
|
+
const client = useApiClient();
|
|
223
229
|
|
|
224
230
|
const { mutateAsync, status } = useMutation({
|
|
225
231
|
mutation: (body: CreateItemRequest) =>
|
|
226
|
-
client<CreateItemResponse>(
|
|
227
|
-
method:
|
|
232
|
+
client<CreateItemResponse>("/api/items", {
|
|
233
|
+
method: "POST",
|
|
228
234
|
body,
|
|
229
235
|
}),
|
|
230
|
-
})
|
|
236
|
+
});
|
|
231
237
|
|
|
232
238
|
async function handleSubmit(data: CreateItemRequest) {
|
|
233
239
|
try {
|
|
234
|
-
const result = await mutateAsync(data)
|
|
235
|
-
}
|
|
236
|
-
catch (error) {
|
|
240
|
+
const result = await mutateAsync(data);
|
|
241
|
+
} catch (error) {
|
|
237
242
|
// FetchError with parsed body in error.data
|
|
238
243
|
}
|
|
239
244
|
}
|
|
@@ -250,17 +255,17 @@ async function handleSubmit(data: CreateItemRequest) {
|
|
|
250
255
|
|
|
251
256
|
#### Return Values
|
|
252
257
|
|
|
253
|
-
| Property
|
|
254
|
-
|
|
255
|
-
| `signOut`
|
|
256
|
-
| `logoutStatus` | `Ref<'idle' \| 'pending' \| 'success' \| 'error'>` | [Mutation status](https://pinia-colada.esm.dev/guide/mutations.html)
|
|
257
|
-
| `logoutError`
|
|
258
|
+
| Property | Type | Description |
|
|
259
|
+
| -------------- | -------------------------------------------------- | --------------------------------------------------------------------------- |
|
|
260
|
+
| `signOut` | `() => Promise<void>` | Calls `POST /api/auth/logout` then redirects to `{PLATFORM_URL}/auth/login` |
|
|
261
|
+
| `logoutStatus` | `Ref<'idle' \| 'pending' \| 'success' \| 'error'>` | [Mutation status](https://pinia-colada.esm.dev/guide/mutations.html) |
|
|
262
|
+
| `logoutError` | `Ref<Error \| null>` | Error if logout failed |
|
|
258
263
|
|
|
259
264
|
#### Usage
|
|
260
265
|
|
|
261
266
|
```vue
|
|
262
267
|
<script setup lang="ts">
|
|
263
|
-
const { signOut, logoutStatus } = useAuth()
|
|
268
|
+
const { signOut, logoutStatus } = useAuth();
|
|
264
269
|
</script>
|
|
265
270
|
|
|
266
271
|
<template>
|
|
@@ -280,42 +285,42 @@ Each query file also exports a **key factory** (e.g., `sessionKeys`, `billingOve
|
|
|
280
285
|
|
|
281
286
|
### Session & User
|
|
282
287
|
|
|
283
|
-
| Composable
|
|
284
|
-
|
|
285
|
-
| `useSessionQuery()`
|
|
286
|
-
| `usePermissionsQuery()`
|
|
288
|
+
| Composable | Endpoint | Response Type | Key Factory |
|
|
289
|
+
| ------------------------ | --------------------------- | ------------------------- | ------------------------ |
|
|
290
|
+
| `useSessionQuery()` | `GET /api/me` | `MeContextResponse` | `sessionKeys.me()` |
|
|
291
|
+
| `usePermissionsQuery()` | `GET /api/me/permissions` | `PermissionsResponse` | `permissionsKeys.all()` |
|
|
287
292
|
| `useCrossSellingQuery()` | `GET /api/me/cross-selling` | `GetCrossSellingResponse` | `crossSellingKeys.all()` |
|
|
288
293
|
|
|
289
294
|
### Organization
|
|
290
295
|
|
|
291
|
-
| Composable
|
|
292
|
-
|
|
296
|
+
| Composable | Endpoint | Response Type | Key Factory |
|
|
297
|
+
| ----------------------------- | ----------------------- | ----------------------------- | ------------------------------ |
|
|
293
298
|
| `useOrganizationQuery(orgId)` | `GET /api/orgs/{orgId}` | `OrganizationContextResponse` | `organizationKeys.byId(orgId)` |
|
|
294
299
|
|
|
295
300
|
**Params:** `orgId: MaybeRefOrGetter<string>` — enabled only when `orgId` is truthy.
|
|
296
301
|
|
|
297
302
|
### Billing
|
|
298
303
|
|
|
299
|
-
| Composable
|
|
300
|
-
|
|
301
|
-
| `useBillingOverviewQuery(orgId)`
|
|
302
|
-
| `useBillingDocumentsQuery(orgId, params?)`
|
|
303
|
-
| `useLegalEntityQuery(orgId, id)`
|
|
304
|
-
| `useBillingDocumentPdfQuery(orgId, type, id)` | `GET /api/orgs/{orgId}/billing/documents/{type}/{id}/pdf` | `BillingDocumentPdfResponse`
|
|
304
|
+
| Composable | Endpoint | Response Type | Key Factory |
|
|
305
|
+
| --------------------------------------------- | --------------------------------------------------------- | ------------------------------ | ---------------------------------------------- |
|
|
306
|
+
| `useBillingOverviewQuery(orgId)` | `GET /api/orgs/{orgId}/billing` | `BillingOverviewResponse` | `billingOverviewKeys.byOrg(orgId)` |
|
|
307
|
+
| `useBillingDocumentsQuery(orgId, params?)` | `GET /api/orgs/{orgId}/billing/documents` | `ListBillingDocumentsResponse` | `billingDocumentsKeys.byOrg(orgId, params)` |
|
|
308
|
+
| `useLegalEntityQuery(orgId, id)` | `GET /api/orgs/{orgId}/billing/legal-entities/{id}` | `LegalEntityDetail` | `legalEntityKeys.byId(orgId, id)` |
|
|
309
|
+
| `useBillingDocumentPdfQuery(orgId, type, id)` | `GET /api/orgs/{orgId}/billing/documents/{type}/{id}/pdf` | `BillingDocumentPdfResponse` | `billingDocumentPdfKeys.byId(orgId, type, id)` |
|
|
305
310
|
|
|
306
311
|
#### Billing Documents Filter Params
|
|
307
312
|
|
|
308
313
|
```ts
|
|
309
314
|
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
|
|
315
|
+
legalEntityId?: string;
|
|
316
|
+
customerId?: string;
|
|
317
|
+
limit?: number;
|
|
318
|
+
after?: string;
|
|
319
|
+
before?: string;
|
|
320
|
+
subscriptionIds?: string[];
|
|
321
|
+
type?: string;
|
|
322
|
+
status?: string;
|
|
323
|
+
search?: string;
|
|
319
324
|
}
|
|
320
325
|
```
|
|
321
326
|
|
|
@@ -323,20 +328,20 @@ interface BillingDocumentsQueryParams {
|
|
|
323
328
|
|
|
324
329
|
```vue
|
|
325
330
|
<script setup lang="ts">
|
|
326
|
-
const route = useRoute()
|
|
331
|
+
const route = useRoute();
|
|
327
332
|
|
|
328
333
|
// Static query — fetches immediately
|
|
329
|
-
const { data: session, status } = useSessionQuery()
|
|
334
|
+
const { data: session, status } = useSessionQuery();
|
|
330
335
|
|
|
331
336
|
// Dynamic query — re-fetches when orgId changes
|
|
332
|
-
const { data: org } = useOrganizationQuery(() => route.params.orgId as string)
|
|
337
|
+
const { data: org } = useOrganizationQuery(() => route.params.orgId as string);
|
|
333
338
|
|
|
334
339
|
// Conditional query with filter params
|
|
335
|
-
const filters = ref<BillingDocumentsQueryParams>({ limit: 20 })
|
|
340
|
+
const filters = ref<BillingDocumentsQueryParams>({ limit: 20 });
|
|
336
341
|
const { data: docs } = useBillingDocumentsQuery(
|
|
337
342
|
() => route.params.orgId as string,
|
|
338
343
|
filters,
|
|
339
|
-
)
|
|
344
|
+
);
|
|
340
345
|
</script>
|
|
341
346
|
|
|
342
347
|
<template>
|
|
@@ -352,16 +357,16 @@ Use the exported key factories with [`useQueryCache()`](https://pinia-colada.esm
|
|
|
352
357
|
|
|
353
358
|
```vue
|
|
354
359
|
<script setup lang="ts">
|
|
355
|
-
import { useQueryCache } from
|
|
360
|
+
import { useQueryCache } from "@pinia/colada";
|
|
356
361
|
|
|
357
|
-
const queryCache = useQueryCache()
|
|
362
|
+
const queryCache = useQueryCache();
|
|
358
363
|
|
|
359
|
-
const { mutateAsync: updateProfile } = useUpdateProfileMutation()
|
|
364
|
+
const { mutateAsync: updateProfile } = useUpdateProfileMutation();
|
|
360
365
|
|
|
361
366
|
async function handleSave(data: UpdateProfileRequest) {
|
|
362
|
-
await updateProfile(data)
|
|
367
|
+
await updateProfile(data);
|
|
363
368
|
// Invalidate session to refetch updated user data
|
|
364
|
-
queryCache.invalidateQueries({ key: sessionKeys.me() })
|
|
369
|
+
queryCache.invalidateQueries({ key: sessionKeys.me() });
|
|
365
370
|
}
|
|
366
371
|
</script>
|
|
367
372
|
```
|
|
@@ -372,62 +377,62 @@ async function handleSave(data: UpdateProfileRequest) {
|
|
|
372
377
|
|
|
373
378
|
All mutations use [`useMutation()`](https://pinia-colada.esm.dev/guide/mutations.html) from Pinia Colada. Each returns:
|
|
374
379
|
|
|
375
|
-
| Property
|
|
376
|
-
|
|
377
|
-
| `mutate`
|
|
378
|
-
| `mutateAsync` | `(params) => Promise<TResult>`
|
|
379
|
-
| `status`
|
|
380
|
-
| `asyncStatus` | `Ref<'idle' \| 'loading'>`
|
|
381
|
-
| `data`
|
|
382
|
-
| `error`
|
|
383
|
-
| `variables`
|
|
384
|
-
| `reset`
|
|
380
|
+
| Property | Type | Description |
|
|
381
|
+
| ------------- | -------------------------------------------------- | ---------------------------------------------------------------------------- |
|
|
382
|
+
| `mutate` | `(params) => void` | Fire-and-forget — errors handled via `onError` hook |
|
|
383
|
+
| `mutateAsync` | `(params) => Promise<TResult>` | Returns promise — use `try/catch` for errors |
|
|
384
|
+
| `status` | `Ref<'idle' \| 'pending' \| 'success' \| 'error'>` | Overall [mutation status](https://pinia-colada.esm.dev/guide/mutations.html) |
|
|
385
|
+
| `asyncStatus` | `Ref<'idle' \| 'loading'>` | Whether a request is in-flight |
|
|
386
|
+
| `data` | `Ref<TResult \| undefined>` | Last successful response |
|
|
387
|
+
| `error` | `Ref<Error \| null>` | Last error |
|
|
388
|
+
| `variables` | `Ref<TParams \| undefined>` | Last mutation parameters |
|
|
389
|
+
| `reset` | `() => void` | Reset to initial state |
|
|
385
390
|
|
|
386
391
|
### Authentication Mutations
|
|
387
392
|
|
|
388
|
-
| Composable
|
|
389
|
-
|
|
390
|
-
| `useLoginMutation()`
|
|
391
|
-
| `useLogoutMutation()`
|
|
392
|
-
| `useRefreshMutation()`
|
|
393
|
-
| `useActivateMutation()`
|
|
394
|
-
| `useAccountingLoginMutation()` | `POST` | `/api/auth/accounting-login` | `AccountingLoginRequest` | `void`
|
|
393
|
+
| Composable | Method | Endpoint | Request | Response |
|
|
394
|
+
| ------------------------------ | ------ | ---------------------------- | ------------------------ | -------- |
|
|
395
|
+
| `useLoginMutation()` | `POST` | `/api/auth/login` | `LoginRequest` | `void` |
|
|
396
|
+
| `useLogoutMutation()` | `POST` | `/api/auth/logout` | — | `void` |
|
|
397
|
+
| `useRefreshMutation()` | `POST` | `/api/auth/refresh` | — | `void` |
|
|
398
|
+
| `useActivateMutation()` | `POST` | `/api/auth/activate` | `ActivateRequest` | `void` |
|
|
399
|
+
| `useAccountingLoginMutation()` | `POST` | `/api/auth/accounting-login` | `AccountingLoginRequest` | `void` |
|
|
395
400
|
|
|
396
401
|
### MFA Mutations
|
|
397
402
|
|
|
398
|
-
| Composable
|
|
399
|
-
|
|
400
|
-
| `useMfaSetupMutation()`
|
|
401
|
-
| `useMfaStepUpMutation()`
|
|
402
|
-
| `useMfaFinalizeMutation()`
|
|
403
|
-
| `useMfaDisableMutation()`
|
|
403
|
+
| Composable | Method | Endpoint | Request | Response |
|
|
404
|
+
| ----------------------------------------- | ------ | ----------------------------------------- | ----------------------------------- | ------------------------------------ |
|
|
405
|
+
| `useMfaSetupMutation()` | `POST` | `/api/auth/mfa/setup` | — | `MfaSetupResponse` |
|
|
406
|
+
| `useMfaStepUpMutation()` | `POST` | `/api/auth/otp` | `MfaStepUpRequest` | `MfaStepUpResponse` |
|
|
407
|
+
| `useMfaFinalizeMutation()` | `POST` | `/api/auth/mfa/finalize` | `MfaFinalizeRequest` | `MfaFinalizeResponse` |
|
|
408
|
+
| `useMfaDisableMutation()` | `POST` | `/api/auth/mfa/disable` | `MfaDisableRequest` | `MfaDisableResponse` |
|
|
404
409
|
| `useMfaRegenerateRecoveryCodesMutation()` | `POST` | `/api/auth/mfa/regenerate-recovery-codes` | `MfaRegenerateRecoveryCodesRequest` | `MfaRegenerateRecoveryCodesResponse` |
|
|
405
410
|
|
|
406
411
|
### Profile Mutations
|
|
407
412
|
|
|
408
|
-
| Composable
|
|
409
|
-
|
|
410
|
-
| `useUpdateProfileMutation()`
|
|
411
|
-
| `useChangePasswordMutation()`
|
|
412
|
-
| `useRequestPasswordResetMutation()` | `POST`
|
|
413
|
-
| `useResetPasswordMutation()`
|
|
413
|
+
| Composable | Method | Endpoint | Request | Response |
|
|
414
|
+
| ----------------------------------- | ------- | ---------------------------------- | ----------------------- | ----------------------- |
|
|
415
|
+
| `useUpdateProfileMutation()` | `PATCH` | `/api/me` | `UpdateProfileRequest` | `UpdateProfileResponse` |
|
|
416
|
+
| `useChangePasswordMutation()` | `POST` | `/api/me/change-password` | `ChangePasswordRequest` | `{ ok: true }` |
|
|
417
|
+
| `useRequestPasswordResetMutation()` | `POST` | `/api/auth/request-password-reset` | `ForgotPasswordRequest` | `void` |
|
|
418
|
+
| `useResetPasswordMutation()` | `POST` | `/api/auth/reset-password` | `ResetPasswordRequest` | `void` |
|
|
414
419
|
|
|
415
420
|
### Billing Mutations
|
|
416
421
|
|
|
417
|
-
| Composable
|
|
418
|
-
|
|
419
|
-
| `useCreatePaymentMethodMutation()`
|
|
420
|
-
| `useSetPrimaryPaymentMethodMutation()` | `PATCH`
|
|
421
|
-
| `useRemovePaymentMethodMutation()`
|
|
422
|
-
| `useCreateSetupIntentMutation()`
|
|
422
|
+
| Composable | Method | Endpoint | Request | Response |
|
|
423
|
+
| -------------------------------------- | -------- | ------------------------------------------------------------- | ------------------------------------------------- | --------------------------------- |
|
|
424
|
+
| `useCreatePaymentMethodMutation()` | `POST` | `/api/orgs/{orgId}/billing/payment-methods` | `{ orgId, body: CreatePaymentMethodRequest }` | `CreatePaymentMethodsResponse` |
|
|
425
|
+
| `useSetPrimaryPaymentMethodMutation()` | `PATCH` | `/api/orgs/{orgId}/billing/payment-methods/primary` | `{ orgId, body: SetPrimaryPaymentMethodRequest }` | `SetPrimaryPaymentMethodResponse` |
|
|
426
|
+
| `useRemovePaymentMethodMutation()` | `DELETE` | `/api/orgs/{orgId}/billing/payment-methods/{paymentMethodId}` | `{ orgId, paymentMethodId }` | `void` |
|
|
427
|
+
| `useCreateSetupIntentMutation()` | `POST` | `/api/orgs/{orgId}/billing/payment-methods/setup-intent` | `{ orgId, body: CreateSetupIntentRequest }` | `CreateSetupIntentResponse` |
|
|
423
428
|
|
|
424
429
|
### Organization Mutations
|
|
425
430
|
|
|
426
|
-
| Composable
|
|
427
|
-
|
|
428
|
-
| `useUpdateLegalEntityMutation()`
|
|
429
|
-
| `useRequestLegalEntityChangeMutation()`
|
|
430
|
-
| `useRequestSubscriptionCancellationMutation()` | `POST`
|
|
431
|
+
| Composable | Method | Endpoint | Request | Response |
|
|
432
|
+
| ---------------------------------------------- | ------- | -------------------------------------------------------------- | ------------------------------------------------------ | --------------------------------------- |
|
|
433
|
+
| `useUpdateLegalEntityMutation()` | `PATCH` | `/api/orgs/{orgId}/billing/legal-entities/{id}` | `{ orgId, id, body: UpdateLegalEntityRequest }` | `LegalEntityDetail` |
|
|
434
|
+
| `useRequestLegalEntityChangeMutation()` | `POST` | `/api/orgs/{orgId}/billing/legal-entities/{id}/change-request` | `{ orgId, id, body: RequestLegalEntityChangeRequest }` | `{ emails_status: 'sent' \| 'failed' }` |
|
|
435
|
+
| `useRequestSubscriptionCancellationMutation()` | `POST` | `/api/orgs/{orgId}/subscriptions/request-cancellation` | `{ orgId, body: SubscriptionCancellationRequest }` | `SubscriptionCancellationResponse` |
|
|
431
436
|
|
|
432
437
|
### Mutation Usage Examples
|
|
433
438
|
|
|
@@ -435,14 +440,13 @@ All mutations use [`useMutation()`](https://pinia-colada.esm.dev/guide/mutations
|
|
|
435
440
|
|
|
436
441
|
```vue
|
|
437
442
|
<script setup lang="ts">
|
|
438
|
-
const { mutateAsync: login, status, error } = useLoginMutation()
|
|
443
|
+
const { mutateAsync: login, status, error } = useLoginMutation();
|
|
439
444
|
|
|
440
445
|
async function handleLogin(email: string, password: string) {
|
|
441
446
|
try {
|
|
442
|
-
await login({ email, password })
|
|
443
|
-
await navigateTo(
|
|
444
|
-
}
|
|
445
|
-
catch (err) {
|
|
447
|
+
await login({ email, password });
|
|
448
|
+
await navigateTo("/dashboard");
|
|
449
|
+
} catch (err) {
|
|
446
450
|
// error.value is also set reactively
|
|
447
451
|
}
|
|
448
452
|
}
|
|
@@ -452,7 +456,7 @@ async function handleLogin(email: string, password: string) {
|
|
|
452
456
|
<form @submit.prevent="handleLogin(email, password)">
|
|
453
457
|
<p v-if="error">{{ error.message }}</p>
|
|
454
458
|
<button :disabled="status === 'pending'" type="submit">
|
|
455
|
-
{{ status ===
|
|
459
|
+
{{ status === "pending" ? "Signing in..." : "Sign In" }}
|
|
456
460
|
</button>
|
|
457
461
|
</form>
|
|
458
462
|
</template>
|
|
@@ -462,14 +466,14 @@ async function handleLogin(email: string, password: string) {
|
|
|
462
466
|
|
|
463
467
|
```vue
|
|
464
468
|
<script setup lang="ts">
|
|
465
|
-
const { mutateAsync: setupMfa } = useMfaSetupMutation()
|
|
466
|
-
const { mutateAsync: finalizeMfa } = useMfaFinalizeMutation()
|
|
469
|
+
const { mutateAsync: setupMfa } = useMfaSetupMutation();
|
|
470
|
+
const { mutateAsync: finalizeMfa } = useMfaFinalizeMutation();
|
|
467
471
|
|
|
468
472
|
// Step 1: Get QR code / secret
|
|
469
|
-
const setupData = await setupMfa()
|
|
473
|
+
const setupData = await setupMfa();
|
|
470
474
|
|
|
471
475
|
// Step 2: User enters TOTP code, finalize
|
|
472
|
-
await finalizeMfa({ otp: userCode })
|
|
476
|
+
await finalizeMfa({ otp: userCode });
|
|
473
477
|
</script>
|
|
474
478
|
```
|
|
475
479
|
|
|
@@ -477,21 +481,22 @@ await finalizeMfa({ otp: userCode })
|
|
|
477
481
|
|
|
478
482
|
```vue
|
|
479
483
|
<script setup lang="ts">
|
|
480
|
-
import { useQueryCache } from
|
|
484
|
+
import { useQueryCache } from "@pinia/colada";
|
|
481
485
|
|
|
482
|
-
const queryCache = useQueryCache()
|
|
483
|
-
const orgId = computed(() => route.params.orgId as string)
|
|
486
|
+
const queryCache = useQueryCache();
|
|
487
|
+
const orgId = computed(() => route.params.orgId as string);
|
|
484
488
|
|
|
485
|
-
const { mutateAsync: removeMethod, status } = useRemovePaymentMethodMutation()
|
|
489
|
+
const { mutateAsync: removeMethod, status } = useRemovePaymentMethodMutation();
|
|
486
490
|
|
|
487
491
|
async function handleRemove(paymentMethodId: string) {
|
|
488
492
|
try {
|
|
489
|
-
await removeMethod({ orgId: orgId.value, paymentMethodId })
|
|
493
|
+
await removeMethod({ orgId: orgId.value, paymentMethodId });
|
|
490
494
|
|
|
491
495
|
// Refetch billing data after removing payment method
|
|
492
|
-
queryCache.invalidateQueries({
|
|
493
|
-
|
|
494
|
-
|
|
496
|
+
queryCache.invalidateQueries({
|
|
497
|
+
key: billingOverviewKeys.byOrg(orgId.value),
|
|
498
|
+
});
|
|
499
|
+
} catch (err) {
|
|
495
500
|
// Handle error
|
|
496
501
|
}
|
|
497
502
|
}
|
|
@@ -502,17 +507,16 @@ async function handleRemove(paymentMethodId: string) {
|
|
|
502
507
|
|
|
503
508
|
```vue
|
|
504
509
|
<script setup lang="ts">
|
|
505
|
-
const { mutate, mutateAsync, status, error } = useUpdateProfileMutation()
|
|
510
|
+
const { mutate, mutateAsync, status, error } = useUpdateProfileMutation();
|
|
506
511
|
|
|
507
512
|
// Fire-and-forget — errors are swallowed, check error ref reactively
|
|
508
|
-
mutate({ name:
|
|
513
|
+
mutate({ name: "New Name" });
|
|
509
514
|
|
|
510
515
|
// Async — errors rethrown, use try/catch
|
|
511
516
|
try {
|
|
512
|
-
const result = await mutateAsync({ name:
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
console.error('Update failed:', err)
|
|
517
|
+
const result = await mutateAsync({ name: "New Name" });
|
|
518
|
+
} catch (err) {
|
|
519
|
+
console.error("Update failed:", err);
|
|
516
520
|
}
|
|
517
521
|
</script>
|
|
518
522
|
```
|
|
@@ -530,10 +534,10 @@ If your app already provides a module the layer includes, [disable it](https://n
|
|
|
530
534
|
```ts
|
|
531
535
|
// nuxt.config.ts
|
|
532
536
|
export default defineNuxtConfig({
|
|
533
|
-
extends: [
|
|
534
|
-
i18n: false,
|
|
535
|
-
pinia: false,
|
|
536
|
-
})
|
|
537
|
+
extends: ["@dev.smartpricing/platform-layer"],
|
|
538
|
+
i18n: false, // disable layer's i18n
|
|
539
|
+
pinia: false, // disable layer's pinia
|
|
540
|
+
});
|
|
537
541
|
```
|
|
538
542
|
|
|
539
543
|
### Override i18n Locales
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Static geo/billing lists for the legal-entity form dropdowns.
|
|
3
|
+
*
|
|
4
|
+
* The three code lists mirror the PostgreSQL enums core-api enforces on
|
|
5
|
+
* `legal_entities` (see `core-api/packages/db-schema/src/schema/enums.ts`:
|
|
6
|
+
* `iso_a2_country_code`, `vat_number_prefix_type`, `iso_phone_country_code`).
|
|
7
|
+
* Anything outside these lists is rejected server-side with a 400, so the
|
|
8
|
+
* UI must not offer it. Keep them in sync when core-api adds values.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** ISO 3166-1 alpha-2 billing countries (`iso_a2_country_code`). Note "IC"
|
|
12
|
+
* (Canary Islands) is a tax territory with no `Intl.DisplayNames` name —
|
|
13
|
+
* label it via the fallback. */
|
|
14
|
+
export const ISO_A2_COUNTRY_CODES = [
|
|
15
|
+
'AD', 'AE', 'AF', 'AG', 'AI', 'AL', 'AM', 'AO', 'AQ', 'AR', 'AS', 'AT', 'AU', 'AW', 'AX', 'AZ',
|
|
16
|
+
'BA', 'BB', 'BD', 'BE', 'BF', 'BG', 'BH', 'BI', 'BJ', 'BL', 'BM', 'BN', 'BO', 'BQ', 'BR', 'BS',
|
|
17
|
+
'BT', 'BV', 'BW', 'BY', 'BZ', 'CA', 'CC', 'CD', 'CF', 'CG', 'CH', 'CI', 'CK', 'CL', 'CM', 'CN',
|
|
18
|
+
'CO', 'CR', 'CU', 'CV', 'CW', 'CX', 'CY', 'CZ', 'DE', 'DJ', 'DK', 'DM', 'DO', 'DZ', 'EC', 'EE',
|
|
19
|
+
'EG', 'EH', 'ER', 'ES', 'ET', 'FI', 'FJ', 'FK', 'FM', 'FO', 'FR', 'GA', 'GB', 'GD', 'GE', 'GF',
|
|
20
|
+
'GG', 'GH', 'GI', 'GL', 'GM', 'GN', 'GP', 'GQ', 'GR', 'GS', 'GT', 'GU', 'GW', 'GY', 'HK', 'HM',
|
|
21
|
+
'HN', 'HR', 'HT', 'HU', 'IC', 'ID', 'IE', 'IL', 'IM', 'IN', 'IO', 'IQ', 'IR', 'IS', 'IT', 'JE',
|
|
22
|
+
'JM', 'JO', 'JP', 'KE', 'KG', 'KH', 'KI', 'KM', 'KN', 'KP', 'KR', 'KW', 'KY', 'KZ', 'LA', 'LB',
|
|
23
|
+
'LC', 'LI', 'LK', 'LR', 'LS', 'LT', 'LU', 'LV', 'LY', 'MA', 'MC', 'MD', 'ME', 'MF', 'MG', 'MH',
|
|
24
|
+
'MK', 'ML', 'MM', 'MN', 'MO', 'MP', 'MQ', 'MR', 'MS', 'MT', 'MU', 'MV', 'MW', 'MX', 'MY', 'MZ',
|
|
25
|
+
'NA', 'NC', 'NE', 'NF', 'NG', 'NI', 'NL', 'NO', 'NP', 'NR', 'NU', 'NZ', 'OM', 'PA', 'PE', 'PF',
|
|
26
|
+
'PG', 'PH', 'PK', 'PL', 'PM', 'PN', 'PR', 'PS', 'PT', 'PW', 'PY', 'QA', 'RE', 'RO', 'RS', 'RU',
|
|
27
|
+
'RW', 'SA', 'SB', 'SC', 'SD', 'SE', 'SG', 'SH', 'SI', 'SJ', 'SK', 'SL', 'SM', 'SN', 'SO', 'SR',
|
|
28
|
+
'SS', 'ST', 'SV', 'SX', 'SY', 'SZ', 'TC', 'TD', 'TF', 'TG', 'TH', 'TJ', 'TK', 'TL', 'TM', 'TN',
|
|
29
|
+
'TO', 'TR', 'TT', 'TV', 'TW', 'TZ', 'UA', 'UG', 'UM', 'US', 'UY', 'UZ', 'VA', 'VC', 'VE', 'VG',
|
|
30
|
+
'VI', 'VN', 'VU', 'WF', 'WS', 'YE', 'YT', 'ZA', 'ZM', 'ZW',
|
|
31
|
+
] as const
|
|
32
|
+
|
|
33
|
+
/** VAT-number prefixes (`vat_number_prefix_type`): the country list plus the
|
|
34
|
+
* VAT-only codes "EL" (Greece) and "XI" (Northern Ireland). */
|
|
35
|
+
export const VAT_NUMBER_PREFIXES = [
|
|
36
|
+
'AD', 'AE', 'AF', 'AG', 'AI', 'AL', 'AM', 'AO', 'AQ', 'AR', 'AS', 'AT', 'AU', 'AW', 'AX', 'AZ',
|
|
37
|
+
'BA', 'BB', 'BD', 'BE', 'BF', 'BG', 'BH', 'BI', 'BJ', 'BL', 'BM', 'BN', 'BO', 'BQ', 'BR', 'BS',
|
|
38
|
+
'BT', 'BV', 'BW', 'BY', 'BZ', 'CA', 'CC', 'CD', 'CF', 'CG', 'CH', 'CI', 'CK', 'CL', 'CM', 'CN',
|
|
39
|
+
'CO', 'CR', 'CU', 'CV', 'CW', 'CX', 'CY', 'CZ', 'DE', 'DJ', 'DK', 'DM', 'DO', 'DZ', 'EC', 'EE',
|
|
40
|
+
'EG', 'EH', 'EL', 'ER', 'ES', 'ET', 'FI', 'FJ', 'FK', 'FM', 'FO', 'FR', 'GA', 'GB', 'GD', 'GE',
|
|
41
|
+
'GF', 'GG', 'GH', 'GI', 'GL', 'GM', 'GN', 'GP', 'GQ', 'GR', 'GS', 'GT', 'GU', 'GW', 'GY', 'HK',
|
|
42
|
+
'HM', 'HN', 'HR', 'HT', 'HU', 'IC', 'ID', 'IE', 'IL', 'IM', 'IN', 'IO', 'IQ', 'IR', 'IS', 'IT',
|
|
43
|
+
'JE', 'JM', 'JO', 'JP', 'KE', 'KG', 'KH', 'KI', 'KM', 'KN', 'KP', 'KR', 'KW', 'KY', 'KZ', 'LA',
|
|
44
|
+
'LB', 'LC', 'LI', 'LK', 'LR', 'LS', 'LT', 'LU', 'LV', 'LY', 'MA', 'MC', 'MD', 'ME', 'MF', 'MG',
|
|
45
|
+
'MH', 'MK', 'ML', 'MM', 'MN', 'MO', 'MP', 'MQ', 'MR', 'MS', 'MT', 'MU', 'MV', 'MW', 'MX', 'MY',
|
|
46
|
+
'MZ', 'NA', 'NC', 'NE', 'NF', 'NG', 'NI', 'NL', 'NO', 'NP', 'NR', 'NU', 'NZ', 'OM', 'PA', 'PE',
|
|
47
|
+
'PF', 'PG', 'PH', 'PK', 'PL', 'PM', 'PN', 'PR', 'PS', 'PT', 'PW', 'PY', 'QA', 'RE', 'RO', 'RS',
|
|
48
|
+
'RU', 'RW', 'SA', 'SB', 'SC', 'SD', 'SE', 'SG', 'SH', 'SI', 'SJ', 'SK', 'SL', 'SM', 'SN', 'SO',
|
|
49
|
+
'SR', 'SS', 'ST', 'SV', 'SX', 'SY', 'SZ', 'TC', 'TD', 'TF', 'TG', 'TH', 'TJ', 'TK', 'TL', 'TM',
|
|
50
|
+
'TN', 'TO', 'TR', 'TT', 'TV', 'TW', 'TZ', 'UA', 'UG', 'UM', 'US', 'UY', 'UZ', 'VA', 'VC', 'VE',
|
|
51
|
+
'VG', 'VI', 'VN', 'VU', 'WF', 'WS', 'XI', 'YE', 'YT', 'ZA', 'ZM', 'ZW',
|
|
52
|
+
] as const
|
|
53
|
+
|
|
54
|
+
/** International calling codes, digits only — exactly the values
|
|
55
|
+
* `iso_phone_country_code` accepts ("+39" is rejected server-side). */
|
|
56
|
+
export const PHONE_COUNTRY_CODES = [
|
|
57
|
+
'1', '7', '20', '27', '30', '31', '32', '33', '34', '36', '39', '40', '41', '43', '44', '45',
|
|
58
|
+
'46', '47', '48', '49', '51', '52', '53', '54', '55', '56', '57', '58', '60', '61', '62', '63',
|
|
59
|
+
'64', '65', '66', '81', '82', '84', '86', '90', '91', '92', '93', '94', '95', '98', '211', '212',
|
|
60
|
+
'213', '216', '218', '220', '221', '222', '223', '224', '225', '226', '227', '228', '229', '230',
|
|
61
|
+
'231', '232', '233', '234', '235', '236', '237', '238', '239', '240', '241', '242', '243', '244',
|
|
62
|
+
'245', '246', '247', '248', '249', '250', '251', '252', '253', '254', '255', '256', '257', '258',
|
|
63
|
+
'260', '261', '262', '263', '264', '265', '266', '267', '268', '269', '290', '291', '297', '298',
|
|
64
|
+
'299', '350', '351', '352', '353', '354', '355', '356', '357', '358', '359', '370', '371', '372',
|
|
65
|
+
'373', '374', '375', '376', '377', '378', '380', '381', '382', '383', '385', '386', '387', '389',
|
|
66
|
+
'420', '421', '423', '500', '501', '502', '503', '504', '505', '506', '507', '508', '509', '590',
|
|
67
|
+
'591', '592', '593', '594', '595', '596', '597', '598', '599', '670', '672', '673', '674', '675',
|
|
68
|
+
'676', '677', '678', '679', '680', '681', '682', '683', '685', '686', '687', '688', '689', '690',
|
|
69
|
+
'691', '692', '850', '852', '853', '855', '856', '880', '886', '960', '961', '962', '963', '964',
|
|
70
|
+
'965', '966', '967', '968', '970', '971', '972', '973', '974', '975', '976', '977', '992', '993',
|
|
71
|
+
'994', '995', '996', '998',
|
|
72
|
+
] as const
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Representative ISO region per calling code, used ONLY for dropdown labels
|
|
76
|
+
* ("+39 · Italia"). Shared codes show their main region (libphonenumber's
|
|
77
|
+
* "main country for code": +1 → US, +7 → RU, ...) without enumerating the
|
|
78
|
+
* others — the stored value is the calling code, so the ambiguity is purely
|
|
79
|
+
* cosmetic. "AC" (+247) and "XK" (+383) are not ISO 3166 codes and fall back
|
|
80
|
+
* to the bare code in `Intl.DisplayNames` lookups.
|
|
81
|
+
*/
|
|
82
|
+
export const PHONE_CODE_PRIMARY_REGION: Record<string, string> = {
|
|
83
|
+
1: 'US', 7: 'RU', 20: 'EG', 27: 'ZA', 30: 'GR', 31: 'NL', 32: 'BE', 33: 'FR', 34: 'ES',
|
|
84
|
+
36: 'HU', 39: 'IT', 40: 'RO', 41: 'CH', 43: 'AT', 44: 'GB', 45: 'DK', 46: 'SE', 47: 'NO',
|
|
85
|
+
48: 'PL', 49: 'DE', 51: 'PE', 52: 'MX', 53: 'CU', 54: 'AR', 55: 'BR', 56: 'CL', 57: 'CO',
|
|
86
|
+
58: 'VE', 60: 'MY', 61: 'AU', 62: 'ID', 63: 'PH', 64: 'NZ', 65: 'SG', 66: 'TH', 81: 'JP',
|
|
87
|
+
82: 'KR', 84: 'VN', 86: 'CN', 90: 'TR', 91: 'IN', 92: 'PK', 93: 'AF', 94: 'LK', 95: 'MM',
|
|
88
|
+
98: 'IR', 211: 'SS', 212: 'MA', 213: 'DZ', 216: 'TN', 218: 'LY', 220: 'GM', 221: 'SN',
|
|
89
|
+
222: 'MR', 223: 'ML', 224: 'GN', 225: 'CI', 226: 'BF', 227: 'NE', 228: 'TG', 229: 'BJ',
|
|
90
|
+
230: 'MU', 231: 'LR', 232: 'SL', 233: 'GH', 234: 'NG', 235: 'TD', 236: 'CF', 237: 'CM',
|
|
91
|
+
238: 'CV', 239: 'ST', 240: 'GQ', 241: 'GA', 242: 'CG', 243: 'CD', 244: 'AO', 245: 'GW',
|
|
92
|
+
246: 'IO', 247: 'AC', 248: 'SC', 249: 'SD', 250: 'RW', 251: 'ET', 252: 'SO', 253: 'DJ',
|
|
93
|
+
254: 'KE', 255: 'TZ', 256: 'UG', 257: 'BI', 258: 'MZ', 260: 'ZM', 261: 'MG', 262: 'RE',
|
|
94
|
+
263: 'ZW', 264: 'NA', 265: 'MW', 266: 'LS', 267: 'BW', 268: 'SZ', 269: 'KM', 290: 'SH',
|
|
95
|
+
291: 'ER', 297: 'AW', 298: 'FO', 299: 'GL', 350: 'GI', 351: 'PT', 352: 'LU', 353: 'IE',
|
|
96
|
+
354: 'IS', 355: 'AL', 356: 'MT', 357: 'CY', 358: 'FI', 359: 'BG', 370: 'LT', 371: 'LV',
|
|
97
|
+
372: 'EE', 373: 'MD', 374: 'AM', 375: 'BY', 376: 'AD', 377: 'MC', 378: 'SM', 380: 'UA',
|
|
98
|
+
381: 'RS', 382: 'ME', 383: 'XK', 385: 'HR', 386: 'SI', 387: 'BA', 389: 'MK', 420: 'CZ',
|
|
99
|
+
421: 'SK', 423: 'LI', 500: 'FK', 501: 'BZ', 502: 'GT', 503: 'SV', 504: 'HN', 505: 'NI',
|
|
100
|
+
506: 'CR', 507: 'PA', 508: 'PM', 509: 'HT', 590: 'GP', 591: 'BO', 592: 'GY', 593: 'EC',
|
|
101
|
+
594: 'GF', 595: 'PY', 596: 'MQ', 597: 'SR', 598: 'UY', 599: 'CW', 670: 'TL', 672: 'NF',
|
|
102
|
+
673: 'BN', 674: 'NR', 675: 'PG', 676: 'TO', 677: 'SB', 678: 'VU', 679: 'FJ', 680: 'PW',
|
|
103
|
+
681: 'WF', 682: 'CK', 683: 'NU', 685: 'WS', 686: 'KI', 687: 'NC', 688: 'TV', 689: 'PF',
|
|
104
|
+
690: 'TK', 691: 'FM', 692: 'MH', 850: 'KP', 852: 'HK', 853: 'MO', 855: 'KH', 856: 'LA',
|
|
105
|
+
880: 'BD', 886: 'TW', 960: 'MV', 961: 'LB', 962: 'JO', 963: 'SY', 964: 'IQ', 965: 'KW',
|
|
106
|
+
966: 'SA', 967: 'YE', 968: 'OM', 970: 'PS', 971: 'AE', 972: 'IL', 973: 'BH', 974: 'QA',
|
|
107
|
+
975: 'BT', 976: 'MN', 977: 'NP', 992: 'TJ', 993: 'TM', 994: 'AZ', 995: 'GE', 996: 'KG',
|
|
108
|
+
998: 'UZ',
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Countries whose VAT prefix differs from their ISO code. Same mapping as
|
|
113
|
+
* core-api (`packages/chargebee-schema/src/customers.ts`) and
|
|
114
|
+
* sp-checkout-frontend: Greece uses "EL" on VATINs.
|
|
115
|
+
*/
|
|
116
|
+
export const COUNTRY_TO_VAT_PREFIX: Record<string, string> = { GR: 'EL' }
|
|
117
|
+
|
|
118
|
+
/** Derives the VAT prefix to preselect for a billing country (GR → EL). */
|
|
119
|
+
export function countryToVatPrefix(country: string | null | undefined): string | null {
|
|
120
|
+
if (!country) return null
|
|
121
|
+
return COUNTRY_TO_VAT_PREFIX[country] ?? country
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Normalizes a user-typed phone prefix to the digits-only form the core-api
|
|
126
|
+
* enum accepts: strips "+", spaces and any other non-digit ("+39 " → "39").
|
|
127
|
+
* Returns the digits, or '' when nothing is left.
|
|
128
|
+
*/
|
|
129
|
+
export function normalizePhonePrefix(input: string): string {
|
|
130
|
+
return input.replace(/\D/g, '')
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* E-invoice routing-code (codice destinatario / `cf_PR_address_code`)
|
|
135
|
+
* placeholders, mirroring core-api's enforcement matrix
|
|
136
|
+
* (`normalizeEinvoiceRoutingCodeOrThrow` in `legal-entities/service.ts`):
|
|
137
|
+
* - non-IT (any type) → "XXXXXXX"
|
|
138
|
+
* - IT natural_person → "0000000"
|
|
139
|
+
* - IT legal_person → any 7-char alphanumeric SDI code ("0000000"
|
|
140
|
+
* when invoices are delivered via PEC)
|
|
141
|
+
*/
|
|
142
|
+
export const IT_EINVOICE_PLACEHOLDER = '0000000'
|
|
143
|
+
export const NON_IT_EINVOICE_PLACEHOLDER = 'XXXXXXX'
|
|
144
|
+
export const SDI_CODE_REGEX = /^[A-Za-z0-9]{7}$/
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The locked routing-code value for a country/entity-type pair, or null when
|
|
148
|
+
* the field is freely editable (Italian businesses).
|
|
149
|
+
*/
|
|
150
|
+
export function fixedEinvoiceRoutingCode(
|
|
151
|
+
countryCode: string | null | undefined,
|
|
152
|
+
entityType: 'natural_person' | 'legal_person' | null | undefined,
|
|
153
|
+
): string | null {
|
|
154
|
+
if (!countryCode || !entityType) return null
|
|
155
|
+
if (countryCode !== 'IT') return NON_IT_EINVOICE_PLACEHOLDER
|
|
156
|
+
if (entityType === 'natural_person') return IT_EINVOICE_PLACEHOLDER
|
|
157
|
+
return null
|
|
158
|
+
}
|
|
@@ -1,6 +1,29 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
|
+
import {
|
|
3
|
+
ISO_A2_COUNTRY_CODES,
|
|
4
|
+
normalizePhonePrefix,
|
|
5
|
+
PHONE_COUNTRY_CODES,
|
|
6
|
+
SDI_CODE_REGEX,
|
|
7
|
+
VAT_NUMBER_PREFIXES,
|
|
8
|
+
} from './billingGeo.js'
|
|
2
9
|
import { UuidSchema } from './common.js'
|
|
3
10
|
|
|
11
|
+
const COUNTRY_CODE_SET = new Set<string>(ISO_A2_COUNTRY_CODES)
|
|
12
|
+
const VAT_PREFIX_SET = new Set<string>(VAT_NUMBER_PREFIXES)
|
|
13
|
+
const PHONE_CODE_SET = new Set<string>(PHONE_COUNTRY_CODES)
|
|
14
|
+
|
|
15
|
+
/** "+39 " → "39", then membership in core-api's `iso_phone_country_code` enum. */
|
|
16
|
+
const PhoneCountryCodeSchema = z
|
|
17
|
+
.string()
|
|
18
|
+
.transform(normalizePhonePrefix)
|
|
19
|
+
.refine(v => PHONE_CODE_SET.has(v), { message: 'Unknown phone country code' })
|
|
20
|
+
const CountryCodeSchema = z
|
|
21
|
+
.string()
|
|
22
|
+
.refine(v => COUNTRY_CODE_SET.has(v), { message: 'Unknown country code' })
|
|
23
|
+
const VatNumberPrefixSchema = z
|
|
24
|
+
.string()
|
|
25
|
+
.refine(v => VAT_PREFIX_SET.has(v), { message: 'Unknown VAT number prefix' })
|
|
26
|
+
|
|
4
27
|
export const LegalEntityTypeSchema = z.enum(['natural_person', 'legal_person'])
|
|
5
28
|
|
|
6
29
|
/**
|
|
@@ -54,6 +77,12 @@ export const LegalEntityDetailSchema = z.object({
|
|
|
54
77
|
einvoiceRoutingCode: z.string(),
|
|
55
78
|
createdAt: z.string(),
|
|
56
79
|
updatedAt: z.string(),
|
|
80
|
+
/**
|
|
81
|
+
* PATCH responses only: true when Chargebee/VIES rejected the VAT and
|
|
82
|
+
* core-api stored it as "unverified" instead of the validated field. The
|
|
83
|
+
* edit modal surfaces this as a warning toast.
|
|
84
|
+
*/
|
|
85
|
+
vatNumberUnverified: z.boolean().optional(),
|
|
57
86
|
})
|
|
58
87
|
|
|
59
88
|
/**
|
|
@@ -68,17 +97,21 @@ export const UpdateLegalEntityRequestSchema = z.object({
|
|
|
68
97
|
companyName: z.string().optional(),
|
|
69
98
|
email: z.string().email().optional(),
|
|
70
99
|
pec: z.string().nullable().optional(),
|
|
71
|
-
phoneCountryCode:
|
|
100
|
+
phoneCountryCode: PhoneCountryCodeSchema.nullable().optional(),
|
|
72
101
|
phone: z.string().nullable().optional(),
|
|
73
102
|
address: z.string().optional(),
|
|
74
103
|
postcode: z.string().optional(),
|
|
75
104
|
city: z.string().optional(),
|
|
76
|
-
state
|
|
77
|
-
|
|
78
|
-
|
|
105
|
+
// Always the 2-character state/province code (TN, NY, 75, …) — core-api
|
|
106
|
+
// rejects anything else and uppercases on write.
|
|
107
|
+
state: z.string().regex(/^[A-Za-z0-9]{2}$/).optional(),
|
|
108
|
+
countryCode: CountryCodeSchema.optional(),
|
|
109
|
+
vatNumberPrefix: VatNumberPrefixSchema.optional(),
|
|
79
110
|
vatNumber: z.string().optional(),
|
|
80
111
|
taxCode: z.string().optional(),
|
|
81
|
-
|
|
112
|
+
// 7 alphanumeric chars: real SDI codes and both placeholders ("0000000",
|
|
113
|
+
// "XXXXXXX") pass; core-api enforces the full country/type matrix.
|
|
114
|
+
einvoiceRoutingCode: z.string().regex(SDI_CODE_REGEX).optional(),
|
|
82
115
|
})
|
|
83
116
|
|
|
84
117
|
/**
|
|
@@ -74,7 +74,13 @@ export const CreatePaymentMethodRequestSchema = z.discriminatedUnion('type', [
|
|
|
74
74
|
/** One item for a card, one per customer for the IBAN fan-out, plus per-customer failures. */
|
|
75
75
|
export const CreatePaymentMethodsResponseSchema = z.object({
|
|
76
76
|
items: z.array(PaymentMethodSchema),
|
|
77
|
-
failed: z.array(z.object({
|
|
77
|
+
failed: z.array(z.object({
|
|
78
|
+
customerId: UuidSchema,
|
|
79
|
+
/** Provider message for logs/ops — never shown to the customer. */
|
|
80
|
+
reason: z.string(),
|
|
81
|
+
/** Machine-readable failure code (Chargebee api_error_code) the UI maps to localized copy. Optional during core-api rollout. */
|
|
82
|
+
code: z.string().optional(),
|
|
83
|
+
})),
|
|
78
84
|
})
|
|
79
85
|
|
|
80
86
|
export type PaymentMethodType = z.infer<typeof PaymentMethodTypeSchema>
|
package/_shared/index.ts
CHANGED
|
@@ -14,6 +14,7 @@ export * from './billingInvoices.js'
|
|
|
14
14
|
export * from './billingCreditNotes.js'
|
|
15
15
|
export * from './billingPaymentMethods.js'
|
|
16
16
|
export * from './billingLegalEntities.js'
|
|
17
|
+
export * from './billingGeo.js'
|
|
17
18
|
export * from './billingOverview.js'
|
|
18
19
|
export * from './billingDocuments.js'
|
|
19
20
|
export * from './crossSelling.js'
|
package/_shared/permissions.ts
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
2
|
|
|
3
3
|
const BundleSchema = z.object({
|
|
4
|
+
/** Has an active or trial billing subscription. */
|
|
5
|
+
subscribed: z.boolean(),
|
|
6
|
+
/** Lifecycle product_access capability is on. */
|
|
4
7
|
enabled: z.boolean(),
|
|
8
|
+
/** Subscribed but blocked by overdue invoices (unpaid past grace). */
|
|
9
|
+
overdue: z.boolean(),
|
|
10
|
+
/** Computed: subscribed && enabled — can the user enter the product. */
|
|
11
|
+
canAccess: z.boolean(),
|
|
5
12
|
})
|
|
6
13
|
|
|
7
14
|
export const PermissionsResponseSchema = z.object({
|
package/_shared/products.ts
CHANGED
|
@@ -15,7 +15,7 @@ export const SuiteLinkSchema = z.object({
|
|
|
15
15
|
export const OrgProductStatusSchema = z.enum(['active', 'trial', 'not_subscribed'])
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
|
-
* Billing context surfaced when a
|
|
18
|
+
* Billing context surfaced when a overdue customer cannot access a product —
|
|
19
19
|
* lets the UI explain *why* access is blocked (e.g. an unpaid invoice).
|
|
20
20
|
*/
|
|
21
21
|
export const ProductBlockedReasonSchema = z.object({
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { SuiteProduct } from 'nuxt-ui-layer/types'
|
|
3
|
+
|
|
4
|
+
const props = defineProps<{
|
|
5
|
+
product: SuiteProduct
|
|
6
|
+
collapsed?: boolean
|
|
7
|
+
}>()
|
|
8
|
+
|
|
9
|
+
const { enabledProducts, navigateToProduct } = useProductSwitcher()
|
|
10
|
+
|
|
11
|
+
const selectedProduct = shallowRef<SuiteProduct>(props.product)
|
|
12
|
+
|
|
13
|
+
watch(selectedProduct, (value) => {
|
|
14
|
+
if (value !== props.product)
|
|
15
|
+
navigateToProduct(value)
|
|
16
|
+
})
|
|
17
|
+
</script>
|
|
18
|
+
|
|
19
|
+
<template>
|
|
20
|
+
<SNavigationProducts
|
|
21
|
+
v-model="selectedProduct"
|
|
22
|
+
:products="enabledProducts"
|
|
23
|
+
:collapsed="collapsed"
|
|
24
|
+
/>
|
|
25
|
+
</template>
|
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
export function useApiClient() {
|
|
2
|
-
const { BACKEND_BASE_URL,
|
|
2
|
+
const { BACKEND_BASE_URL, CSRF_COOKIE_NAME, PLATFORM_URL, ENVIRONMENT } = useRuntimeConfig().public
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
const baseCsrfName = (CSRF_COOKIE_NAME as string | undefined) || 'smt_csrf'
|
|
6
|
+
const environment = (ENVIRONMENT as string | undefined) || 'local'
|
|
7
|
+
const csrfCookieName = environment === 'prod' ? baseCsrfName : `${baseCsrfName}_${environment}`
|
|
3
8
|
|
|
4
9
|
return createApiClient({
|
|
5
10
|
baseURL: BACKEND_BASE_URL as string,
|
|
6
11
|
csrf: {
|
|
7
|
-
cookieName:
|
|
12
|
+
cookieName: csrfCookieName
|
|
8
13
|
},
|
|
9
14
|
auth: {
|
|
10
15
|
refreshBaseURL: BACKEND_BASE_URL as string,
|
|
@@ -16,7 +21,11 @@ export function useApiClient() {
|
|
|
16
21
|
},
|
|
17
22
|
},
|
|
18
23
|
onRefreshFailed: () => {
|
|
19
|
-
if (import.meta.client)
|
|
24
|
+
if (!import.meta.client) return
|
|
25
|
+
// An accounting-token exchange is in flight (00.accounting-token
|
|
26
|
+
// middleware) — redirecting to login now would clobber it.
|
|
27
|
+
if (new URLSearchParams(window.location.search).has('accounting_token')) return
|
|
28
|
+
if (!isPublicAuthRoute(window.location.pathname)) {
|
|
20
29
|
window.location.assign(`${PLATFORM_URL}/auth/login`)
|
|
21
30
|
}
|
|
22
31
|
},
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import type { PermissionsResponse } from '@package/platform-shared'
|
|
2
|
+
import type { SuiteProduct } from 'nuxt-ui-layer/types';
|
|
3
|
+
import { computed } from 'vue'
|
|
4
|
+
|
|
5
|
+
type ProductKey = keyof PermissionsResponse['products']
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Product access state derived from the subscribed/enabled/overdue matrix
|
|
9
|
+
* (overdue = subscribed but blocked by unpaid invoices):
|
|
10
|
+
*
|
|
11
|
+
* | subscribed | enabled | overdue | state | action |
|
|
12
|
+
* |------------|---------|---------|-----------------|----------------------|
|
|
13
|
+
* | true | true | false | accessible | enter product |
|
|
14
|
+
* | true | false | true | overdue | view unpaid invoices |
|
|
15
|
+
* | true | false | false | blocked | discovery |
|
|
16
|
+
* | false | true | false | not_subscribed | go to billing |
|
|
17
|
+
* | false | false | false | not_subscribed | discovery |
|
|
18
|
+
*/
|
|
19
|
+
export type ProductAccessState = 'accessible' | 'overdue' | 'blocked' | 'not_subscribed'
|
|
20
|
+
|
|
21
|
+
function resolveAccessState(product: { subscribed: boolean; enabled: boolean; overdue: boolean; canAccess: boolean }): ProductAccessState {
|
|
22
|
+
if (product.canAccess) return 'accessible'
|
|
23
|
+
if (product.subscribed && product.overdue) return 'overdue'
|
|
24
|
+
if (product.subscribed && !product.enabled) return 'blocked'
|
|
25
|
+
if (!product.subscribed && product.enabled) return 'not_subscribed'
|
|
26
|
+
return 'not_subscribed'
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const PRODUCT_KEY_TO_SUITE: Record<ProductKey, SuiteProduct> = {
|
|
30
|
+
pricing: 'pricing',
|
|
31
|
+
connect: 'connect',
|
|
32
|
+
chat: 'chat',
|
|
33
|
+
smartpms: 'pms',
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function useProductSwitcher() {
|
|
37
|
+
const { data: permissions, status } = usePermissionsQuery()
|
|
38
|
+
const config = useRuntimeConfig().public
|
|
39
|
+
|
|
40
|
+
const productUrls: Record<string, string> = {
|
|
41
|
+
config: config.PLATFORM_APP_URL,
|
|
42
|
+
pricing: config.PRICING_APP_URL,
|
|
43
|
+
connect: config.CONNECT_APP_URL,
|
|
44
|
+
chat: config.CHAT_APP_URL,
|
|
45
|
+
pms: config.PMS_APP_URL,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Access state for each suite product. */
|
|
49
|
+
const productStates = computed<Map<SuiteProduct, ProductAccessState>>(() => {
|
|
50
|
+
const map = new Map<SuiteProduct, ProductAccessState>()
|
|
51
|
+
if (!permissions.value) return map
|
|
52
|
+
|
|
53
|
+
for (const [key, bundle] of Object.entries(permissions.value.products)) {
|
|
54
|
+
const suite = PRODUCT_KEY_TO_SUITE[key as ProductKey]
|
|
55
|
+
if (suite) {
|
|
56
|
+
map.set(suite, resolveAccessState(bundle))
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return map
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
/** Products the user can fully enter (subscribed + enabled). */
|
|
63
|
+
const enabledProducts = computed<SuiteProduct[]>(() => {
|
|
64
|
+
const enabled: SuiteProduct[] = ['config' as SuiteProduct]
|
|
65
|
+
|
|
66
|
+
for (const [suite, state] of productStates.value) {
|
|
67
|
+
if (state === 'accessible') {
|
|
68
|
+
enabled.push(suite)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return enabled
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
const isLoading = computed(() => status.value === 'pending')
|
|
76
|
+
|
|
77
|
+
function navigateToProduct(product?: SuiteProduct) {
|
|
78
|
+
if (!product) return
|
|
79
|
+
|
|
80
|
+
const state = productStates.value.get(product) ?? 'not_subscribed'
|
|
81
|
+
|
|
82
|
+
switch (state) {
|
|
83
|
+
case 'accessible': {
|
|
84
|
+
const url = productUrls[product]
|
|
85
|
+
if (url) location.assign(url)
|
|
86
|
+
break
|
|
87
|
+
}
|
|
88
|
+
case 'overdue':
|
|
89
|
+
window.open(`${productUrls.config}/account/overview`, '_blank', 'noopener,noreferrer')
|
|
90
|
+
break
|
|
91
|
+
case 'blocked':
|
|
92
|
+
case 'not_subscribed':
|
|
93
|
+
window.open(`${productUrls.config}/upgrade`, '_blank', 'noopener,noreferrer')
|
|
94
|
+
break
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function getProductUrl(product: SuiteProduct): string | undefined {
|
|
99
|
+
return productUrls[product] || undefined
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function getProductState(product: SuiteProduct): ProductAccessState {
|
|
103
|
+
return productStates.value.get(product) ?? 'not_subscribed'
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
enabledProducts,
|
|
108
|
+
productStates,
|
|
109
|
+
isLoading,
|
|
110
|
+
navigateToProduct,
|
|
111
|
+
getProductUrl,
|
|
112
|
+
getProductState,
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -32,12 +32,26 @@ export interface ApiClientOptions {
|
|
|
32
32
|
onResponseError?: OnResponseErrorHook
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
function errorCode(data: unknown): string | undefined {
|
|
36
|
+
return (data as { error?: { code?: string } } | undefined)?.error?.code
|
|
37
|
+
}
|
|
38
|
+
|
|
35
39
|
function defaultShouldRefresh(status: number, data: unknown): boolean {
|
|
36
40
|
if (status !== 401) return false
|
|
37
|
-
const code = (data
|
|
41
|
+
const code = errorCode(data)
|
|
38
42
|
return code === 'session.expired' || code === 'session.missing'
|
|
39
43
|
}
|
|
40
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Codes that mean the session is dead and can't be recovered by a refresh: a tampered/corrupted or
|
|
47
|
+
* rotated-secret token (`session.invalid`), or `session.expired`/`session.missing` once a refresh
|
|
48
|
+
* has already been tried and failed. Distinct from non-session 401s (bad credentials, wrong OTP)
|
|
49
|
+
* which must NOT trigger a logout redirect.
|
|
50
|
+
*/
|
|
51
|
+
function isDeadSessionCode(code: string | undefined): boolean {
|
|
52
|
+
return code === 'session.invalid' || code === 'session.expired' || code === 'session.missing'
|
|
53
|
+
}
|
|
54
|
+
|
|
41
55
|
// Module-level refresh dedup — safe because refresh is client-only
|
|
42
56
|
let refreshPromise: Promise<boolean> | null = null
|
|
43
57
|
|
|
@@ -136,6 +150,17 @@ export function createApiClient(options: ApiClientOptions) {
|
|
|
136
150
|
serializeAndThrow(retryError)
|
|
137
151
|
}
|
|
138
152
|
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// A dead-session 401 we reach here is unrecoverable: either it wasn't refresh-eligible — a
|
|
156
|
+
// tampered or rotated-secret token surfaces as `session.invalid`, which never becomes
|
|
157
|
+
// `session.expired` and so never self-heals — or the refresh above failed. Force re-auth
|
|
158
|
+
// instead of leaving the client "logged in" while every request 401s (the zombie state: a JWT
|
|
159
|
+
// secret rotation would drop all active users into it at once). onRefreshFailed does a hard
|
|
160
|
+
// redirect to login, which reloads the app and discards the stale in-memory auth state; it
|
|
161
|
+
// self-guards against the accounting-token exchange and public auth routes. Non-session 401s
|
|
162
|
+
// (bad credentials, wrong OTP) fall through untouched so callers can surface the error.
|
|
163
|
+
if (status === 401 && isDeadSessionCode(errorCode(data))) {
|
|
139
164
|
auth.onRefreshFailed?.()
|
|
140
165
|
}
|
|
141
166
|
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth paths that require an active session despite living under `/auth/`.
|
|
3
|
+
* Every other `/auth/*` route is public (login, activate, password reset, etc.).
|
|
4
|
+
*/
|
|
5
|
+
const AUTHENTICATED_AUTH_PATHS = ['/auth/otp-step-up', '/auth/products'] as const
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Returns `true` for `/auth/*` routes that are reachable without a session
|
|
9
|
+
* (e.g. login, activate, forgot-password, reset-password).
|
|
10
|
+
*
|
|
11
|
+
* Used to:
|
|
12
|
+
* - skip the boot-time session restore (avoids a guaranteed-to-fail GET /api/me)
|
|
13
|
+
* - suppress the redirect-to-login on refresh failure
|
|
14
|
+
*/
|
|
15
|
+
export function isPublicAuthRoute(pathname: string): boolean {
|
|
16
|
+
if (!pathname.startsWith('/auth/')) return false
|
|
17
|
+
return !AUTHENTICATED_AUTH_PATHS.some(p => pathname.startsWith(p))
|
|
18
|
+
}
|
package/nuxt.config.ts
CHANGED
|
@@ -46,8 +46,15 @@ export default defineNuxtConfig({
|
|
|
46
46
|
runtimeConfig: {
|
|
47
47
|
public: {
|
|
48
48
|
BACKEND_BASE_URL: '',
|
|
49
|
-
|
|
49
|
+
CSRF_COOKIE_NAME: '',
|
|
50
50
|
PLATFORM_URL: '',
|
|
51
|
+
ENVIRONMENT: '',
|
|
52
|
+
// Standard product URL convention: {PRODUCT}_APP_URL
|
|
53
|
+
PLATFORM_APP_URL: '',
|
|
54
|
+
PRICING_APP_URL: '',
|
|
55
|
+
CONNECT_APP_URL: '',
|
|
56
|
+
CHAT_APP_URL: '',
|
|
57
|
+
PMS_APP_URL: '',
|
|
51
58
|
},
|
|
52
59
|
},
|
|
53
60
|
})
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dev.smartpricing/platform-layer",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.6",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"private": false,
|
|
6
6
|
"main": "./nuxt.config.ts",
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
},
|
|
20
20
|
"devDependencies": {
|
|
21
21
|
"nuxt": "^4.4.2",
|
|
22
|
-
"nuxt-ui-layer": "github:smartpricing/smartness-nuxt-ui#v1.
|
|
22
|
+
"nuxt-ui-layer": "github:smartpricing/smartness-nuxt-ui#v1.7.1-platform.1",
|
|
23
23
|
"vue": "latest"
|
|
24
24
|
},
|
|
25
25
|
"peerDependencies": {
|
|
@@ -29,6 +29,6 @@
|
|
|
29
29
|
"dev": "nuxi dev .playground",
|
|
30
30
|
"dev:prepare": "nuxt prepare .playground",
|
|
31
31
|
"build": "nuxt build .playground",
|
|
32
|
-
"postinstall": "nuxt prepare"
|
|
32
|
+
"postinstall": "nuxt prepare .playground"
|
|
33
33
|
}
|
|
34
34
|
}
|