@antelopejs/dms 0.12.1 → 0.13.1

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.
Files changed (81) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/data/dbip-country-lite.mmdb +0 -0
  3. package/data/module-store/catalog.json +0 -6
  4. package/dist/index.js +13 -3
  5. package/dist/index.js.map +1 -1
  6. package/dist/utils/ensure-default-tenant.js +7 -2
  7. package/dist/utils/ensure-default-tenant.js.map +1 -1
  8. package/frontend-vue/dms.frontend.ts +2 -0
  9. package/frontend-vue/layers/dms-auth/app/build/components/AuthFormAlert.vue +1 -1
  10. package/frontend-vue/layers/dms-auth/app/build/composables/useAuthFormError.ts +11 -14
  11. package/frontend-vue/layers/dms-auth/app/composables/useOAuthErrorToast.ts +3 -8
  12. package/frontend-vue/layers/dms-auth/app/custom-pages/auth/accounts.vue +3 -2
  13. package/frontend-vue/layers/dms-core/app/build/composables/api-error.ts +145 -0
  14. package/frontend-vue/layers/dms-core/app/build/composables/auth/usePermissionPreview.ts +5 -1
  15. package/frontend-vue/layers/dms-core/app/composables/auth/useAuthFetch.ts +0 -2
  16. package/frontend-vue/layers/dms-core/app/composables/page/useSiteLayout.ts +6 -5
  17. package/frontend-vue/layers/dms-core/app/composables/useApiError.ts +3 -11
  18. package/frontend-vue/layers/dms-core/app/composables/useApiErrorPresenter.ts +159 -0
  19. package/frontend-vue/layers/dms-core/app/composables/useFieldErrors.ts +14 -29
  20. package/frontend-vue/layers/dms-layout/app/build/components/layout/PageHeaderActionBar.vue +3 -1
  21. package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/notification/NotificationInboxDisplay.vue +3 -3
  22. package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/profile/ProfilePersonalInfo.vue +4 -4
  23. package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/security/SecurityEmail.vue +7 -9
  24. package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/security/SecurityPassword.vue +4 -4
  25. package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/security/SecuritySessions.vue +6 -7
  26. package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/security/SecurityTwoFactor.vue +2 -1
  27. package/frontend-vue/layers/dms-layout/app/build/composables/settings/profile/useAccountData.ts +14 -34
  28. package/frontend-vue/layers/dms-layout/app/build/composables/settings/security/useSecurityFormat.ts +3 -11
  29. package/frontend-vue/layers/dms-layout/app/composables/page/useModuleStore.ts +9 -0
  30. package/frontend-vue/layers/dms-layout/app/composables/page/useModuleStoreActions.ts +13 -28
  31. package/frontend-vue/layers/dms-layout/app/custom-pages/modules-store.vue +2 -2
  32. package/frontend-vue/layers/dms-layout/app/custom-pages/settings/roles.vue +5 -2
  33. package/frontend-vue/layers/dms-layout/app/plugins/command-palette-session.client.ts +4 -4
  34. package/frontend-vue/layers/dms-layout/i18n/locales/layout-en-GB.json +0 -4
  35. package/frontend-vue/layers/dms-layout/i18n/locales/layout-fr-FR.json +0 -4
  36. package/frontend-vue/layers/dms-ui/app/build/components/blocks/BlockStatus.vue +2 -1
  37. package/frontend-vue/layers/dms-ui/app/build/composables/actions/actionTargetRequests.ts +6 -10
  38. package/frontend-vue/layers/dms-ui/app/build/composables/chart/groupPoints.ts +13 -2
  39. package/frontend-vue/layers/dms-ui/app/build/composables/confirm/actionError.ts +10 -139
  40. package/frontend-vue/layers/dms-ui/app/build/composables/confirm/useActionConfirm.ts +2 -10
  41. package/frontend-vue/layers/dms-ui/app/build/composables/form/formState.ts +2 -12
  42. package/frontend-vue/layers/dms-ui/app/build/composables/table/useTableConfigClipboard.ts +7 -10
  43. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/tableRowActionBulkRequests.ts +11 -10
  44. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/tableRowActionValues.ts +1 -4
  45. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/tableViewGrouping.ts +7 -10
  46. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useExpandedRowDetails.ts +4 -1
  47. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableRecordLink.ts +4 -1
  48. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableViewRowActions.ts +7 -18
  49. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/utils/recordLink.ts +3 -1
  50. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/utils/writeError.ts +9 -5
  51. package/frontend-vue/layers/dms-ui/app/build/utils/fetchNotFound.ts +4 -3
  52. package/frontend-vue/layers/dms-ui/app/components/block-preview/BlockPreview.vue +6 -1
  53. package/frontend-vue/layers/dms-ui/app/components/blocks/BannerBlock.vue +3 -1
  54. package/frontend-vue/layers/dms-ui/app/components/chart/ChartCard.vue +3 -2
  55. package/frontend-vue/layers/dms-ui/app/components/confirm/ConfirmModal.vue +24 -19
  56. package/frontend-vue/layers/dms-ui/app/components/form/components/Cascader.vue +2 -6
  57. package/frontend-vue/layers/dms-ui/app/components/form/components/File.vue +6 -2
  58. package/frontend-vue/layers/dms-ui/app/components/form/components/Image.vue +5 -1
  59. package/frontend-vue/layers/dms-ui/app/components/form/components/ImagePresentation.vue +2 -2
  60. package/frontend-vue/layers/dms-ui/app/components/form/components/InputAddress.vue +2 -6
  61. package/frontend-vue/layers/dms-ui/app/components/form/components/Relation.vue +2 -6
  62. package/frontend-vue/layers/dms-ui/app/components/form/components/imageItems.ts +1 -0
  63. package/frontend-vue/layers/dms-ui/app/components/guard/Guard.vue +2 -1
  64. package/frontend-vue/layers/dms-ui/app/components/table-view/KanbanBoard.vue +13 -11
  65. package/frontend-vue/layers/dms-ui/app/composables/confirm/types.ts +6 -6
  66. package/frontend-vue/layers/dms-ui/app/composables/form/useForm.ts +14 -25
  67. package/frontend-vue/layers/dms-ui/app/composables/global/keyboardPlatform.ts +36 -9
  68. package/frontend-vue/layers/dms-ui/app/composables/useExportJob.ts +13 -59
  69. package/frontend-vue/layers/dms-ui/app/plugins/kbd-platform.ts +13 -0
  70. package/frontend-vue/layers/dms-ui/i18n/locales/ui-en-GB.json +1 -0
  71. package/frontend-vue/layers/dms-ui/i18n/locales/ui-fr-FR.json +1 -0
  72. package/frontend-vue/package.json +1 -3
  73. package/frontend-vue/scripts/check-api-error-presentation.mjs +248 -0
  74. package/frontend-vue/scripts/check-invariants.mjs +23 -0
  75. package/frontend-vue/scripts/codemods/api-error-presenter.mjs +196 -0
  76. package/frontend-vue/scripts/test-api-error-presentation.mjs +134 -0
  77. package/package.json +10 -4
  78. package/skills/dms-module/REFERENCE.md +3 -1
  79. package/skills/dms-module/SKILL.md +7 -2
  80. package/skills/dms-project/SKILL.md +9 -3
  81. package/frontend-vue/layers/dms-core/app/composables/useReadOnlyRefusal.ts +0 -49
