@revenexx/studio-integrations 0.5.1 → 0.7.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 (171) hide show
  1. package/CHANGELOG.md +428 -0
  2. package/README.md +48 -4
  3. package/dist/module.d.mts +17 -1
  4. package/dist/module.json +1 -1
  5. package/dist/module.mjs +10 -2
  6. package/dist/runtime/components/Integrations/CredentialCreateDialog.vue +43 -41
  7. package/dist/runtime/components/Integrations/CredentialDetailDialog.vue +123 -50
  8. package/dist/runtime/components/Integrations/CredentialRevenexxSource.vue +108 -71
  9. package/dist/runtime/components/Integrations/CredentialTypePicker.vue +12 -15
  10. package/dist/runtime/components/Integrations/DeadLettersPanel.vue +18 -17
  11. package/dist/runtime/components/Integrations/EventTypeCombobox.d.vue.ts +1 -0
  12. package/dist/runtime/components/Integrations/EventTypeCombobox.vue +7 -4
  13. package/dist/runtime/components/Integrations/EventTypeCombobox.vue.d.ts +1 -0
  14. package/dist/runtime/components/Integrations/ExpressionInput.d.vue.ts +4 -0
  15. package/dist/runtime/components/Integrations/ExpressionInput.vue +67 -37
  16. package/dist/runtime/components/Integrations/ExpressionInput.vue.d.ts +4 -0
  17. package/dist/runtime/components/Integrations/GroupFrameView.vue +30 -20
  18. package/dist/runtime/components/Integrations/Layout.vue +48 -4
  19. package/dist/runtime/components/Integrations/NodeConfigDynamicField.vue +23 -23
  20. package/dist/runtime/components/Integrations/NodeConfigDynamicSchema.vue +11 -13
  21. package/dist/runtime/components/Integrations/NodeConfigField.d.vue.ts +2 -0
  22. package/dist/runtime/components/Integrations/NodeConfigField.vue +187 -93
  23. package/dist/runtime/components/Integrations/NodeConfigField.vue.d.ts +2 -0
  24. package/dist/runtime/components/Integrations/NodeInspector.d.vue.ts +6 -0
  25. package/dist/runtime/components/Integrations/NodeInspector.vue +1157 -376
  26. package/dist/runtime/components/Integrations/NodeInspector.vue.d.ts +6 -0
  27. package/dist/runtime/components/Integrations/NodeLogo.d.vue.ts +12 -0
  28. package/dist/runtime/components/Integrations/NodeLogo.vue +2 -30
  29. package/dist/runtime/components/Integrations/NodeLogo.vue.d.ts +12 -0
  30. package/dist/runtime/components/Integrations/NodeMark.d.vue.ts +15 -0
  31. package/dist/runtime/components/Integrations/NodeMark.vue +79 -0
  32. package/dist/runtime/components/Integrations/NodeMark.vue.d.ts +15 -0
  33. package/dist/runtime/components/Integrations/NodeOutputSchema.d.vue.ts +21 -0
  34. package/dist/runtime/components/Integrations/NodeOutputSchema.vue +43 -0
  35. package/dist/runtime/components/Integrations/NodeOutputSchema.vue.d.ts +21 -0
  36. package/dist/runtime/components/Integrations/NodePalette.vue +122 -55
  37. package/dist/runtime/components/Integrations/QueryBuilder.vue +46 -38
  38. package/dist/runtime/components/Integrations/RunActionsMenu.vue +38 -12
  39. package/dist/runtime/components/Integrations/RunDetailPanel.vue +114 -62
  40. package/dist/runtime/components/Integrations/RunPayloadInput.vue +27 -13
  41. package/dist/runtime/components/Integrations/RunResponseModal.vue +11 -10
  42. package/dist/runtime/components/Integrations/RunsTable.d.vue.ts +0 -1
  43. package/dist/runtime/components/Integrations/RunsTable.vue +38 -16
  44. package/dist/runtime/components/Integrations/RunsTable.vue.d.ts +0 -1
  45. package/dist/runtime/components/Integrations/SftpAccountImportDialog.vue +8 -7
  46. package/dist/runtime/components/Integrations/Sidebar.vue +14 -10
  47. package/dist/runtime/components/Integrations/StickyNoteView.d.vue.ts +14 -4
  48. package/dist/runtime/components/Integrations/StickyNoteView.vue +38 -23
  49. package/dist/runtime/components/Integrations/StickyNoteView.vue.d.ts +14 -4
  50. package/dist/runtime/components/Integrations/TemplateCard.d.vue.ts +18 -0
  51. package/dist/runtime/components/Integrations/TemplateCard.vue +119 -0
  52. package/dist/runtime/components/Integrations/TemplateCard.vue.d.ts +18 -0
  53. package/dist/runtime/components/Integrations/TemplateFlowPreview.d.vue.ts +2 -2
  54. package/dist/runtime/components/Integrations/TemplateFlowPreview.vue +16 -18
  55. package/dist/runtime/components/Integrations/TemplateFlowPreview.vue.d.ts +2 -2
  56. package/dist/runtime/components/Integrations/WorkflowContextMenu.vue +34 -33
  57. package/dist/runtime/components/Integrations/WorkflowCreateDialog.vue +52 -67
  58. package/dist/runtime/components/Integrations/WorkflowDuplicateDialog.vue +8 -7
  59. package/dist/runtime/components/Integrations/WorkflowEdgeView.d.vue.ts +18 -4
  60. package/dist/runtime/components/Integrations/WorkflowEdgeView.vue +29 -11
  61. package/dist/runtime/components/Integrations/WorkflowEdgeView.vue.d.ts +18 -4
  62. package/dist/runtime/components/Integrations/WorkflowEditor.d.vue.ts +20 -1
  63. package/dist/runtime/components/Integrations/WorkflowEditor.vue +240 -116
  64. package/dist/runtime/components/Integrations/WorkflowEditor.vue.d.ts +20 -1
  65. package/dist/runtime/components/Integrations/WorkflowHistoryPanel.vue +28 -43
  66. package/dist/runtime/components/Integrations/WorkflowIconPicker.d.vue.ts +19 -1
  67. package/dist/runtime/components/Integrations/WorkflowIconPicker.vue +17 -7
  68. package/dist/runtime/components/Integrations/WorkflowIconPicker.vue.d.ts +19 -1
  69. package/dist/runtime/components/Integrations/WorkflowNodeView.d.vue.ts +4 -0
  70. package/dist/runtime/components/Integrations/WorkflowNodeView.vue +198 -56
  71. package/dist/runtime/components/Integrations/WorkflowNodeView.vue.d.ts +4 -0
  72. package/dist/runtime/components/Integrations/WorkflowRunReplayPanel.vue +56 -59
  73. package/dist/runtime/components/Integrations/WorkflowSettingsDialog.vue +28 -21
  74. package/dist/runtime/components/Integrations/WorkflowTagsInput.d.vue.ts +1 -0
  75. package/dist/runtime/components/Integrations/WorkflowTagsInput.vue +15 -9
  76. package/dist/runtime/components/Integrations/WorkflowTagsInput.vue.d.ts +1 -0
  77. package/dist/runtime/components/Integrations/WorkflowTriggerView.vue +15 -10
  78. package/dist/runtime/components/Integrations/WorkflowVariablesDialog.vue +50 -36
  79. package/dist/runtime/components/Integrations/WorkflowsTable.vue +126 -48
  80. package/dist/runtime/composables/integrationsApi.js +4 -2
  81. package/dist/runtime/composables/useCredentialTypes.js +8 -6
  82. package/dist/runtime/composables/useIntegrationsAudit.js +4 -3
  83. package/dist/runtime/composables/useIntegrationsCredentials.js +10 -8
  84. package/dist/runtime/composables/useIntegrationsDeadLetters.js +7 -6
  85. package/dist/runtime/composables/useIntegrationsFocus.d.ts +12 -0
  86. package/dist/runtime/composables/useIntegrationsLocale.d.ts +26 -0
  87. package/dist/runtime/composables/useIntegrationsLocale.js +8 -0
  88. package/dist/runtime/composables/useIntegrationsNodes.js +7 -5
  89. package/dist/runtime/composables/useIntegrationsRealtime.d.ts +149 -0
  90. package/dist/runtime/composables/useIntegrationsRealtime.js +80 -0
  91. package/dist/runtime/composables/useIntegrationsRevisions.js +9 -7
  92. package/dist/runtime/composables/useIntegrationsRuns.d.ts +1 -1
  93. package/dist/runtime/composables/useIntegrationsRuns.js +14 -7
  94. package/dist/runtime/composables/useIntegrationsSchedules.d.ts +4 -0
  95. package/dist/runtime/composables/useIntegrationsSchedules.js +10 -3
  96. package/dist/runtime/composables/useIntegrationsSecrets.js +14 -9
  97. package/dist/runtime/composables/useIntegrationsT.d.ts +35 -0
  98. package/dist/runtime/composables/useIntegrationsT.js +9 -0
  99. package/dist/runtime/composables/useIntegrationsTemplates.d.ts +3 -3
  100. package/dist/runtime/composables/useIntegrationsTemplates.js +28 -16
  101. package/dist/runtime/composables/useIntegrationsTriggers.js +10 -8
  102. package/dist/runtime/composables/useIntegrationsWorkflows.js +13 -7
  103. package/dist/runtime/composables/useLoadGate.d.ts +17 -0
  104. package/dist/runtime/composables/useLoadGate.js +8 -0
  105. package/dist/runtime/composables/useNodeConfigResolve.js +2 -1
  106. package/dist/runtime/data/branchReorder.d.ts +37 -0
  107. package/dist/runtime/data/branchReorder.js +71 -0
  108. package/dist/runtime/data/handleId.d.ts +23 -0
  109. package/dist/runtime/data/handleId.js +6 -0
  110. package/dist/runtime/data/integrationNodeCatalog.d.ts +41 -0
  111. package/dist/runtime/data/integrationNodeCatalog.js +14 -0
  112. package/dist/runtime/data/integrationTemplates.d.ts +48 -0
  113. package/dist/runtime/data/integrationTemplates.js +21 -0
  114. package/dist/runtime/data/integrationWorkflowSamples.js +1 -1
  115. package/dist/runtime/data/nodeIcons.d.ts +2 -0
  116. package/dist/runtime/data/nodeIcons.js +73 -0
  117. package/dist/runtime/data/nodeInputData.d.ts +16 -4
  118. package/dist/runtime/data/nodeInputData.js +4 -0
  119. package/dist/runtime/data/payloadForm.d.ts +9 -2
  120. package/dist/runtime/data/payloadForm.js +29 -4
  121. package/dist/runtime/data/portTone.d.ts +17 -0
  122. package/dist/runtime/data/portTone.js +7 -0
  123. package/dist/runtime/data/webhookAuth.d.ts +18 -0
  124. package/dist/runtime/data/webhookAuth.js +19 -0
  125. package/dist/runtime/data/workflowIconCatalog.js +4 -6
  126. package/dist/runtime/i18n/messages/de.json +1207 -0
  127. package/dist/runtime/i18n/messages/en.json +1207 -0
  128. package/dist/runtime/i18n/translate.d.ts +83 -0
  129. package/dist/runtime/i18n/translate.js +41 -0
  130. package/dist/runtime/pages/integrations/audit.vue +140 -37
  131. package/dist/runtime/pages/integrations/credentials/oauth-complete.vue +9 -6
  132. package/dist/runtime/pages/integrations/credentials.vue +180 -64
  133. package/dist/runtime/pages/integrations/index.vue +91 -92
  134. package/dist/runtime/pages/integrations/runs.vue +33 -21
  135. package/dist/runtime/pages/integrations/schedules.vue +88 -23
  136. package/dist/runtime/pages/integrations/secrets.vue +57 -35
  137. package/dist/runtime/pages/integrations/templates/[slug].vue +119 -88
  138. package/dist/runtime/pages/integrations/templates/index.vue +82 -103
  139. package/dist/runtime/pages/integrations/workflows/[id].vue +344 -91
  140. package/dist/runtime/pages/integrations/workflows/index.vue +23 -5
  141. package/dist/runtime/plugins/locale.d.ts +10 -0
  142. package/dist/runtime/plugins/locale.js +6 -0
  143. package/dist/runtime/plugins/realtime.client.d.ts +32 -0
  144. package/dist/runtime/plugins/realtime.client.js +4 -0
  145. package/dist/runtime/types/integrations.d.ts +65 -6
  146. package/dist/runtime/utils/apiError.d.ts +47 -0
  147. package/dist/runtime/utils/apiError.js +32 -0
  148. package/dist/runtime/utils/cockpitSources.d.ts +153 -0
  149. package/dist/runtime/utils/cockpitSources.js +14 -0
  150. package/dist/runtime/utils/credentialState.d.ts +24 -1
  151. package/dist/runtime/utils/credentialState.js +26 -11
  152. package/dist/runtime/utils/loadGate.d.ts +61 -0
  153. package/dist/runtime/utils/loadGate.js +22 -0
  154. package/dist/runtime/utils/localize.d.ts +46 -4
  155. package/dist/runtime/utils/localize.js +34 -7
  156. package/dist/runtime/utils/notify.d.ts +59 -0
  157. package/dist/runtime/utils/notify.js +21 -0
  158. package/dist/runtime/utils/pollBudget.d.ts +72 -0
  159. package/dist/runtime/utils/pollBudget.js +17 -0
  160. package/dist/runtime/utils/revenexxCredentialSource.d.ts +26 -0
  161. package/dist/runtime/utils/revenexxCredentialSource.js +8 -0
  162. package/dist/runtime/utils/schedule.d.ts +116 -0
  163. package/dist/runtime/utils/schedule.js +219 -0
  164. package/dist/runtime/utils/statusStyle.d.ts +26 -2
  165. package/dist/runtime/utils/statusStyle.js +9 -1
  166. package/dist/runtime/utils/validationFindings.d.ts +101 -0
  167. package/dist/runtime/utils/validationFindings.js +54 -0
  168. package/dist/runtime/utils/whenIdle.d.ts +42 -0
  169. package/dist/runtime/utils/whenIdle.js +12 -0
  170. package/dist/types.d.mts +2 -6
  171. package/package.json +10 -3
