@merkaly/nuxt 0.12.3 → 0.12.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -35,6 +35,20 @@ On boot the module restores an existing session (user and token) and then runs t
35
35
  | store | optional | required | `merkaly:tenant` resolves the store from the host (public); a signed-in buyer is restored too; protected pages (account, checkout) ask for login. |
36
36
  | www | optional | not used | No hooks; anonymous or signed-in. |
37
37
 
38
+ ### Login entry points (`MKAuth`)
39
+
40
+ The callback page renders `MKAuth`, which reads its query string:
41
+
42
+ | Query | Behavior |
43
+ |---|---|
44
+ | `?code=...` (Auth0 callback) | Completes the login, navigates to the `appState.target` set when it started (or `/`) and emits `success` with that `appState`. |
45
+ | `?error=...&error_description=...` | Emits `error` and shows a 400 error page. |
46
+ | `?invitation=<ticket>&organization=<org_id>` | Accepts an organization invitation (Auth0 Organizations). |
47
+ | `?organization=<org_id>[&redirect=/path]` | Logs in straight into that organization (a silent login when the Auth0 session exists) and lands on `redirect`, e.g. `https://admin.merkaly.io/auth?organization=org_123&redirect=/onboarding` right after creating a store in www. |
48
+ | `?redirect=/path` | Plain login, lands on `redirect` (the auth middleware sends protected pages here). |
49
+
50
+ To open the Auth0 signup screen instead of the login one, use `useAuth().signup({ redirectTo })`: it calls `loginWithRedirect` with `screen_hint: 'signup'` and `appState: { target: redirectTo }`, so after the callback the app lands on `redirectTo` (default `/`).
51
+
38
52
  ### Session lifetime
39
53
 
40
54
  The SPA keeps the session with **refresh tokens**: the client uses `useRefreshTokens` with `localStorage` cache and logs in with `audience` and `scope: openid profile email offline_access`. Before each API call the module asks Auth0 for the current access token, which is cached and renewed with the refresh token once it expires (2 hours for `https://api.merkaly.io`). When the refresh token is gone or expired, protected surfaces (`requiresAuth: true`) go back to login instead of calling the API without a token. Account linking (`linkWithConnection(connection)`) uses a separate Auth0 client, the `auth.merkaly.io` SPA client in Auth0, with an in-memory cache, created only on the first call (no extra `checkSession` on page load). It opens a login popup for that connection and sends the popup's raw ID token to `POST /auth/identities` as `{ idToken }`; the api verifies the token (signature, issuer, audience = the account linking client, expiry) and links the identity from its `sub` into the logged-in user. Requires an api with that contract (older clients that sent `{ provider, userId }` get `400`).
@@ -113,6 +127,39 @@ The path is `/phog` because ad blockers drop requests to paths such as
113
127
  `/posthog`, `/analytics`, `/tracking` or `/telemetry`; a first-party, neutral
114
128
  path keeps events flowing.
115
129
 
130
+ ## Notifications and modals
131
+
132
+ `useNotify()` is the standard for feedback and confirmations in every app:
133
+
134
+ | Need | Use |
135
+ |---|---|
136
+ | Short feedback | `$notify.toast({ title, variant })` |
137
+ | Confirm an action (text and two buttons) | `$notify.confirm({ title, message, confirmText, cancelText, variant, onConfirm })` |
138
+ | Modal with its own content (form, list) | a component with `<BModal>`, opened with `useModal().create({ component: markRaw(component), ...props }).show()` |
139
+
140
+ ```ts
141
+ const $notify = useNotify();
142
+
143
+ $notify.confirm({
144
+ cancelText: $t('actions.back'),
145
+ confirmText: $t('order.cancel.submit'),
146
+ message: $t('order.cancel.confirm'),
147
+ onConfirm: () => cancel({ id }).then(() => !error.value), // false keeps it open (the error is shown by the app)
148
+ title: $t('order.cancel.modalTitle', { number }),
149
+ variant: 'danger',
150
+ });
151
+ ```
152
+
153
+ - `onConfirm` returning a promise keeps the modal open with both buttons disabled until it settles; it
154
+ closes afterwards unless the promise resolves `false`. Adapters' `execute` resolves on api errors too,
155
+ so check `error` before returning.
156
+ - Each call creates its own modal and destroys it when hidden.
157
+ - No inline "click again to confirm" and no `<BModal v-model>` in a page: open modals through `create`.
158
+ - A component opened by `create` is mounted outside the page tree: pass data as props and answer
159
+ through emits (`onSaved`), never `inject`. Do not use `modelValue` for data: the orchestrator shows the modal through it
160
+ (use a named model such as `v-model:page`). Destroy it on `onHidden` when created from a click.
161
+ - Props already set in the app `bootstrap.config.ts` are defaults: do not repeat them.
162
+
116
163
  ## Money formatting