@@ -0,0 +1,159 @@
1
+ import { decodeApiFailure } from "../build/composables/api-error";
2
+
3
+ /** The supported tones for request failures. */
4
+ export type ApiErrorSeverity = "info" | "warning" | "error";
5
+
6
+ /** Localized text and tone for an inline error. */
7
+ export interface ApiErrorNotice {
8
+ readonly message: string;
9
+ readonly severity: ApiErrorSeverity;
10
+ }
11
+
12
+ /** Placeholder values accepted by error translations. */
13
+ export type ApiErrorParams = Readonly<
14
+ Record<string, string | number | boolean>
15
+ >;
16
+
17
+ /** Keys are resolved against loaded catalogs and never displayed verbatim. */
18
+ export interface ResolveApiErrorOptions {
19
+ readonly fallbackKey: string;
20
+ readonly params?: ApiErrorParams;
21
+ }
22
+
23
+ /** A contextual action whose label is also a loaded translation key. */
24
+ export interface ApiErrorAction {
25
+ readonly labelKey: string;
26
+ readonly onSelect: () => void | Promise<void>;
27
+ }
28
+
29
+ /** An existing progress notification, not the identity of a failure. */
30
+ export type ApiErrorToastId = string | number;
31
+
32
+ /** Toast presentation never overrides the server's translated reason or tone. */
33
+ export interface PresentApiErrorOptions extends ResolveApiErrorOptions {
34
+ readonly actions?: readonly ApiErrorAction[];
35
+ readonly replaceToastId?: ApiErrorToastId;
36
+ }
37
+
38
+ /** Captured during setup and callable after awaits. */
39
+ export interface ApiErrorPresenter {
40
+ resolveError(
41
+ error: unknown,
42
+ options: ResolveApiErrorOptions,
43
+ ): ApiErrorNotice | null;
44
+ presentError(error: unknown, options: PresentApiErrorOptions): void;
45
+ }
46
+
47
+ const GENERIC_KEY = "error.500.description";
48
+ const PRESENTED_STATE_KEY = "dms.apiErrors.presentedResponses";
49
+ const FAILURE_DURATION_MS = 5000;
50
+ const ICONS: Record<ApiErrorSeverity, string> = {
51
+ info: "i-ph-info",
52
+ warning: "i-ph-warning",
53
+ error: "i-ph-warning-circle",
54
+ };
55
+
56
+ function isCompiledMessage(value: unknown): boolean {
57
+ return (
58
+ value !== null &&
59
+ typeof value === "object" &&
60
+ (("type" in value && value.type === 0) || ("t" in value && value.t === 0))
61
+ );
62
+ }
63
+
64
+ /**
65
+ * Resolves errors without side effects and presents at most one toast per failed
66
+ * Response in this app. Aborts without a failed response are silent.
67
+ */
68
+ export function useApiErrorPresenter(): ApiErrorPresenter {
69
+ const toast = useToast();
70
+ const { i18n } = getPluginI18n();
71
+ const presented = useDmsState(
72
+ PRESENTED_STATE_KEY,
73
+ () => new WeakMap<object, ApiErrorToastId | null>(),
74
+ );
75
+
76
+ function lookup(
77
+ key: string | undefined,
78
+ params: ApiErrorParams = {},
79
+ ): string | null {
80
+ if (!key) return null;
81
+ try {
82
+ const message = i18n.tm(key.startsWith("$") ? key.slice(1) : key);
83
+ if (
84
+ typeof message !== "string" &&
85
+ typeof message !== "function" &&
86
+ !isCompiledMessage(message)
87
+ )
88
+ return null;
89
+ return i18n.rt(message, params);
90
+ } catch {
91
+ return null;
92
+ }
93
+ }
94
+
95
+ function noticeOf(
96
+ failure: ReturnType<typeof decodeApiFailure>,
97
+ options: ResolveApiErrorOptions,
98
+ ): ApiErrorNotice | null {
99
+ if (failure.cancelled) return null;
100
+ const params = { ...failure.params, ...options.params };
101
+ const message =
102
+ lookup(failure.key, params) ??
103
+ lookup(options.fallbackKey, params) ??
104
+ lookup(GENERIC_KEY);
105
+ if (message === null)
106
+ throw new Error(
107
+ "The core error.500.description translation must be loaded",
108
+ );
109
+ return { message, severity: failure.severity };
110
+ }
111
+
112
+ function resolveError(
113
+ error: unknown,
114
+ options: ResolveApiErrorOptions,
115
+ ): ApiErrorNotice | null {
116
+ return noticeOf(decodeApiFailure(error), options);
117
+ }
118
+
119
+ function presentError(error: unknown, options: PresentApiErrorOptions): void {
120
+ const failure = decodeApiFailure(error);
121
+ const notice = noticeOf(failure, options);
122
+ if (import.meta.env.SSR) return;
123
+ const replacement = options.replaceToastId;
124
+ const identity = failure.identity;
125
+ if (!notice || (identity && presented.value.has(identity))) {
126
+ if (
127
+ replacement !== undefined &&
128
+ (!identity || presented.value.get(identity) !== replacement)
129
+ )
130
+ toast.remove(replacement);
131
+ return;
132
+ }
133
+ const payload = {
134
+ title: notice.message,
135
+ description: undefined,
136
+ color: notice.severity,
137
+ icon: ICONS[notice.severity],
138
+ duration: FAILURE_DURATION_MS,
139
+ progress: true,
140
+ actions: options.actions?.flatMap((action) => {
141
+ const label = lookup(action.labelKey);
142
+ return label === null ? [] : [{ label, onClick: action.onSelect }];
143
+ }),
144
+ };
145
+ if (identity) presented.value.set(identity, replacement ?? null);
146
+ try {
147
+ if (replacement !== undefined) toast.update(replacement, payload);
148
+ else {
149
+ const added = toast.add(payload);
150
+ if (identity) presented.value.set(identity, added.id);
151
+ }
152
+ } catch (error) {
153
+ if (identity) presented.value.delete(identity);
154
+ throw error;
155
+ }
156
+ }
157
+
158
+ return { presentError, resolveError };
159
+ }
@@ -1,8 +1,5 @@
1
1
  import { nextTick, reactive } from "vue";