@@ -3,10 +3,16 @@ import { computed, onMounted, reactive, ref } from "vue";
3
3
  import {
4
4
  RefreshCircle as Loader2,
5
5
  AddCircle as Plus,
6
- TrashBinTrash as Trash2,
6
+ TrashBinMinimalistic as TrashBin,
7
7
  TestTube as TestIcon
8
8
  } from "#solar-icons";
9
9
  import {
10
+ AlertDialog,
11
+ AlertDialogContent,
12
+ AlertDialogDescription,
13
+ AlertDialogFooter,
14
+ AlertDialogHeader,
15
+ AlertDialogTitle,
10
16
  Badge,
11
17
  Button,
12
18
  Dialog,
@@ -24,9 +30,13 @@ import {
24
30
  SelectTrigger,
25
31
  SelectValue
26
32
  } from "@revenexx/studio";
27
- import { toast } from "vue-sonner";
28
- import { credentialState } from "../../utils/credentialState";
29
- useHead({ title: "Credentials \xB7 Revenexx Cockpit" });
33
+ import { toast } from "../../utils/notify";
34
+ import { apiFieldErrors, refusalReason } from "../../utils/apiError";
35
+ import { CREDENTIAL_STATES, credentialState, credentialStateLabel } from "../../utils/credentialState";
36
+ import { hasRevenexxSource } from "../../utils/revenexxCredentialSource";
37
+ import { resolveLocalized } from "../../utils/localize";
38
+ const t = useIntegrationsT();
39
+ useHead({ title: () => t("integration.credentials.list.head.title") });
30
40
  const { credentials, loading, error, load, create, update, remove, testInline, test, oauthAuthorizeUrl } = useIntegrationsCredentials();
31
41
  const { types, load: loadTypes, bySlug } = useCredentialTypes();
32
42
  onMounted(async () => {
@@ -35,17 +45,11 @@ onMounted(async () => {
35
45
  function typeFor(slug) {
36
46
  return bySlug(slug);
37
47
  }
38
- function loc(v) {
39
- if (!v) return "";
40
- if (typeof v === "string") return v;
41
- const o = v;
42
- return o.en ?? o.de ?? Object.values(o)[0] ?? "";
43
- }
44
48
  function typeName(slug) {
45
- return loc(typeFor(slug)?.name) || slug;
49
+ return resolveLocalized(typeFor(slug)?.name) || slug;
46
50
  }
47
- function isOAuthAuthcode(t) {
48
- return t?.auth_kind === "oauth2-authcode";
51
+ function isOAuthAuthcode(t2) {
52
+ return t2?.auth_kind === "oauth2-authcode";
49
53
  }
50
54
  function formatDateTime(iso) {
51
55
  if (!iso) return "\u2014";
@@ -55,15 +59,32 @@ function formatDateTime(iso) {
55
59
  return iso;
56
60
  }
57
61
  }
58
- const columns = [
59
- { key: "system", label: "System", width: "minmax(160px,220px)", sortable: true, sortGetter: (c) => typeName(c.credential_type_slug).toLowerCase(), searchGetter: (c) => `${typeName(c.credential_type_slug)} ${c.credential_type_slug}` },
60
- { key: "name", label: "Name", sortable: true, sortGetter: (c) => c.name.toLowerCase(), searchGetter: (c) => c.name },
61
- { key: "status", label: "Status", width: "150px", sortable: true, sortGetter: (c) => credentialState(c).key },
62
- { key: "connected", label: "Connected", width: "150px", sortable: true, sortGetter: (c) => c.last_test_ok ? c.last_test_at ?? "" : "" },
63
- { key: "created", label: "Created", width: "150px", sortable: true, sortGetter: (c) => c.created_at ?? "" },
64
- { key: "updated", label: "Updated", width: "150px", sortable: true, sortGetter: (c) => c.updated_at ?? "" },
62
+ const filterTabs = computed(() => {
63
+ const systems = [...new Set(credentials.value.map((c) => c.credential_type_slug))].map((slug) => ({ value: slug, label: typeName(slug) })).sort((a, b) => a.label.localeCompare(b.label));
64
+ return [
65
+ {
66
+ key: "state",
67
+ label: t("integration.credentials.list.table.column.status"),
68
+ getter: (c) => credentialState(c).key,
69
+ options: CREDENTIAL_STATES.map((key) => ({ value: key, label: credentialStateLabel(key) }))
70
+ },
71
+ {
72
+ key: "system",
73
+ label: t("integration.credentials.list.table.column.system"),
74
+ getter: (c) => c.credential_type_slug,
75
+ options: systems
76
+ }
77
+ ];
78
+ });
79
+ const columns = computed(() => [
80
+ { key: "system", label: t("integration.credentials.list.table.column.system"), width: "minmax(160px,220px)", sortable: true, sortGetter: (c) => typeName(c.credential_type_slug).toLowerCase(), searchGetter: (c) => `${typeName(c.credential_type_slug)} ${c.credential_type_slug}` },
81
+ { key: "name", label: t("integration.credentials.list.table.column.name"), sortable: true, sortGetter: (c) => c.name.toLowerCase(), searchGetter: (c) => c.name },
82
+ { key: "status", label: t("integration.credentials.list.table.column.status"), width: "150px", sortable: true, sortGetter: (c) => credentialState(c).key },
83
+ { key: "connected", label: t("integration.credentials.list.table.column.connected"), width: "150px", sortable: true, sortGetter: (c) => c.last_test_ok ? c.last_test_at ?? "" : "" },
84
+ { key: "created", label: t("integration.credentials.list.table.column.created"), width: "150px", sortable: true, sortGetter: (c) => c.created_at ?? "" },
85
+ { key: "updated", label: t("integration.credentials.list.table.column.updated"), width: "150px", sortable: true, sortGetter: (c) => c.updated_at ?? "" },
65
86
  { key: "actions", label: "", width: "80px" }
66
- ];
87
+ ]);
67
88
  const dialogOpen = ref(false);
68
89
  const editing = ref(null);
69
90
  const pickedType = ref(null);
@@ -71,17 +92,27 @@ const form = reactive({ name: "", config: {} });
71
92
  const saving = ref(false);
72
93
  const testing = ref(false);
73
94
  const testResult = ref(null);
95
+ const saveError = ref(null);
96
+ const fieldErrors = ref({});
74
97
  const sourceActive = ref(false);
75
98
  const isEdit = computed(() => editing.value !== null);
76
- function fieldsOf(t) {
77
- return t?.fields ?? [];
99
+ function fieldsOf(t2) {
100
+ return t2?.fields ?? [];
101
+ }
102
+ function configFieldAria(key) {
103
+ return fieldErrors.value[`config.${key}`] ? { "aria-invalid": "true", "aria-describedby": `cred-field-${key}-error` } : {};
78
104
  }
79
- function openCreate(t) {
105
+ function clearRefusal() {
106
+ saveError.value = null;
107
+ fieldErrors.value = {};
108
+ }
109
+ function openCreate(t2) {
80
110
  editing.value = null;
81
- pickedType.value = t ?? null;
111
+ pickedType.value = t2 ?? null;
82
112
  form.name = "";
83
113
  form.config = {};
84
114
  testResult.value = null;
115
+ clearRefusal();
85
116
  sourceActive.value = false;
86
117
  dialogOpen.value = true;
87
118
  }
@@ -116,6 +147,7 @@ async function runTest() {
116
147
  if (!pickedType.value) return;
117
148
  testing.value = true;
118
149
  testResult.value = null;
150
+ clearRefusal();
119
151
  try {
120
152
  testResult.value = isEdit.value && editing.value ? await test(editing.value.id) : await testInline(pickedType.value.slug, buildConfigPayload());
121
153
  } finally {
@@ -125,24 +157,28 @@ async function runTest() {
125
157
  async function submit() {
126
158
  if (!pickedType.value || !form.name.trim()) return;
127
159
  saving.value = true;
160
+ clearRefusal();
128
161
  try {
129
162
  if (isEdit.value && editing.value) {
130
163
  await update(editing.value.id, { name: form.name.trim(), config: buildConfigPayload() });
131
- toast.success("Credential updated");
164
+ dialogOpen.value = false;
165
+ toast.success(t("integration.credentials.toast.updated"));
132
166
  } else {
133
167
  const created = await create({
134
168
  credential_type_slug: pickedType.value.slug,
135
169
  name: form.name.trim(),
136
170
  config: buildConfigPayload()
137
171
  });
138
- toast.success("Credential created");
172
+ dialogOpen.value = false;
173
+ toast.success(t("integration.credentials.toast.created"));
139
174
  if (isOAuthAuthcode(pickedType.value)) {
140
175
  await connect(created);
141
176
  }
142
177
  }
143
- dialogOpen.value = false;
144
178
  } catch (e) {
145
- toast.error(e?.message ?? "Could not save credential.");
179
+ fieldErrors.value = apiFieldErrors(e);
180
+ saveError.value = refusalReason(e, t("integration.credentials.error.save"));
181
+ toast.error(saveError.value);
146
182
  } finally {
147
183
  saving.value = false;
148
184
  }
@@ -157,7 +193,7 @@ async function connect(c) {
157
193
  if (ev.data?.type === "integrations-oauth-complete") {
158
194
  window.removeEventListener("message", onMessage);
159
195
  await load(true);
160
- toast.success("Connected");
196
+ toast.success(t("integration.credentials.toast.connected"));
161
197
  }
162
198
  };
163
199
  window.addEventListener("message", onMessage);
@@ -169,18 +205,25 @@ async function connect(c) {
169
205
  }
170
206
  }, 1e3);
171
207
  } catch (e) {
172
- toast.error(e?.message ?? "Could not start OAuth connect.");
208
+ toast.error(e?.message ?? t("integration.credentials.error.oauthConnect"));
173
209
  } finally {
174
210
  connecting.value = null;
175
211
  }
176
212
  }
177
- async function destroy(c) {
178
- if (!confirm(`Delete credential "${c.name}"? Workflows referencing it will fail until re-bound.`)) return;
213
+ const pendingDelete = ref(null);
214
+ const deleting = ref(false);
215
+ async function confirmDelete() {
216
+ const c = pendingDelete.value;
217
+ if (!c) return;
218
+ deleting.value = true;
179
219
  try {
180
220
  await remove(c.id);
181
- toast.success("Credential deleted");
221
+ toast.success(t("integration.credentials.toast.deleted"));
222
+ pendingDelete.value = null;
182
223
  } catch (e) {
183
- toast.error(e?.message ?? "Could not delete credential.");
224
+ toast.error(e?.message ?? t("integration.credentials.error.delete"));
225
+ } finally {
226
+ deleting.value = false;
184
227
  }
185
228
  }
186
229
  </script>
@@ -188,13 +231,13 @@ async function destroy(c) {
188
231
  <template>
189
232
  <IntegrationsLayout>
190
233
  <PageHeader
191
- title="Credentials"
192
- description="Typed, testable access data for the systems your workflows talk to — one per connection."
234
+ :title="t('integration.credentials.list.header.title')"
235
+ :description="t('integration.credentials.list.header.description')"
193
236
  >
194
237
  <template #actions>
195
238
  <Button class="h-7 text-sm" :disabled="!types.length" @click="openCreate()">
196
239
  <Plus :size="14" class="mr-1" />
197
- New credential
240
+ {{ t("integration.credentials.list.action.new") }}
198
241
  </Button>
199
242
  </template>
200
243
  </PageHeader>
@@ -209,10 +252,10 @@ async function destroy(c) {
209
252
  :columns="columns"
210
253
  :row-key="(c2) => c2.id"
211
254
  :default-sort="{ column: 'name', direction: 'asc' }"
212
- :search="{ placeholder: 'Search credentials\u2026' }"
213
- :filter="true"
255
+ :search="{ placeholder: t('integration.credentials.list.search.placeholder') }"
256
+ :filter-tabs="filterTabs"
214
257
  :loading="loading"
215
- empty-text="No credentials yet — hit “New credential” to connect your first system."
258
+ :empty-text="t('integration.credentials.list.empty', { action: t('integration.credentials.list.action.new') })"
216
259
  @row-click="(c2) => openEdit(c2)"
217
260
  >
218
261
  <template #cell-system="{ row: c }">
@@ -227,11 +270,15 @@ async function destroy(c) {
227
270
  </template>
228
271
 
229
272
  <template #cell-status="{ row: c }">
273
+ <!-- The name is part of the accessible label: on its own, "Connected"
274
+ says nothing about *which* credential is, and collides with the
275
+ column header of the same name. -->
230
276
  <Badge
231
277
  variant="outline"
232
278
  class="border text-xs font-medium uppercase"
233
279
  :class="credentialState(c).toneClass"
234
280
  :title="credentialState(c).message ?? void 0"
281
+ :aria-label="`${c.name}: ${credentialState(c).label}`"
235
282
  >
236
283
  {{ credentialState(c).label }}
237
284
  </Badge>
@@ -259,12 +306,13 @@ async function destroy(c) {
259
306
  size="sm"
260
307
  class="h-7 text-amber-600"
261
308
  :disabled="connecting === c.id"
309
+ :aria-label="t('integration.credentials.list.action.reconnect.aria', { name: c.name })"
262
310
  @click="connect(c)"
263
311
  >
264
- Reconnect
312
+ {{ t("integration.credentials.list.action.reconnect") }}
265
313
  </Button>
266
- <button type="button" class="flex size-7 items-center justify-center rounded text-foreground/40 hover:text-rose-600 hover:bg-rose-500/10" title="Delete" @click="destroy(c)">
267
- <Trash2 :size="13" />
314
+ <button type="button" class="flex size-7 items-center justify-center rounded text-foreground/40 hover:text-rose-600 hover:bg-rose-500/10" :title="t('integration.credentials.list.action.delete.aria', { name: c.name })" :aria-label="t('integration.credentials.list.action.delete.aria', { name: c.name })" @click="pendingDelete = c">
315
+ <TrashBin :size="13" />
268
316
  </button>
269
317
  </div>
270
318
  </template>
@@ -275,9 +323,9 @@ async function destroy(c) {
275
323
  <Dialog v-model:open="dialogOpen">
276
324
  <DialogContent class="sm:max-w-lg">
277
325
  <DialogHeader>
278
- <DialogTitle>{{ isEdit ? "Edit credential" : "New credential" }}</DialogTitle>
326
+ <DialogTitle>{{ isEdit ? t("integration.credentials.shared.dialog.title.edit") : t("integration.credentials.shared.dialog.title.create") }}</DialogTitle>
279
327
  <DialogDescription class="text-sm">
280
- {{ isEdit ? "Update the credential. Leave secret fields blank to keep the current value." : "Pick a type and fill in its access data." }}
328
+ {{ isEdit ? t("integration.credentials.list.dialog.description.edit") : t("integration.credentials.list.dialog.description.create") }}
281
329
  </DialogDescription>
282
330
  </DialogHeader>
283
331
 
@@ -285,50 +333,71 @@ async function destroy(c) {
285
333
  <IntegrationsCredentialTypePicker
286
334
  v-if="!pickedType"
287
335
  :types="types"
288
- @pick="(t) => {
289
- pickedType = t;
290
- form.name = loc(t.name);
336
+ @pick="(picked) => {
337
+ pickedType = picked;
338
+ form.name = resolveLocalized(picked.name);
339
+ clearRefusal();
291
340
  }"
292
341
  />
293
342
 
294
343
  <!-- Step 2: dynamic form -->
295
344
  <div v-else class="space-y-3 py-1 max-h-[28rem] overflow-y-auto pr-1">
296
345
  <div class="space-y-1.5">
297
- <Label for="cred-name">Name</Label>
298
- <Input id="cred-name" v-model="form.name" class="bg-white" placeholder="e.g. Production SMTP" />
346
+ <Label for="cred-name">{{ t("integration.credentials.shared.field.name.label") }}</Label>
347
+ <Input
348
+ id="cred-name"
349
+ v-model="form.name"
350
+ class="bg-white"
351
+ :placeholder="t('integration.credentials.list.dialog.field.name.placeholder')"
352
+ :aria-invalid="fieldErrors.name ? 'true' : void 0"
353
+ :aria-describedby="fieldErrors.name ? 'cred-name-error' : void 0"
354
+ />
355
+ <p v-if="fieldErrors.name" id="cred-name-error" class="text-sm text-rose-700">
356
+ {{ fieldErrors.name }}
357
+ </p>
299
358
  </div>
300
359
 
301
- <!-- "Use revenexx …" source picker (SFTP / Mail / API key) -->
360
+ <!-- "Use revenexx …" source picker (SFTP / Mail / API key) — mounted
361
+ only for the types it serves: it resolves cockpit-only sources at
362
+ setup, so mounting it elsewhere would take this form down. -->
302
363
  <IntegrationsCredentialRevenexxSource
364
+ v-if="hasRevenexxSource(pickedType?.slug)"
303
365
  :type="pickedType"
304
366
  @update:active="(v) => sourceActive = v"
305
367
  @apply="(c2) => Object.assign(form.config, c2)"
306
368
  />
307
369
 
370
+ <!-- Each label names its own control (`for`/`id`): without it a screen
371
+ reader reads the field as unlabelled, and the label text cannot be
372
+ used to reach it at all. -->
308
373
  <div v-for="f in fieldsOf(pickedType)" v-show="!sourceActive" :key="f.key" class="space-y-1.5">
309
- <Label>{{ loc(f.label) }}{{ f.required ? " *" : "" }}</Label>
374
+ <Label :for="`cred-field-${f.key}`">{{ resolveLocalized(f.label) }}{{ f.required ? " *" : "" }}</Label>
310
375
 
311
376
  <!-- secret -->
312
377
  <Input
313
378
  v-if="f.type === 'secret'"
379
+ :id="`cred-field-${f.key}`"
314
380
  :model-value="form.config[f.key] ?? ''"
315
381
  type="password"
316
382
  autocomplete="new-password"
317
- :placeholder="isEdit ? 'Leave blank to keep current' : ''"
383
+ :placeholder="isEdit ? t('integration.credentials.shared.field.secret.placeholder') : ''"
318
384
  class="bg-white"
385
+ v-bind="configFieldAria(f.key)"
319
386
  @update:model-value="(v) => form.config[f.key] = v"
320
387
  />
321
388
  <!-- number -->
322
389
  <Input
323
390
  v-else-if="f.type === 'number'"
391
+ :id="`cred-field-${f.key}`"
324
392
  type="number"
325
393
  :model-value="form.config[f.key] ?? ''"
326
394
  class="bg-white"
395
+ v-bind="configFieldAria(f.key)"
327
396
  @update:model-value="(v) => form.config[f.key] = v === '' ? void 0 : Number(v)"
328
397
  />
329
398
  <!-- boolean -->
330
399
  <div v-else-if="f.type === 'boolean'" class="flex items-center gap-2 pt-1">
331
- <Switch :model-value="Boolean(form.config[f.key])" @update:model-value="(v) => form.config[f.key] = v" />
400
+ <Switch :id="`cred-field-${f.key}`" :model-value="Boolean(form.config[f.key])" v-bind="configFieldAria(f.key)" @update:model-value="(v) => form.config[f.key] = v" />
332
401
  </div>
333
402
  <!-- select -->
334
403
  <Select
@@ -336,26 +405,36 @@ async function destroy(c) {
336
405
  :model-value="form.config[f.key] ?? ''"
337
406
  @update:model-value="(v) => form.config[f.key] = v"
338
407
  >
339
- <SelectTrigger class="bg-white">
340
- <SelectValue placeholder="Pick one" />
408
+ <SelectTrigger :id="`cred-field-${f.key}`" class="bg-white" v-bind="configFieldAria(f.key)">
409
+ <SelectValue :placeholder="t('integration.credentials.shared.field.select.placeholder')" />
341
410
  </SelectTrigger>
342
411
  <SelectContent>
343
412
  <SelectItem v-for="opt in f.options ?? []" :key="String(opt.value)" :value="String(opt.value)">
344
- {{ loc(opt.label) }}
413
+ {{ resolveLocalized(opt.label) }}
345
414
  </SelectItem>
346
415
  </SelectContent>
347
416
  </Select>
348
417
  <!-- string (default) -->
349
418
  <Input
350
419
  v-else
420
+ :id="`cred-field-${f.key}`"
351
421
  :model-value="form.config[f.key] ?? ''"
352
422
  class="bg-white"
423
+ v-bind="configFieldAria(f.key)"
353
424
  @update:model-value="(v) => form.config[f.key] = v"
354
425
  />
426
+
427
+ <!-- The service keys a config refusal as `config.<key>`, which is this
428
+ field. Same rule as the name above: the message belongs where the
429
+ input is, not only in the summary, and the id is what the control's
430
+ `aria-describedby` points at. -->
431
+ <p v-if="fieldErrors[`config.${f.key}`]" :id="`cred-field-${f.key}-error`" class="text-sm text-rose-700">
432
+ {{ fieldErrors[`config.${f.key}`] }}
433
+ </p>
355
434
  </div>
356
435
 
357
436
  <p v-if="isOAuthAuthcode(pickedType) && !isEdit" class="text-sm text-muted-foreground">
358
- After saving, a window opens to connect with the provider.
437
+ {{ t("integration.credentials.shared.oauthHint") }}
359
438
  </p>
360
439
 
361
440
  <div
@@ -363,30 +442,67 @@ async function destroy(c) {
363
442
  class="rounded-md px-3 py-2 text-sm"
364
443
  :class="testResult.ok ? 'bg-emerald-500/10 text-emerald-700' : 'bg-rose-500/10 text-rose-700'"
365
444
  >
366
- {{ testResult.ok ? "Connection ok." : testResult.error ?? "Connection failed." }}
445
+ {{ testResult.ok ? t("integration.credentials.shared.test.ok") : testResult.error ?? t("integration.credentials.shared.test.failed") }}
367
446
  </div>
447
+
448
+ </div>
449
+
450
+ <!-- The save was refused. Outside the scrolling form on purpose: the Save
451
+ button sits in the footer below, so a summary at the end of a form that
452
+ scrolls — five fields already do — would be a refusal rendered under the
453
+ fold, which is the defect this whole change is about. Stated here as well
454
+ as at the field it belongs to, so a refusal with no field — a conflict, a
455
+ gateway error — is not silent either. -->
456
+ <div v-if="saveError && pickedType" role="alert" class="rounded-md bg-rose-500/10 px-3 py-2 text-sm text-rose-700">
457
+ {{ saveError }}
368
458
  </div>
369
459
 
370
460
  <DialogFooter v-if="pickedType" class="sm:justify-between">
371
- <Button v-if="!isEdit" variant="ghost" size="sm" :disabled="saving || testing" @click="pickedType = null">
372
- Back
461
+ <Button v-if="!isEdit" variant="ghost" size="sm" :disabled="saving || testing" @click="pickedType = null;
462
+ clearRefusal()">
463
+ {{ t("integration.credentials.list.dialog.action.back") }}
373
464
  </Button>
374
465
  <span v-else />
375
466
  <div class="flex items-center gap-2">
376
467
  <Button variant="outline" :disabled="saving || testing" @click="runTest">
377
468
  <Loader2 v-if="testing" :size="14" class="mr-1 animate-spin" />
378
469
  <TestIcon v-else :size="14" class="mr-1" />
379
- Connect
470
+ {{ t("integration.credentials.shared.action.testConnection") }}
380
471
  </Button>
381
472
  <Button :disabled="saving || !form.name.trim()" @click="submit">
382
473
  <Loader2 v-if="saving" :size="14" class="mr-1 animate-spin" />
383
- Save
474
+ {{ t("integration.common.action.save") }}
384
475
  </Button>
385
476
  </div>
386
477
  </DialogFooter>
387
478
  </DialogContent>
388
479
  </Dialog>
389
480
 
481
+ <AlertDialog :open="!!pendingDelete" @update:open="(v) => {
482
+ if (!v) pendingDelete = null;
483
+ }">
484
+ <AlertDialogContent>
485
+ <AlertDialogHeader>
486
+ <AlertDialogTitle>{{ t("integration.credentials.shared.confirm.delete.title") }}</AlertDialogTitle>
487
+ <AlertDialogDescription>
488
+ <span v-if="pendingDelete">
489
+ <span class="font-medium text-foreground">{{ pendingDelete.name }}</span>
490
+ {{ t("integration.credentials.shared.confirm.delete.body") }}
491
+ </span>
492
+ </AlertDialogDescription>
493
+ </AlertDialogHeader>
494
+ <AlertDialogFooter>
495
+ <Button variant="outline" :disabled="deleting" @click="pendingDelete = null">
496
+ {{ t("integration.common.action.cancel") }}
497
+ </Button>
498
+ <Button class="bg-destructive text-white hover:bg-destructive/90" :disabled="deleting" @click="confirmDelete">
499
+ <Loader2 v-if="deleting" :size="14" class="animate-spin" />
500
+ {{ deleting ? t("integration.common.state.deleting") : t("integration.common.action.delete") }}
501
+ </Button>
502
+ </AlertDialogFooter>
503
+ </AlertDialogContent>
504
+ </AlertDialog>
505
+
390
506
  <!-- Detail / edit view (single-page: connection state + fields + metadata) -->
391
507
  <IntegrationsCredentialDetailDialog
392
508
  v-model:open="detailOpen"