117
164
 
118
165
  Money in Merkaly is stored as **integer cents**. `MKFormatMoney`, `MKInputMoney`,
package/dist/module.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "compatibility": {
5
5
  "nuxt": ">=3.14.0"
6
6
  },
7
- "version": "0.12.3",
7
+ "version": "0.12.5",
8
8
  "builder": {
9
9
  "@nuxt/module-builder": "1.0.2",
10
10
  "unbuild": "3.6.1"
@@ -1,9 +1,13 @@
1
1
  declare const __VLS_export: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {} & {
2
2
  error: (reason: string) => any;
3
- success: () => any;
3
+ success: (appState?: {
4
+ target?: string;
5
+ } | undefined) => any;
4
6
  }, string, import("vue").PublicProps, Readonly<{}> & Readonly<{
5
7
  onError?: ((reason: string) => any) | undefined;
6
- onSuccess?: (() => any) | undefined;
8
+ onSuccess?: ((appState?: {
9
+ target?: string;
10
+ } | undefined) => any) | undefined;
7
11
  }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
8
12
  declare const _default: typeof __VLS_export;
9
13
  export default _default;
@@ -12,6 +12,9 @@ const redirect = params.get("redirect");
12
12
  function handleInvite() {
13
13
  return $auth0.loginWithRedirect({ authorizationParams: { organization, invitation } });
14
14
  }
15
+ function handleOrganization() {
16
+ return $auth0.loginWithRedirect({ appState: { target: redirect || "/" }, authorizationParams: { organization } });
17
+ }
15
18
  function handleError() {
16
19
  const errorDescription = params.get("error_description") ?? "Auth error";
17
20
  emit("error", errorDescription);
@@ -21,12 +24,13 @@ function handleError() {
21
24
  });
22
25
  }
23
26
  function handleCode() {
24
- return void $auth0.handleRedirectCallback().then(() => emit("success"));
27
+ return void $auth0.handleRedirectCallback().then((result) => emit("success", result?.appState));
25
28
  }
26
29
  callOnce(async () => {
27
30
  if (error) return handleError();
28
31
  if (code) return handleCode();
29
32
  if (invitation && organization) return handleInvite();
33
+ if (organization) return handleOrganization();
30
34
  return $auth0.loginWithRedirect({ appState: { target: redirect || "/" } });
31
35
  });
32
36
  </script>
@@ -1,9 +1,13 @@
1
1
  declare const __VLS_export: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {} & {
2
2
  error: (reason: string) => any;
3
- success: () => any;
3
+ success: (appState?: {
4
+ target?: string;
5
+ } | undefined) => any;
4
6
  }, string, import("vue").PublicProps, Readonly<{}> & Readonly<{
5
7
  onError?: ((reason: string) => any) | undefined;
6
- onSuccess?: (() => any) | undefined;
8
+ onSuccess?: ((appState?: {
9
+ target?: string;
10
+ } | undefined) => any) | undefined;
7
11
  }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
8
12
  declare const _default: typeof __VLS_export;
9
13
  export default _default;