2
- import {
3
- type I18nTranslate,
4
- resolveApiMessage,
5
- } from "./translation/useTranslation";
2
+ import { type I18nTranslate } from "./translation/useTranslation";
6
3
  import { resolveApiErrorMessage } from "./useApiError";
7
4
  import { isBoolean, isNumber, isObject, isString } from "../utils/type-check";
8
5
 
@@ -19,11 +16,10 @@ import { isBoolean, isNumber, isObject, isString } from "../utils/type-check";
19
16
  export interface ApiFieldError {
20
17
  /** Id of the field, as the form names it (the request body key). */
21
18
  field: string;
22
- /**
23
- * What to show under the field: an i18n key (bare `error.xxx` or `$key`)
24
- * or prose, resolved with `resolveApiMessage`.
25
- */
19
+ /** Server translation key, resolved by the API error presenter. */
26
20
  message: string;
21
+ /** Caller key used only when the server's message is not translated. */
22
+ fallbackKey?: string;
27
23
  /** The values of the field the error names (addresses of a list…). */
28
24
  values?: string[];
29
25
  /**
@@ -325,7 +321,7 @@ function lookupCode<F extends string>(
325
321
  if (!entry) return undefined;
326
322
  return isString(entry)
327
323
  ? { field: entry, message: code }
328
- : { field: entry.field, message: entry.message };
324
+ : { field: entry.field, message: code, fallbackKey: entry.message };
329
325
  }
330
326
 
331
327
  function fromBody(body: ReturnType<typeof bodyOf>): ApiFieldError[] {
@@ -489,11 +485,7 @@ export interface HandleApiErrorOptions {
489
485
  export function useFieldErrors<F extends string>(
490
486
  options: UseFieldErrorsOptions<F>,
491
487
  ) {
492
- const { t } = useI18n();
493
- const toast = useToast();
494
- const consumeReadOnlyRefusal = useReadOnlyRefusal();
495
- const translate = (key: string, params: Parameters<I18nTranslate>[1] = {}) =>
496
- t(key, params);
488
+ const { presentError, resolveError } = useApiErrorPresenter();
497
489
  const fieldIds = Object.keys(options.fields) as F[];
498
490
  const errors: Partial<Record<F, string>> = reactive({});
499
491
 
@@ -549,21 +541,20 @@ export function useFieldErrors<F extends string>(
549
541
  async function applyApiError(
550
542
  error: Parameters<typeof resolveApiErrorMessage>[0],
551
543
  ): Promise<boolean> {
552
- const { fields } = resolveFieldErrors(error, {
544
+ const { fields, hasUnmatched } = resolveFieldErrors(error, {
553
545
  fields: fieldIds,
554
546
  codes: options.codes,
555
547
  });
556
548
  if (fields.length === 0) return false;
557
549
  clear();
558
550
  for (const entry of fields) {
559
- errors[entry.field as F] = resolveApiMessage(
560
- translate,
561
- entry.message,
562
- entry.params,
563
- );
551
+ errors[entry.field as F] = resolveError(entry.message, {
552
+ fallbackKey: entry.fallbackKey ?? "dms.field_errors.invalid",
553
+ params: entry.params,
554
+ })?.message;
564
555
  }
565
556
  await focus(fields[0]!.field as F);
566
- return true;
557
+ return !hasUnmatched;
567
558
  }
568
559
  /* oxlint-enable anti-slop/require-safety-comment-for-type-assertion */
569
560
 
@@ -572,13 +563,8 @@ export function useFieldErrors<F extends string>(
572
563
  error: Parameters<typeof resolveApiErrorMessage>[0],
573
564
  toastOptions: HandleApiErrorOptions,
574
565
  ) {
575
- if (consumeReadOnlyRefusal(error)) return;
576
- toast.add({
577
- title: t(toastOptions.toastTitle),
578
- description: toastOptions.toastDescription
579
- ? t(toastOptions.toastDescription)
580
- : resolveApiMessage(translate, describeApiError(error)),
581
- color: "error",
566
+ presentError(error, {
567
+ fallbackKey: toastOptions.toastDescription ?? toastOptions.toastTitle,
582
568
  });
583
569
  }
584
570
 
@@ -591,7 +577,6 @@ export function useFieldErrors<F extends string>(
591
577
  error: Parameters<typeof resolveApiErrorMessage>[0],
592
578
  toastOptions: HandleApiErrorOptions,
593
579
  ): Promise<boolean> {
594
- if (consumeReadOnlyRefusal(error)) return false;
595
580
  if (await applyApiError(error)) return true;
596
581
  toastError(error, toastOptions);
597
582
  return false;
@@ -75,13 +75,15 @@ const { quickActions } = siteLayout;
75
75
  const route = useDmsRoute();
76
76
  const router = useDmsRouter();
77
77
  const { $authFetch } = useAuthFetch();
78
+ const { presentError } = useApiErrorPresenter();
78
79
 
79
80
  const { handleCustomButton } = useActionTargets({
80
81
  api: $authFetch,
81
82
  componentId: "page-header",
82
83
  pageId: siteLayout.findMatchingRoute(route.path)?.metadata?.fullId ?? "page",
83
84
  refreshCallback: refreshPageBlocks,
84
- handleApiError: (error, title) => useApiError(error, { title }),
85
+ handleApiError: (error, title) =>
86
+ presentError(error, { fallbackKey: title ?? "error.500.description" }),
85
87
  });
86
88
 
87
89
  function pressComponentButton(componentId: string, buttonId?: string) {
@@ -27,7 +27,7 @@ const props = defineProps<NotificationInboxDisplayProps>();
27
27
  const SKELETON_ROWS = 3;
28
28
 
29
29
  const { t, locale } = useI18n();
30
- const toast = useToast();
30
+ const { presentError } = useApiErrorPresenter();
31
31
  const { confirm } = useConfirm();
32
32
  const catalog = useNotificationCatalog();
33
33
  const {
@@ -70,8 +70,8 @@ const runThenRefresh = async (action: () => ReturnType<typeof markAsRead>) => {
70
70
  await action();
71
71
 
72
72
  await nextTick();
73
- } catch {
74
- toast.add({ title: t("dms.form.error_title"), color: "error" });
73
+ } catch (error) {
74
+ presentError(error, { fallbackKey: "dms.form.error_title" });
75
75
  } finally {
76
76
  ownChanges--;
77
77
  }
@@ -44,6 +44,7 @@ const EMAIL_ANCHOR = "#email";
44
44
 
45
45
  const { t } = useI18n();
46
46
  const toast = useToast();
47
+ const { presentError } = useApiErrorPresenter();
47
48
  const { $authFetch } = useAuthFetch();
48
49
  const { processApiMessage } = useTranslation();
49
50
  const { user, refresh: refreshSession } = useCurrentUser();
@@ -130,10 +131,9 @@ async function onFileChange(event: Event): Promise<void> {
130
131
  }
131
132
  avatar.value = { key: result.key! };
132
133
  pickedAvatarUrl.value = URL.createObjectURL(file);
133
- } catch {
134
- toast.add({
135
- title: t("page.settings.profile.avatar_error"),
136
- color: "error",
134
+ } catch (error) {
135
+ presentError(error, {
136
+ fallbackKey: "page.settings.profile.avatar_error",
137
137
  });
138
138
  } finally {
139
139
  isUploading.value = false;
@@ -38,6 +38,7 @@ const FIELD_CODES = {
38
38
 
39
39
  const { t } = useI18n();
40
40
  const toast = useToast();
41
+ const { presentError } = useApiErrorPresenter();
41
42
  const { $authFetch } = useAuthFetch();
42
43
  const { refresh: refreshSession } = useCurrentUser();
43
44
  const { overview, refresh } = useSecurityOverview();
@@ -180,9 +181,8 @@ async function submit(): Promise<void> {
180
181
  isCodeOpen.value = true;
181
182
  } catch (error) {
182
183
  if (await flagField(error)) return;
183
- toast.add({
184
- title: errorMessage(error, "page.settings.security.email.error"),
185
- color: "error",
184
+ presentError(error, {
185
+ fallbackKey: "page.settings.security.email.error",
186
186
  });
187
187
  } finally {
188
188
  isSaving.value = false;
@@ -217,9 +217,8 @@ async function confirmCode(code: string): Promise<void> {
217
217
  );
218
218
  return;
219
219
  }
220
- toast.add({
221
- title: errorMessage(error, "page.settings.security.email.error"),
222
- color: "error",
220
+ presentError(error, {
221
+ fallbackKey: "page.settings.security.email.error",
223
222
  });
224
223
  if (errorCode(error) === CODE_BURNT) {
225
224
  isCodeOpen.value = false;
@@ -239,9 +238,8 @@ async function cancelChange(): Promise<void> {
239
238
  });
240
239
  await refresh();
241
240
  } catch (error) {
242
- toast.add({
243
- title: errorMessage(error, "page.settings.security.email.error"),
244
- color: "error",
241
+ presentError(error, {
242
+ fallbackKey: "page.settings.security.email.error",
245
243
  });
246
244
  }
247
245
  }
@@ -36,10 +36,11 @@ const CONFIRM_FIELD_ID = "security-confirm-password";
36
36
 
37
37
  const { t } = useI18n();
38
38
  const toast = useToast();
39
+ const { presentError } = useApiErrorPresenter();
39
40
  const { $authFetch } = useAuthFetch();
40
41
  const { processApiMessage } = useTranslation();
41
42
  const { overview, refresh } = useSecurityOverview();
42
- const { formatDate, daysSince, errorMessage } = useSecurityFormat();
43
+ const { formatDate, daysSince } = useSecurityFormat();
43
44
  const { passwordSchema } = usePasswordStrength(ref(""));
44
45
  const adoptSessionHandoff = useSessionHandoff();
45
46
 
@@ -170,9 +171,8 @@ async function submit(): Promise<void> {
170
171
  document.getElementById(CURRENT_FIELD_ID)?.focus();
171
172
  return;
172
173
  }
173
- toast.add({
174
- title: errorMessage(error, "page.settings.security.password.error"),
175
- color: "error",
174
+ presentError(error, {
175
+ fallbackKey: "page.settings.security.password.error",
176
176
  });
177
177
  } finally {
178
178
  isSaving.value = false;
@@ -50,10 +50,11 @@ const DEVICE_ICONS: Readonly<
50
50
 
51
51
  const { t } = useI18n();
52
52
  const toast = useToast();
53
+ const { presentError } = useApiErrorPresenter();
53
54
  const { $authFetch } = useAuthFetch();
54
55
  const adoptSessionHandoff = useSessionHandoff();
55
56
  const { overview, refresh: refreshOverview } = useSecurityOverview();
56
- const { formatDate, formatRelative, errorMessage } = useSecurityFormat();
57
+ const { formatDate, formatRelative } = useSecurityFormat();
57
58
 
58
59
  const list = useApiData<SessionsRead>(SESSIONS_URL, {
59
60
  transform: (sessions) => ({
@@ -106,9 +107,8 @@ async function revoke(session: SessionInfo): Promise<void> {
106
107
  toast.add({ title: t("page.settings.sessions.revoked"), color: "success" });
107
108
  await refreshOverview();
108
109
  } catch (error) {
109
- toast.add({
110
- title: errorMessage(error, "page.settings.sessions.revoke_error"),
111
- color: "error",
110
+ presentError(error, {
111
+ fallbackKey: "page.settings.sessions.revoke_error",
112
112
  });
113
113
  } finally {
114
114
  revokingId.value = null;
@@ -134,9 +134,8 @@ async function signOutOthers(): Promise<void> {
134
134
  });
135
135
  await Promise.all([fetchSessions(), refreshOverview()]);
136
136
  } catch (error) {
137
- toast.add({
138
- title: errorMessage(error, "page.settings.sessions.revoke_all_error"),
139
- color: "error",
137
+ presentError(error, {
138
+ fallbackKey: "page.settings.sessions.revoke_all_error",
140
139
  });
141
140
  } finally {
142
141
  isSigningOutOthers.value = false;
@@ -65,6 +65,7 @@ const METHODS: MethodConfig[] = [
65
65
 
66
66
  const { t } = useI18n();
67
67
  const toast = useToast();
68
+ const { presentError } = useApiErrorPresenter();
68
69
  const { $authFetch } = useAuthFetch();
69
70
  const { overview, attention, attentionToneOf, refresh } = useSecurityOverview();
70
71
  const { formatDate, errorMessage } = useSecurityFormat();
@@ -148,7 +149,7 @@ function fail(
148
149
  error: Parameters<typeof errorMessage>[0],
149
150
  fallbackKey: string,
150
151
  ): void {
151
- toast.add({ title: errorMessage(error, fallbackKey), color: "error" });
152
+ presentError(error, { fallbackKey });
152
153
  }
153
154
 
154
155
  function showCodes(
@@ -12,7 +12,6 @@ import {
12
12
  } from "#dms-ui/app/composables/confirm/types";
13
13
  import { resolveFieldErrors } from "#dms-core/app/composables/useFieldErrors";
14
14
  import { REQUIRED_MESSAGE } from "#dms-core/app/composables/useFormValidation";
15
- import { useSecurityFormat } from "../security/useSecurityFormat";
16
15
 
17
16
  const PROFILE_ENDPOINT = pageRoutePath("/settings/user/profile");
18
17
  const EXPORT_ENDPOINT = `${PROFILE_ENDPOINT}/account-export`;
@@ -63,24 +62,14 @@ function useAccountDataContext() {
63
62
  const { confirm } = useConfirm();
64
63
  const { user, clear } = useDmsUserSession<User>();
65
64
  const { removeAccount } = useMultiAccount();
66
- const { processI18n, processApiMessage } = useTranslation();
67
- const { errorMessage, errorCode } = useSecurityFormat();
65
+ const { processApiMessage } = useTranslation();
66
+ const { presentError, resolveError } = useApiErrorPresenter();
68
67
  const toast = useToast();
69
68
  const { t } = useI18n();
70
69
 
71
70
  const isExporting = ref(false);
72
71
  const isCheckingDeletion = ref(false);
73
72
 
74
- /** A message key the API answers with (`$…`), else a known error code. */
75
- function describeError(
76
- error: Parameters<typeof errorCode>[0],
77
- fallbackKey: string,
78
- ): string {
79
- const code = errorCode(error);
80
- if (code?.startsWith("$")) return processI18n(code);
81
- return errorMessage(error, fallbackKey);
82
- }
83
-
84
73
  async function exportData(): Promise<void> {
85
74
  isExporting.value = true;
86
75
  try {
@@ -97,11 +86,7 @@ function useAccountDataContext() {
97
86
  title: t(`${I18N}.export_done`),
98
87
  });
99
88
  } catch (error) {
100
- toast.add({
101
- color: "error",
102
- icon: "i-ph-warning-circle",
103
- title: describeError(error, `${I18N}.export_error`),
104
- });
89
+ presentError(error, { fallbackKey: `${I18N}.export_error` });
105
90
  } finally {
106
91
  isExporting.value = false;
107
92
  }
@@ -128,7 +113,8 @@ function useAccountDataContext() {
128
113
  t,
129
114
  isExporting,
130
115
  isCheckingDeletion,
131
- describeError,
116
+ presentError,
117
+ resolveError,
132
118
  exportData,
133
119
  signOutDeleted,
134
120
  };
@@ -260,11 +246,10 @@ export function useAccountData() {
260
246
  const {
261
247
  $authFetch,
262
248
  confirm,
263
- processApiMessage,
264
- toast,
249
+ presentError,
250
+ resolveError,
265
251
  t,
266
252
  isCheckingDeletion,
267
- describeError,
268
253
  signOutDeleted,
269
254
  } = context;
270
255
  const { impactList, passwordField, explainBlockers, passwordCheck } =
@@ -305,19 +290,18 @@ export function useAccountData() {
305
290
  },
306
291
  }).fields;
307
292
  if (fieldError?.field === "confirmation") {
308
- throw new ConfirmTextError(processApiMessage(fieldError.message));
293
+ throw new ConfirmTextError(fieldError.message, { cause: error });
309
294
  }
310
295
  if (fieldError?.field === "password") {
311
- passwordError.value = processApiMessage(fieldError.message);
296
+ passwordError.value = resolveError(fieldError.message, {
297
+ fallbackKey: fieldError.fallbackKey ?? "dms.field_errors.invalid",
298
+ params: fieldError.params,
299
+ })?.message;
312
300
  await nextTick();
313
301
  passwordInput.value?.inputRef?.focus();
314
302
  return false;
315
303
  }
316
- toast.add({
317
- color: "error",
318
- icon: "i-ph-warning-circle",
319
- title: describeError(error, `${I18N}.delete_error`),
320
- });
304
+ presentError(error, { fallbackKey: `${I18N}.delete_error` });
321
305
  return false;
322
306
  }
323
307
  }
@@ -353,11 +337,7 @@ export function useAccountData() {
353
337
  try {
354
338
  impact = await $authFetch<AccountDeletionImpact>(DELETION_ENDPOINT);
355
339
  } catch (error) {
356
- toast.add({
357
- color: "error",
358
- icon: "i-ph-warning-circle",
359
- title: describeError(error, `${I18N}.delete_error`),
360
- });
340
+ presentError(error, { fallbackKey: `${I18N}.delete_error` });
361
341
  return;
362
342
  } finally {
363
343
  isCheckingDeletion.value = false;
@@ -7,8 +7,6 @@ const MS_PER_HOUR = 60 * MS_PER_MINUTE;
7
7
  const MS_PER_DAY = 24 * MS_PER_HOUR;
8
8
  const MS_PER_MONTH = 30 * MS_PER_DAY;
9
9
  const MS_PER_YEAR = 365 * MS_PER_DAY;
10
- const ERROR_CODE_PREFIX = "error.";
11
- const SECURITY_ERRORS_PREFIX = "page.settings.security.errors.";
12
10
 
13
11
  interface RelativeUnit {
14
12
  unit: Intl.RelativeTimeFormatUnit;
@@ -35,7 +33,8 @@ type DateInput = string | Date;
35
33
  * translation of API error codes into sentences.
36
34
  */
37
35
  export function useSecurityFormat() {
38
- const { locale, t, te } = useI18n();
36
+ const { locale } = useI18n();
37
+ const { resolveError } = useApiErrorPresenter();
39
38
 
40
39
  const formatDate = (value: DateInput): string =>
41
40
  regionalDateTimeFormat(locale.value, { dateStyle: "medium" }).format(
@@ -74,14 +73,7 @@ export function useSecurityFormat() {
74
73
  */
75
74
  // oxlint-disable-next-line anti-slop/no-unknown-parameters -- JavaScript throws arbitrary values. FetchError-derived parameters would reject the boundary inputs this formatter already handles.
76
75
  const errorMessage = (error: unknown, fallbackKey: string): string => {
77
- // oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- Unchecked optional property projection preserves primitive and nullish thrown values; this is not a validated FetchError and owner typing cannot guarantee one.
78
- const code = (error as FetchErrorLike | undefined)?.data;
79
- if (!isString(code) || !code.startsWith(ERROR_CODE_PREFIX)) {
80
- return t(fallbackKey);
81
- }
82
- const ownKey = `${SECURITY_ERRORS_PREFIX}${code.slice(ERROR_CODE_PREFIX.length)}`;
83
- if (te(ownKey)) return t(ownKey);
84
- return te(code) ? t(code) : t(fallbackKey);
76
+ return resolveError(error, { fallbackKey })?.message ?? "";
85
77
  };
86
78
 
87
79
  /** @returns The error code (`error.xxx`) the API answered with, if any */
@@ -23,6 +23,7 @@ const FOLLOW_INTERVAL_MS = 1000;
23
23
  */
24
24
  export const useModuleStore = () => {
25
25
  const { $authFetch } = useAuthFetch();
26
+ const { resolveError } = useApiErrorPresenter();
26
27
  const isOwner = useIsOwner();
27
28
 
28
29
  const initial = useApiData<StoreListing>(() =>
@@ -42,6 +43,13 @@ export const useModuleStore = () => {
42
43
  ? (initial.error.value ?? null)
43
44
  : followError.value,
44
45
  );
46
+ const loadingNotice = computed(() =>
47
+ loadingError.value
48
+ ? resolveError(loadingError.value, {
49
+ fallbackKey: "modules.store.load_error",
50
+ })
51
+ : null,
52
+ );
45
53
  /** Modules an operation from this page changed, until the frontend follows. */
46
54
  const awaitingReload = ref(new Set<string>());
47
55
  let followTimer: ReturnType<typeof setTimeout> | undefined;
@@ -151,6 +159,7 @@ export const useModuleStore = () => {
151
159
  return {
152
160
  listing,
153
161
  loadingError,
162
+ loadingNotice,
154
163
  isFollowing,
155
164
  awaitingReload,
156
165
  load,