@antelopejs/dms 0.12.0 → 0.13.0

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 (109) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/README.md +1 -1
  3. package/data/dbip-country-lite.mmdb +0 -0
  4. package/data/module-store/catalog.json +0 -6
  5. package/dist/config/internal/state.d.ts +5 -0
  6. package/dist/config/internal/state.js.map +1 -1
  7. package/dist/frontend-module.js +1 -0
  8. package/dist/frontend-module.js.map +1 -1
  9. package/dist/implementations/dms/page/quick-action-access.js +4 -1
  10. package/dist/implementations/dms/page/quick-action-access.js.map +1 -1
  11. package/dist/index.js +13 -3
  12. package/dist/index.js.map +1 -1
  13. package/dist/store/operations.js +3 -1
  14. package/dist/store/operations.js.map +1 -1
  15. package/dist/store/runtime.d.ts +1 -0
  16. package/dist/store/runtime.js +9 -0
  17. package/dist/store/runtime.js.map +1 -1
  18. package/dist/utils/ensure-default-tenant.js +7 -2
  19. package/dist/utils/ensure-default-tenant.js.map +1 -1
  20. package/frontend-vue/dms.frontend.ts +2 -0
  21. package/frontend-vue/layers/dms-auth/app/build/components/AuthFormAlert.vue +1 -1
  22. package/frontend-vue/layers/dms-auth/app/build/composables/useAuthFormError.ts +11 -11
  23. package/frontend-vue/layers/dms-auth/app/composables/useOAuthErrorToast.ts +3 -8
  24. package/frontend-vue/layers/dms-auth/app/custom-pages/auth/accounts.vue +3 -2
  25. package/frontend-vue/layers/dms-auth/app/custom-pages/auth/login.vue +3 -1
  26. package/frontend-vue/layers/dms-auth/server/oauth.ts +5 -3
  27. package/frontend-vue/layers/dms-core/app/build/composables/api-error.ts +145 -0
  28. package/frontend-vue/layers/dms-core/app/build/composables/auth/usePermissionPreview.ts +5 -1
  29. package/frontend-vue/layers/dms-core/app/composables/auth/useAuthFetch.ts +2 -2
  30. package/frontend-vue/layers/dms-core/app/composables/page/useSiteLayout.ts +6 -5
  31. package/frontend-vue/layers/dms-core/app/composables/useApiError.ts +3 -10
  32. package/frontend-vue/layers/dms-core/app/composables/useApiErrorPresenter.ts +159 -0
  33. package/frontend-vue/layers/dms-core/app/composables/useFieldErrors.ts +14 -26
  34. package/frontend-vue/layers/dms-core/app/utils/composedText.ts +11 -4
  35. package/frontend-vue/layers/dms-core/app/utils/row-action-rule-evaluator.ts +8 -36
  36. package/frontend-vue/layers/dms-core/i18n/locales/core-en-GB.json +1 -0
  37. package/frontend-vue/layers/dms-core/i18n/locales/core-fr-FR.json +1 -0
  38. package/frontend-vue/layers/dms-core/shared/types/runtime-config.d.ts +6 -0
  39. package/frontend-vue/layers/dms-layout/app/build/components/layout/DashboardSidePanel.vue +3 -2
  40. package/frontend-vue/layers/dms-layout/app/build/components/layout/Footer.vue +14 -0
  41. package/frontend-vue/layers/dms-layout/app/build/components/layout/PageHeaderActionBar.vue +3 -1
  42. package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/notification/NotificationInboxDisplay.vue +3 -3
  43. package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/profile/ProfilePersonalInfo.vue +6 -6
  44. package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/security/SecurityEmail.vue +7 -9
  45. package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/security/SecurityPassword.vue +4 -4
  46. package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/security/SecuritySessions.vue +6 -7
  47. package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/security/SecurityTwoFactor.vue +2 -1
  48. package/frontend-vue/layers/dms-layout/app/build/composables/settings/profile/useAccountData.ts +14 -34
  49. package/frontend-vue/layers/dms-layout/app/build/composables/settings/security/useSecurityFormat.ts +3 -11
  50. package/frontend-vue/layers/dms-layout/app/build/utils/github-repository.ts +1 -1
  51. package/frontend-vue/layers/dms-layout/app/composables/page/useModuleStore.ts +9 -0
  52. package/frontend-vue/layers/dms-layout/app/composables/page/useModuleStoreActions.ts +13 -28
  53. package/frontend-vue/layers/dms-layout/app/custom-pages/modules-store.vue +2 -2
  54. package/frontend-vue/layers/dms-layout/app/custom-pages/settings/roles.vue +5 -2
  55. package/frontend-vue/layers/dms-layout/app/plugins/command-palette-session.client.ts +4 -4
  56. package/frontend-vue/layers/dms-layout/i18n/locales/layout-en-GB.json +2 -4
  57. package/frontend-vue/layers/dms-layout/i18n/locales/layout-fr-FR.json +2 -4
  58. package/frontend-vue/layers/dms-layout/server/render-token.ts +5 -3
  59. package/frontend-vue/layers/dms-ui/app/build/components/blocks/BlockStatus.vue +2 -1
  60. package/frontend-vue/layers/dms-ui/app/build/composables/actions/actionTargetRequests.ts +6 -10
  61. package/frontend-vue/layers/dms-ui/app/build/composables/chart/groupPoints.ts +51 -0
  62. package/frontend-vue/layers/dms-ui/app/build/composables/confirm/actionError.ts +10 -139
  63. package/frontend-vue/layers/dms-ui/app/build/composables/confirm/useActionConfirm.ts +2 -8
  64. package/frontend-vue/layers/dms-ui/app/build/composables/data-types/defaultPrimitiveTypes.ts +6 -8
  65. package/frontend-vue/layers/dms-ui/app/build/composables/form/formState.ts +2 -12
  66. package/frontend-vue/layers/dms-ui/app/build/composables/page/blockAnchors.ts +2 -3
  67. package/frontend-vue/layers/dms-ui/app/build/composables/table/useTableConfigClipboard.ts +7 -10
  68. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/tableRowActionBulkRequests.ts +11 -10
  69. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/tableRowActionValues.ts +1 -4
  70. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/tableViewGrouping.ts +9 -9
  71. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useExpandedRowDetails.ts +4 -1
  72. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableRecordLink.ts +8 -3
  73. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableRows.ts +53 -5
  74. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableViewRowActions.ts +7 -16
  75. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/utils/recordLink.ts +3 -1
  76. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/utils/writeError.ts +9 -5
  77. package/frontend-vue/layers/dms-ui/app/build/utils/fetchNotFound.ts +4 -3
  78. package/frontend-vue/layers/dms-ui/app/components/block-preview/BlockPreview.vue +6 -1
  79. package/frontend-vue/layers/dms-ui/app/components/blocks/BannerBlock.vue +3 -1
  80. package/frontend-vue/layers/dms-ui/app/components/chart/Chart.vue +4 -1
  81. package/frontend-vue/layers/dms-ui/app/components/chart/ChartCard.vue +3 -2
  82. package/frontend-vue/layers/dms-ui/app/components/confirm/ConfirmModal.vue +24 -16
  83. package/frontend-vue/layers/dms-ui/app/components/form/components/Cascader.vue +2 -6
  84. package/frontend-vue/layers/dms-ui/app/components/form/components/DatePicker.vue +2 -4
  85. package/frontend-vue/layers/dms-ui/app/components/form/components/File.vue +6 -2
  86. package/frontend-vue/layers/dms-ui/app/components/form/components/Image.vue +5 -1
  87. package/frontend-vue/layers/dms-ui/app/components/form/components/ImagePresentation.vue +2 -2
  88. package/frontend-vue/layers/dms-ui/app/components/form/components/InputAddress.vue +2 -6
  89. package/frontend-vue/layers/dms-ui/app/components/form/components/Relation.vue +2 -6
  90. package/frontend-vue/layers/dms-ui/app/components/form/components/imageItems.ts +1 -0
  91. package/frontend-vue/layers/dms-ui/app/components/guard/Guard.vue +2 -1
  92. package/frontend-vue/layers/dms-ui/app/components/table-view/KanbanBoard.vue +13 -9
  93. package/frontend-vue/layers/dms-ui/app/composables/chart/types.ts +11 -1
  94. package/frontend-vue/layers/dms-ui/app/composables/confirm/types.ts +6 -6
  95. package/frontend-vue/layers/dms-ui/app/composables/form/useForm.ts +14 -21
  96. package/frontend-vue/layers/dms-ui/app/composables/global/keyboardPlatform.ts +36 -9
  97. package/frontend-vue/layers/dms-ui/app/composables/useExportJob.ts +13 -59
  98. package/frontend-vue/layers/dms-ui/app/plugins/kbd-platform.ts +13 -0
  99. package/frontend-vue/layers/dms-ui/i18n/locales/ui-en-GB.json +1 -0
  100. package/frontend-vue/layers/dms-ui/i18n/locales/ui-fr-FR.json +1 -0
  101. package/frontend-vue/package.json +3 -5
  102. package/frontend-vue/scripts/check-api-error-presentation.mjs +242 -0
  103. package/frontend-vue/scripts/check-invariants.mjs +23 -0
  104. package/frontend-vue/scripts/codemods/api-error-presenter.mjs +196 -0
  105. package/frontend-vue/scripts/test-api-error-presentation.mjs +93 -0
  106. package/package.json +23 -7
  107. package/skills/dms-module/REFERENCE.md +3 -1
  108. package/skills/dms-module/SKILL.md +7 -2
  109. package/skills/dms-project/SKILL.md +9 -3