@@ -106,8 +106,8 @@ function getRowAttrs(item, idx) {
106
106
  </script>
107
107
 
108
108
  <template>
109
- <BCard no-body>
110
- <BCardHeader v-if="!props.hideHeader" class="align-items-center position-relative">
109
+ <BCard class="card-flush" no-body>
110
+ <BCardHeader v-if="!props.hideHeader" class="align-items-center position-relative py-5 gap-2 gap-md-5">
111
111
  <BCardTitle>
112
112
  <div v-if="hasBulkSlot && !props.hideSelect && selectionState.selectedCount" class="w-35px">
113
113
  <DropdownIcon icon="caret-down" toggle-class="p-2">
@@ -181,99 +181,100 @@ function getRowAttrs(item, idx) {
181
181
  style="bottom: -6px; z-index: 10" />
182
182
  </BCardHeader>
183
183
 
184
- <BTableSimple
185
- :class="{ 'h-100': !visibleItems.length || $datagrid.loading }"
186
- class="mb-0 overflow-auto"
187
- hover
188
- responsive="lg"
189
- small
190
- table-class="align-middle table-row-dashed h-100">
191
- <BThead class="sticky-top z-index-1 align-middle">
192
- <BTr class="text-start text-body-secondary fw-bold text-uppercase">
193
- <BTh v-if="hasDetailsSlot" class="w-25px" style="padding: unset !important;" />
184
+ <BCardBody class="pt-0">
185
+ <BTableSimple
186
+ :class="{ 'h-100': !visibleItems.length || $datagrid.loading }"
187
+ class="mb-0 overflow-auto"
188
+ responsive="lg"
189
+ table-class="align-middle table-row-dashed fs-6 gy-5 h-100">
190
+ <BThead class="sticky-top z-index-1 align-middle">
191
+ <BTr class="text-start text-gray-500 fw-bold fs-7 text-uppercase gs-0">
192
+ <BTh v-if="hasDetailsSlot" class="w-25px" style="padding: unset !important;" />
194
193
 
195
- <BTh v-if="!props.hideSelect" class="w-40px px-0">
196
- <BFormCheckbox
197
- :disabled="$datagrid.loading"
198
- :indeterminate="selectionState.checkboxAllAttrs.indeterminate"
199
- :model-value="selectionState.allChecked"
200
- class="form-check-sm form-check-custom cell-checkbox"
201
- @update:model-value="toggleCheckAll()" />
202
- </BTh>
203
-
204
- <template v-for="[key, column] in visibleColumns" :key="key">
205
- <BTh :class="[column.class, column.thClass, 'text-nowrap']">
206
- <slot :name="`head[${key}]`" v-bind="{ column, key }">
207
- <span v-text="column.title ?? sentenceCase(key)" />
208
- </slot>
209
- </BTh>
210
- </template>
211
-
212
- <BTh v-if="hasActionsSlot" class="text-end px-3" />
213
- </BTr>
214
- </BThead>
215
-
216
- <BTbody v-if="!visibleItems.length">
217
- <BTd :colspan="tableColspan">
218
- <slot name="empty">
219
- <div class="text-center text-muted" v-text="props.emptyText ?? t('empty')" />
220
- </slot>
221
- </BTd>
222
- </BTbody>
223
-
224
- <BTbody v-else class="fw-semibold text-nowrap">
225
- <template v-for="(item, idx) in visibleItems" :key="getRowKey(item, idx)">
226
- <BTr v-bind="getRowAttrs(item, idx)">
227
- <BTd v-if="hasDetailsSlot" class="w-25px" style="padding: unset !important;">
228
- <BButton
229
- class="w-25px h-100 rounded-0 p-0 border-end border-dashed"
230
- size="sm"
231
- variant="none"
232
- @click="toggleDetails(item)">
233
- <FormatIcon :name="item._showDetails ? 'chevron-down' : 'chevron-right'" class="p-0" size="sm"
234
- variant="primary" />
235
- </BButton>
236
- </BTd>
237
-
238
- <BTd v-if="!props.hideSelect" class="w-40px px-0">
194
+ <BTh v-if="!props.hideSelect" class="w-40px px-0">
239
195
  <BFormCheckbox
240
- v-model="item._checked"
241
196
  :disabled="$datagrid.loading"
242
- class="form-check-sm form-check-custom cell-checkbox" />
243
- </BTd>
197
+ :indeterminate="selectionState.checkboxAllAttrs.indeterminate"
198
+ :model-value="selectionState.allChecked"
199
+ class="form-check-sm form-check-custom cell-checkbox"
200
+ @update:model-value="toggleCheckAll()" />
201
+ </BTh>
244
202
 
245
203
  <template v-for="[key, column] in visibleColumns" :key="key">
246
- <BTd :class="[column.class, column.tdClass]">
247
- <slot :name="`row[${key}]`" v-bind="{ column, idx, item, key }">
248
- <span v-text="column.getter?.(item) ?? getItemValue(item, key)" />
204
+ <BTh :class="[column.class, column.thClass, 'text-nowrap']">
205
+ <slot :name="`head[${key}]`" v-bind="{ column, key }">
206
+ <span v-text="column.title ?? sentenceCase(key)" />
249
207
  </slot>
250
- </BTd>
208
+ </BTh>
251
209
  </template>
252
210
 
253
- <BTd v-if="hasActionsSlot" class="text-end px-3">
254
- <DropdownIcon :text="t('actions')" icon="caret-down" toggle-class="py-1 px-3" variant="light-dark">
255
- <slot :index="idx" :item="item" name="actions" />
256
- </DropdownIcon>
257
- </BTd>
211
+ <BTh v-if="hasActionsSlot" class="text-end px-3" />
258
212
  </BTr>
213
+ </BThead>
259
214
 
260
- <BTr v-if="hasDetailsSlot && item._showDetails">
261
- <BTd :colspan="tableColspan" class="p-0">
262
- <slot
263
- :index="idx"
264
- :item="item"
265
- :toggle-details="() => toggleDetails(item)"
266
- name="details" />
267
- </BTd>
268
- </BTr>
269
- </template>
270
- </BTbody>
215
+ <BTbody v-if="!visibleItems.length">
216
+ <BTd :colspan="tableColspan">
217
+ <slot name="empty">
218
+ <div class="text-center text-muted" v-text="props.emptyText ?? t('empty')" />
219
+ </slot>
220
+ </BTd>
221
+ </BTbody>
222
+
223
+ <BTbody v-else class="fw-semibold text-gray-600 text-nowrap">
224
+ <template v-for="(item, idx) in visibleItems" :key="getRowKey(item, idx)">
225
+ <BTr v-bind="getRowAttrs(item, idx)">
226
+ <BTd v-if="hasDetailsSlot" class="w-25px" style="padding: unset !important;">
227
+ <BButton
228
+ class="w-25px h-100 rounded-0 p-0 border-end border-dashed"
229
+ size="sm"
230
+ variant="none"
231
+ @click="toggleDetails(item)">
232
+ <FormatIcon
233
+ :name="item._showDetails ? 'chevron-down' : 'chevron-right'" class="p-0" size="sm"
234
+ variant="primary" />
235
+ </BButton>
236
+ </BTd>
237
+
238
+ <BTd v-if="!props.hideSelect" class="w-40px px-0">
239
+ <BFormCheckbox
240
+ v-model="item._checked"
241
+ :disabled="$datagrid.loading"
242
+ class="form-check-sm form-check-custom cell-checkbox" />
243
+ </BTd>
244
+
245
+ <template v-for="[key, column] in visibleColumns" :key="key">
246
+ <BTd :class="[column.class, column.tdClass]">
247
+ <slot :name="`row[${key}]`" v-bind="{ column, idx, item, key }">
248
+ <span v-text="column.getter?.(item) ?? getItemValue(item, key)" />
249
+ </slot>
250
+ </BTd>
251
+ </template>
252
+
253
+ <BTd v-if="hasActionsSlot" class="text-end px-3">
254
+ <DropdownIcon :text="t('actions')" icon="caret-down" toggle-class="py-1 px-3" variant="light-dark">
255
+ <slot :index="idx" :item="item" name="actions" />
256
+ </DropdownIcon>
257
+ </BTd>
258
+ </BTr>
259
+
260
+ <BTr v-if="hasDetailsSlot && item._showDetails">
261
+ <BTd :colspan="tableColspan" class="p-0">
262
+ <slot
263
+ :index="idx"
264
+ :item="item"
265
+ :toggle-details="() => toggleDetails(item)"
266
+ name="details" />
267
+ </BTd>
268
+ </BTr>
269
+ </template>
270
+ </BTbody>
271
271
 
272
- <BTfoot>
273
- <slot name="tfoot" />
274
- </BTfoot>
272
+ <BTfoot>
273
+ <slot name="tfoot" />
274
+ </BTfoot>
275
275
 
276
- </BTableSimple>
276
+ </BTableSimple>
277
+ </BCardBody>
277
278
 
278
279
  <BCardFooter v-if="!props.hideFooter">
279
280
  <slot name="footer" />
@@ -317,24 +318,6 @@ function getRowAttrs(item, idx) {
317
318
  .cell-checkbox input {
318
319
  cursor: pointer;
319
320
  }
320
-
321
- table thead {
322
- font-size: 0.8rem;
323
- background: var(--bs-body-bg);
324
- }
325
- table thead:before {
326
- content: "";
327
- position: absolute;
328
- inset: 0;
329
- z-index: -1;
330
- background: rgb(var(--bs-primary-rgb), 0.05);
331
- }
332
- table thead tr th:is(:first-child, :last-child) {
333
- padding: 0 0.75rem !important;
334
- }
335
- table tbody tr td:is(:first-child, :last-child) {
336
- padding: 0 0.75rem !important;
337
- }
338
321
  </style>
339
322
 
340
323
  <i18n lang="json">
@@ -20,6 +20,9 @@ export declare const useAuth: () => {
20
20
  isLoading: import("vue").Ref<boolean, boolean>;
21
21
  logout: () => any;
22
22
  retry: () => Promise<void>;
23
+ signup: ({ redirectTo }?: {
24
+ redirectTo?: string;
25
+ }) => any;
23
26
  token: import("vue").Ref<string | null, string | null>;
24
27
  user: import("vue").Ref<User | undefined, User | undefined>;
25
28
  };
@@ -9,6 +9,10 @@ export const useAuth = () => {
9
9
  const isAuthenticated = computed(() => Boolean(user.value?.sub));
10
10
  const clear = () => error.value = void 0;
11
11
  const logout = () => nuxtApp.$auth0.logout();
12
+ const signup = ({ redirectTo = "/" } = {}) => nuxtApp.$auth0.loginWithRedirect({
13
+ appState: { target: redirectTo },
14
+ authorizationParams: { screen_hint: "signup" }
15
+ });
12
16
  const retry = async () => {
13
17
  isLoading.value = true;
14
18
  clear();
@@ -31,6 +35,7 @@ export const useAuth = () => {
31
35
  isLoading,
32
36
  logout,
33
37
  retry,
38
+ signup,
34
39
  token,
35
40
  user
36
41
  };
@@ -6,7 +6,7 @@ export interface NotifyOptions {
6
6
  export interface NotifyConfirmOptions extends NotifyOptions {
7
7
  cancelText?: string;
8
8
  confirmText?: string;
9
- onConfirm?: () => void;
9
+ onConfirm?: () => Promise<unknown> | unknown;
10
10
  }
11
11
  export interface NotifyAlertOptions extends NotifyOptions {
12
12
  okText?: string;
@@ -1,4 +1,5 @@
1
1
  import { push } from "notivue";
2
+ import { computed, ref } from "vue";
2
3
  import { useModal } from "bootstrap-vue-next";
3
4
  export function useNotify() {
4
5
  const $modal = useModal();
@@ -25,15 +26,27 @@ export function useNotify() {
25
26
  instance.show();
26
27
  }
27
28
  function confirm(options) {
28
- const instance = $modal.create({
29
+ const busy = ref(false);
30
+ const instance = $modal.create(computed(() => ({
29
31
  body: options.message,
32
+ busy: busy.value,
33
+ // disables both buttons
30
34
  cancelTitle: options.cancelText || "Cancel",
31
35
  cancelVariant: "secondary",
36
+ noCloseOnEsc: busy.value,
32
37
  okTitle: options.confirmText || "OK",
33
38
  okVariant: options.variant || "primary",
34
- onOk: options.onConfirm,
39
+ onHidden: () => void instance.destroy(),
40
+ // one modal per call: never pile up hidden ones
41
+ onOk: (event) => {
42
+ const result = options.onConfirm?.();
43
+ if (!(result instanceof Promise)) return;
44
+ event.preventDefault();
45
+ busy.value = true;
46
+ result.then((value) => value !== false && instance.hide("ok")).finally(() => void (busy.value = false));
47
+ },
35
48
  title: options.title || "Confirm"
36
- });
49
+ })));
37
50
  instance.show();
38
51
  }
39
52
  return { toast, alert, confirm };
@@ -53,10 +53,12 @@ export default defineNuxtPlugin(async ({ callHook, hook }) => {
53
53
  return initTenantContext();
54
54
  };
55
55
  hook("merkaly:auth:retry", () => resolveIdentity().then(startSession));
56
- auth0.handleRedirectCallback = () => self0.handleRedirectCallback().then(async ({ appState }) => {
56
+ auth0.handleRedirectCallback = () => self0.handleRedirectCallback().then(async (result) => {
57
+ const { appState } = result;
57
58
  await resolveIdentity();
58
59
  await startSession();
59
60
  void navigateTo(appState?.target || "/");
61
+ return result;
60
62
  }, (reason) => fail({ message: reason.error_description || reason.message, stage: "callback" }));
61
63
  auth0.loginWithRedirect = (options = {}) => self0.loginWithRedirect(defu(options, {
62
64
  authorizationParams: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@merkaly/nuxt",
3
- "version": "0.12.3",
3
+ "version": "0.12.5",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/merkaly-io/nuxt.git"
@@ -30,7 +30,7 @@
30
30
  "dependencies": {
31
31
  "@auth0/auth0-spa-js": "^2.30.0",
32
32
  "@bootstrap-vue-next/nuxt": "^1.0.0",
33
- "@nuxt/devtools": "^4.0.0",
33
+ "@nuxt/devtools": "^4.1.0",
34
34
  "@nuxt/eslint": "1.17.0",
35
35
  "@nuxt/eslint-config": "^1.17.0",
36
36
  "@nuxt/fonts": "1.2.0",
@@ -40,7 +40,7 @@
40
40
  "@nuxtjs/plausible": "^4.0.0",
41
41
  "@nuxtjs/robots": "^7.0.2",
42
42
  "@nuxtjs/sitemap": "^9.1.0",
43
- "@sentry/nuxt": "^11.10.0",
43
+ "@sentry/nuxt": "^11.11.0",
44
44
  "@types/node": "latest",
45
45
  "@types/vue-select": "^3.16.8",
46
46
  "@vueuse/components": "^15.0.0",
@@ -57,9 +57,9 @@
57
57
  "gsap": "^3.15.0",
58
58
  "notivue": "^2.4.5",
59
59
  "nuxt": "^4.6.3",
60
- "posthog-js": "^1.446.0",
60
+ "posthog-js": "^1.447.1",
61
61
  "reflect-metadata": "^0.2.2",
62
- "sass": "^1.107.1",
62
+ "sass": "^1.108.0",
63
63
  "typescript": "~5.9.3",
64
64
  "v-money3": "^3.26.1",
65
65
  "vite-svg-loader": "^5.1.1",