@@ -0,0 +1,145 @@
1
+ import { toRaw } from "vue";
2
+ import { z } from "zod";
3
+ import type {
4
+ ApiErrorParams,
5
+ ApiErrorSeverity,
6
+ } from "../../composables/useApiErrorPresenter";
7
+
8
+ const MAX_DEPTH = 8;
9
+ const HTTP_FAILURE_MIN = 400;
10
+ const HTTP_CLIENT_MAX = 499;
11
+ const HTTP_STATUS_MAX = 599;
12
+ const statusSchema = z
13
+ .number()
14
+ .int()
15
+ .min(HTTP_FAILURE_MIN)
16
+ .max(HTTP_STATUS_MAX);
17
+ const severitySchema = z.enum(["info", "warning", "error"]);
18
+ const paramsSchema = z.record(z.union([z.string(), z.number(), z.boolean()]));
19
+ const recordSchema = z.object({
20
+ cause: z.unknown().optional(),
21
+ response: z.unknown().optional(),
22
+ data: z.unknown().optional(),
23
+ _data: z.unknown().optional(),
24
+ message: z.unknown().optional(),
25
+ name: z.unknown().optional(),
26
+ severity: z.unknown().optional(),
27
+ params: z.unknown().optional(),
28
+ status: z.unknown().optional(),
29
+ statusCode: z.unknown().optional(),
30
+ body: z.unknown().optional(),
31
+ });
32
+
33
+ interface DecodedBody {
34
+ key?: string;
35
+ params?: ApiErrorParams;
36
+ severity?: ApiErrorSeverity;
37
+ status?: number;
38
+ }
39
+
40
+ /** @internal */
41
+ export interface DecodedApiFailure {
42
+ cancelled: boolean;
43
+ identity: object | null;
44
+ key?: string;
45
+ params?: ApiErrorParams;
46
+ severity: ApiErrorSeverity;
47
+ }
48
+
49
+ function record(value: unknown) {
50
+ return recordSchema.safeParse(value).data;
51
+ }
52
+
53
+ function decodeBody(input: unknown): DecodedBody {
54
+ let value = input;
55
+ let status: number | undefined;
56
+ const seen = new Set<unknown>();
57
+ for (let depth = 0; depth < MAX_DEPTH; depth++) {
58
+ if (seen.has(value)) return { status };
59
+ seen.add(value);
60
+ if (typeof value === "string") {
61
+ const text = value;
62
+ try {
63
+ const parsed: unknown = JSON.parse(text);
64
+ value = parsed;
65
+ continue;
66
+ } catch {
67
+ return { key: text, status };
68
+ }
69
+ }
70
+ const body = record(value);
71
+ if (!body) return { status };
72
+ const envelopeStatus = statusSchema.safeParse(body.status).data;
73
+ if (envelopeStatus !== undefined && body.body !== undefined) {
74
+ status ??= envelopeStatus;
75
+ value = body.body;
76
+ continue;
77
+ }
78
+ return {
79
+ key: z.string().safeParse(body.message).data,
80
+ severity: severitySchema.safeParse(body.severity).data,
81
+ params: paramsSchema.safeParse(body.params).data,
82
+ status,
83
+ };
84
+ }
85
+ return { status };
86
+ }
87
+
88
+ /** @internal */
89
+ export function decodeApiFailure(error: unknown): DecodedApiFailure {
90
+ let current = error;
91
+ let identity: object | null = null;
92
+ let request: DecodedBody | undefined;
93
+ let local: unknown = error;
94
+ let status: number | undefined;
95
+ let cancelled = false;
96
+ let hasFailedResponse = false;
97
+ let params: ApiErrorParams | undefined;
98
+ const seen = new Set<unknown>();
99
+ for (let depth = 0; depth < MAX_DEPTH && !seen.has(current); depth++) {
100
+ seen.add(current);
101
+ const entry = record(current);
102
+ if (!entry) {
103
+ local = current;
104
+ break;
105
+ }
106
+ if (current !== null && typeof current === "object")
107
+ identity = toRaw(current);
108
+ local = entry.message;
109
+ params ??= paramsSchema.safeParse(entry.params).data;
110
+ cancelled ||= entry.name === "AbortError";
111
+ const response = record(entry.response);
112
+ const responseStatus = statusSchema.safeParse(response?.status).data;
113
+ const entryStatus =
114
+ responseStatus ??
115
+ statusSchema.safeParse(entry.statusCode ?? entry.status).data;
116
+ if (
117
+ entry.response &&
118
+ responseStatus !== undefined &&
119
+ typeof entry.response === "object"
120
+ ) {
121
+ identity = toRaw(entry.response);
122
+ request = decodeBody(entry.data ?? response?._data);
123
+ status = responseStatus;
124
+ hasFailedResponse = true;
125
+ break;
126
+ }
127
+ if (entry.data !== undefined || entryStatus !== undefined) {
128
+ request = decodeBody(entry.data);
129
+ status = entryStatus;
130
+ }
131
+ if (entry.cause === undefined) break;
132
+ current = entry.cause;
133
+ }
134
+ const body = request ?? decodeBody(local);
135
+ status ??= body.status;
136
+ return {
137
+ cancelled: cancelled && !hasFailedResponse,
138
+ identity,
139
+ key: body.key,
140
+ params: { ...params, ...body.params },
141
+ severity:
142
+ body.severity ??
143
+ (status !== undefined && status <= HTTP_CLIENT_MAX ? "warning" : "error"),
144
+ };
145
+ }
@@ -155,6 +155,7 @@ function createPreviewSharing() {
155
155
  */
156
156
  export function usePermissionPreview() {
157
157
  const state = usePreviewState();
158
+ const { presentError } = useApiErrorPresenter();
158
159
  const isActive = computed(() => state.session.value !== null);
159
160
  const hiddenEntries = computed(
160
161
  () => new Set(state.result.value?.hiddenEntries ?? []),
@@ -249,9 +250,12 @@ export function usePermissionPreview() {
249
250
  if (current !== refreshGeneration) return;
250
251
  state.result.value = result;
251
252
  state.failed.value = false;
252
- } catch {
253
+ } catch (error) {
253
254
  if (current !== refreshGeneration) return;
254
255
  state.failed.value = true;
256
+ presentError(error, {
257
+ fallbackKey: "page.settings.roles.preview.failed",
258
+ });
255
259
  } finally {
256
260
  if (current === refreshGeneration) state.loading.value = false;
257
261
  }
@@ -126,12 +126,12 @@ export const useAuthFetch = () => {
126
126
  ...defaults,
127
127
  ...options,
128
128
  });
129
- // oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- The wrapper forwards each call to baseFetch, whose generic response correlation inference loses; raw, native and create are attached below before it is returned.
129
+ // SAFETY: retryingFetch resolves with the response baseFetch gives for the same request, of the same response type, so the wrapper answers as AuthFetch's call signature does; raw, native and create are attached below before it is returned.
130
130
  const authFetch = ((
131
131
  request: AuthFetchArgs[0],
132
132
  options?: AuthFetchArgs[1],
133
133
  ) => retryingFetch(request, optionsOf(options))) as AuthFetch;
134
- // oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- The raw wrapper preserves the owner's generic overloads through forwarding; inference loses their response-type correlation.
134
+ // SAFETY: retryingRaw resolves with the response baseFetch.raw gives for the same request, of the same response type, so the wrapper answers as AuthFetch['raw'] does.
135
135
  authFetch.raw = ((request: AuthFetchArgs[0], options?: AuthFetchArgs[1]) =>
136
136
  retryingRaw(request, optionsOf(options))) as AuthFetch["raw"];
137
137
  authFetch.native = baseFetch.native;
@@ -209,18 +209,19 @@ const useSiteLayoutRequests = () => {
209
209
  const { pageLayoutPromises, loadingPromise } =
210
210
  useSiteLayoutRequestsInFlight();
211
211
 
212
- const SITE_LAYOUT_ERROR_MESSAGE = "Error loading siteLayout";
213
- const UNKNOWN_SITE_LAYOUT_ERROR = "Unknown error loading siteLayout";
212
+ const { resolveError } = useApiErrorPresenter();
214
213
  const HTTP_INTERNAL_ERROR = 500;
215
214
 
216
215
  // oxlint-disable-next-line anti-slop/no-unknown-parameters -- Fetch rejection values have no shared owner schema; an Error-only input would exclude malformed thrown values that intentionally use the fallback message.
217
216
  const handleSiteLayoutError = (error: unknown) => {
218
- const errorMessage =
219
- error instanceof Error ? error.message : UNKNOWN_SITE_LAYOUT_ERROR;
217
+ const errorMessage = resolveError(error, {
218
+ fallbackKey: "error.500.description",
219
+ })?.message;
220
+ if (!errorMessage) return;
220
221
  loadingError.value = errorMessage;
221
222
  showDmsError({
222
223
  statusCode: HTTP_INTERNAL_ERROR,
223
- statusMessage: SITE_LAYOUT_ERROR_MESSAGE,
224
+ statusMessage: errorMessage,
224
225
  message: errorMessage,
225
226
  });
226
227
  };
@@ -69,19 +69,12 @@ export function resolveApiErrorMessage(
69
69
  return isApiError(error) ? resolveDescription(error) : FALLBACK_ERROR_KEY;
70
70
  }
71
71
 
72
+ /** @deprecated Capture useApiErrorPresenter during setup instead. */
72
73
  export function useApiError(
73
74
  error: Parameters<typeof resolveApiErrorTitle>[0],
74
75
  options?: Options,
75
76
  ) {
76
- const toast = useToast();
77
- const { processApiMessage } = getAppTranslation();
78
-
79
- const title = options?.title || resolveApiErrorTitle(error);
80
- const description = options?.description || resolveApiErrorMessage(error);
81
-
82
- toast.add({
83
- title: processApiMessage(title),
84
- description: processApiMessage(description),
85
- color: "error",
77
+ useApiErrorPresenter().presentError(error, {
78
+ fallbackKey: options?.description || options?.title || FALLBACK_ERROR_KEY,
86
79
  });
87
80
  }
@@ -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,10 +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 translate = (key: string, params: Parameters<I18nTranslate>[1] = {}) =>
495
- t(key, params);
488
+ const { presentError, resolveError } = useApiErrorPresenter();
496
489
  const fieldIds = Object.keys(options.fields) as F[];
497
490
  const errors: Partial<Record<F, string>> = reactive({});
498
491
 
@@ -548,21 +541,20 @@ export function useFieldErrors<F extends string>(
548
541
  async function applyApiError(
549
542
  error: Parameters<typeof resolveApiErrorMessage>[0],
550
543
  ): Promise<boolean> {
551
- const { fields } = resolveFieldErrors(error, {
544
+ const { fields, hasUnmatched } = resolveFieldErrors(error, {
552
545
  fields: fieldIds,
553
546
  codes: options.codes,
554
547
  });
555
548
  if (fields.length === 0) return false;
556
549
  clear();
557
550
  for (const entry of fields) {
558
- errors[entry.field as F] = resolveApiMessage(
559
- translate,
560
- entry.message,
561
- entry.params,
562
- );
551
+ errors[entry.field as F] = resolveError(entry.message, {
552
+ fallbackKey: entry.fallbackKey ?? "dms.field_errors.invalid",
553
+ params: entry.params,
554
+ })?.message;
563
555
  }
564
556
  await focus(fields[0]!.field as F);
565
- return true;
557
+ return !hasUnmatched;
566
558
  }
567
559
  /* oxlint-enable anti-slop/require-safety-comment-for-type-assertion */
568
560
 
@@ -571,12 +563,8 @@ export function useFieldErrors<F extends string>(
571
563
  error: Parameters<typeof resolveApiErrorMessage>[0],
572
564
  toastOptions: HandleApiErrorOptions,
573
565
  ) {
574
- toast.add({
575
- title: t(toastOptions.toastTitle),
576
- description: toastOptions.toastDescription
577
- ? t(toastOptions.toastDescription)
578
- : resolveApiMessage(translate, describeApiError(error)),
579
- color: "error",
566
+ presentError(error, {
567
+ fallbackKey: toastOptions.toastDescription ?? toastOptions.toastTitle,
580
568
  });
581
569
  }
582
570
 
@@ -48,10 +48,17 @@ type ParamFormatter<Param extends ComposedTextTypedParam> = (
48
48
  context: ComposedTextContext,
49
49
  ) => string;
50
50
 
51
+ type ParamOfType<Type extends ComposedTextParamType> =
52
+ ComposedTextTypedParam extends infer Param
53
+ ? Param extends { type: infer Types }
54
+ ? Type extends Types
55
+ ? Param
56
+ : never
57
+ : never
58
+ : never;
59
+
51
60
  type ParamFormatters = {
52
- [Type in ComposedTextParamType]: ParamFormatter<
53
- Extract<ComposedTextTypedParam, { type: Type }>
54
- >;
61
+ [Type in ComposedTextParamType]: ParamFormatter<ParamOfType<Type>>;
55
62
  };
56
63
 
57
64
  const DEFAULT_MINOR_DIGITS = 2;
@@ -162,7 +169,7 @@ function formatParam(
162
169
  return String(formatNumber(param, context.locale));
163
170
  }
164
171
  if (isComposedText(param)) return composeText(param, context);
165
- // oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- ParamFormatters preserves the nominal discriminator correlation, which indexed union inference loses. A switch would change legacy lookup behavior for malformed and inherited type keys.
172
+ // SAFETY: ParamFormatters types each entry as the formatter of the variants whose type includes that key, so for a param typed ComposedTextTypedParam the entry under param.type accepts it; the indexed union loses that correlation.
166
173
  const formatter = PARAM_FORMATTERS[param.type] as
167
174
  | ParamFormatter<ComposedTextTypedParam>
168
175
  | undefined;
@@ -2,11 +2,7 @@ import { isBoolean } from "./type-check";
2
2
  import type { DataTypeFormatter } from "../composables/data-types/useDataType";
3
3
  import { get } from "@nuxt/ui/runtime/utils/index.js";
4
4
  import type { RowActionRule, RowActionConfig } from "../types/row-action";
5
- import {
6
- FIELD_KEY,
7
- FIELD_OPERATORS,
8
- LOGICAL_OPERATOR_KEYS,
9
- } from "../types/row-action-operators";
5
+ import { FIELD_KEY, FIELD_OPERATORS } from "../types/row-action-operators";
10
6
 
11
7
  type RuleEvaluator = (
12
8
  rule: RowActionRule,
@@ -18,39 +14,15 @@ type RuleDictionary = Record<
18
14
  Parameters<typeof FIELD_OPERATORS.equals.compare>[0]
19
15
  >;
20
16
 
21
- const createLogicalEvaluators = (evaluate: RuleEvaluator) => ({
22
- and: (val: RuleDictionary[string], row: Parameters<RuleEvaluator>[1]) =>
23
- // oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- Operator presence does not validate its payload. Owner rule types cannot prove dynamically indexed values; retain the unchecked every call and its existing failures.
24
- (val as RowActionRule[]).every((r) => evaluate(r, row)),
25
- or: (val: RuleDictionary[string], row: Parameters<RuleEvaluator>[1]) =>
26
- // oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- The existing rule contract assumes an array here without checking it. Inference cannot prove that assumption and guards would change malformed-input failures.
27
- (val as RowActionRule[]).some((r) => evaluate(r, row)),
28
- not: (val: RuleDictionary[string], row: Parameters<RuleEvaluator>[1]) =>
29
- // oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- Payload is not runtime-validated. Owner types cannot prove this dynamic value; preserve recursive evaluation's existing behavior.
30
- !evaluate(val as RowActionRule, row),
31
- });
32
-
33
- let cachedLogicalEvaluators: ReturnType<typeof createLogicalEvaluators>;
34
- const getLogicalEvaluators = () => {
35
- if (!cachedLogicalEvaluators) {
36
- cachedLogicalEvaluators = createLogicalEvaluators(evaluateRowActionRule);
37
- }
38
- return cachedLogicalEvaluators;
39
- };
40
-
41
17
  export const evaluateRowActionRule: RuleEvaluator = (rule, row) => {
42
- const ruleObj: RuleDictionary = rule;
43
- const logicalEvaluators = getLogicalEvaluators();
44
-
45
- for (const operator of LOGICAL_OPERATOR_KEYS) {
46
- if (operator in ruleObj) {
47
- return logicalEvaluators[operator](ruleObj[operator], row);
48
- }
49
- }
18
+ if ("and" in rule)
19
+ return rule.and.every((r) => evaluateRowActionRule(r, row));
20
+ if ("or" in rule) return rule.or.some((r) => evaluateRowActionRule(r, row));
21
+ if ("not" in rule) return !evaluateRowActionRule(rule.not, row);
50
22
 
51
- if (FIELD_KEY in ruleObj) {
52
- // oxlint-disable-next-line anti-slop/require-safety-comment-for-type-assertion -- Presence does not validate the field path. Inference leaves it unknown; adding validation would alter get's existing malformed-input behavior.
53
- const fieldValue = get(row, ruleObj[FIELD_KEY] as string);
23
+ if (FIELD_KEY in rule) {
24
+ const ruleObj: RuleDictionary = rule;
25
+ const fieldValue = get(row, rule.field);
54
26
  for (const [operator, def] of Object.entries(FIELD_OPERATORS)) {
55
27
  if (operator in ruleObj) {
56
28
  const compare = def.compare;
@@ -17,6 +17,7 @@
17
17
  }
18
18
  },
19
19
  "error": {
20
+ "read_only_demo": "Read-only demo, changes are not saved",
20
21
  "401": {
21
22
  "title": "Access denied",
22
23
  "description": "You don't have permission to access this page."
@@ -17,6 +17,7 @@
17
17
  }
18
18
  },
19
19
  "error": {
20
+ "read_only_demo": "Démo en lecture seule, les modifications ne sont pas enregistrées",
20
21
  "401": {
21
22
  "title": "Accès refusé",
22
23
  "description": "Vous n'avez pas la permission d'accéder à cette page."
@@ -4,9 +4,15 @@ interface DmsOAuthProviderDescriptor {
4
4
  icon: string;
5
5
  }
6
6
 
7
+ interface DmsLoginPrefillConfig {
8
+ email: string;
9
+ password: string;
10
+ }
11
+
7
12
  interface DmsPublicConfig {
8
13
  baseURL: string;
9
14
  homepage?: string;
15
+ loginPrefill?: DmsLoginPrefillConfig;
10
16
  mustValidateEmail?: boolean;
11
17
  clientBaseUrl?: string;
12
18
  /**
@@ -56,8 +56,9 @@ function closeSheetOnEscape(event: KeyboardEvent): void {
56
56
  }
57
57
 
58
58
  function startHandleDrag(event: PointerEvent): void {
59
- // SAFETY: The template binds this listener directly to the resize handle div, so currentTarget is that HTMLElement during dispatch.
60
- (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
59
+ const handle = event.currentTarget;
60
+ if (!(handle instanceof HTMLElement)) return;
61
+ handle.setPointerCapture(event.pointerId);
61
62
  const renderedWidth = panelEl.value?.getBoundingClientRect().width;
62
63
  startDrag(event, renderedWidth || width.value);
63
64
  }
@@ -1,5 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import type { SelectItem } from "@nuxt/ui";
3
+ import { GITHUB_REPOSITORY_URL } from "../../utils/github-repository";
3
4
 
4
5
  const EXTERNAL_LINK_PATTERN = /^https?:\/\//;
5
6
  const DEFAULT_COPYRIGHT = "Antelope";
@@ -65,6 +66,19 @@ async function onLocaleChange(code: Parameters<typeof changeLanguage>[0]) {
65
66
  </DmsLink>
66
67
  </template>
67
68
 
69
+ <a
70
+ :href="GITHUB_REPOSITORY_URL"
71
+ target="_blank"
72
+ rel="noopener noreferrer"
73
+ :aria-label="t('empty_layout.footer.github_star_label')"
74
+ :title="t('empty_layout.footer.github_star_label')"
75
+ class="border-default text-muted hover:text-highlighted hover:border-accented hover:bg-elevated inline-flex h-6 items-center gap-1.5 rounded-md border px-2 font-medium transition-colors max-sm:h-8"
76
+ >
77
+ <UIcon name="i-ph-github-logo" class="size-3.5" aria-hidden="true" />
78
+ <UIcon name="i-ph-star" class="size-3" aria-hidden="true" />
79
+ {{ t("empty_layout.footer.github_star") }}
80
+ </a>
81
+
68
82
  <USelect
69
83
  v-if="showsLocaleSwitcher"
70
84
  :model-value="locale"
@@ -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) {