@revenexx/studio-integrations 0.5.1 → 0.6.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 (129) hide show
  1. package/CHANGELOG.md +201 -0
  2. package/README.md +17 -4
  3. package/dist/module.json +1 -1
  4. package/dist/runtime/components/Integrations/CredentialCreateDialog.vue +15 -14
  5. package/dist/runtime/components/Integrations/CredentialDetailDialog.vue +91 -19
  6. package/dist/runtime/components/Integrations/CredentialRevenexxSource.vue +58 -21
  7. package/dist/runtime/components/Integrations/CredentialTypePicker.vue +4 -8
  8. package/dist/runtime/components/Integrations/EventTypeCombobox.d.vue.ts +1 -0
  9. package/dist/runtime/components/Integrations/EventTypeCombobox.vue +4 -2
  10. package/dist/runtime/components/Integrations/EventTypeCombobox.vue.d.ts +1 -0
  11. package/dist/runtime/components/Integrations/ExpressionInput.d.vue.ts +4 -0
  12. package/dist/runtime/components/Integrations/ExpressionInput.vue +54 -25
  13. package/dist/runtime/components/Integrations/ExpressionInput.vue.d.ts +4 -0
  14. package/dist/runtime/components/Integrations/GroupFrameView.vue +12 -12
  15. package/dist/runtime/components/Integrations/Layout.vue +21 -2
  16. package/dist/runtime/components/Integrations/NodeConfigDynamicField.vue +10 -11
  17. package/dist/runtime/components/Integrations/NodeConfigDynamicSchema.vue +2 -5
  18. package/dist/runtime/components/Integrations/NodeConfigField.d.vue.ts +2 -0
  19. package/dist/runtime/components/Integrations/NodeConfigField.vue +160 -67
  20. package/dist/runtime/components/Integrations/NodeConfigField.vue.d.ts +2 -0
  21. package/dist/runtime/components/Integrations/NodeInspector.d.vue.ts +6 -0
  22. package/dist/runtime/components/Integrations/NodeInspector.vue +1075 -303
  23. package/dist/runtime/components/Integrations/NodeInspector.vue.d.ts +6 -0
  24. package/dist/runtime/components/Integrations/NodeLogo.d.vue.ts +12 -0
  25. package/dist/runtime/components/Integrations/NodeLogo.vue +2 -30
  26. package/dist/runtime/components/Integrations/NodeLogo.vue.d.ts +12 -0
  27. package/dist/runtime/components/Integrations/NodeMark.d.vue.ts +15 -0
  28. package/dist/runtime/components/Integrations/NodeMark.vue +79 -0
  29. package/dist/runtime/components/Integrations/NodeMark.vue.d.ts +15 -0
  30. package/dist/runtime/components/Integrations/NodeOutputSchema.d.vue.ts +21 -0
  31. package/dist/runtime/components/Integrations/NodeOutputSchema.vue +42 -0
  32. package/dist/runtime/components/Integrations/NodeOutputSchema.vue.d.ts +21 -0
  33. package/dist/runtime/components/Integrations/NodePalette.vue +100 -34
  34. package/dist/runtime/components/Integrations/QueryBuilder.vue +8 -1
  35. package/dist/runtime/components/Integrations/RunActionsMenu.vue +3 -1
  36. package/dist/runtime/components/Integrations/RunPayloadInput.vue +17 -4
  37. package/dist/runtime/components/Integrations/StickyNoteView.vue +2 -1
  38. package/dist/runtime/components/Integrations/TemplateCard.d.vue.ts +18 -0
  39. package/dist/runtime/components/Integrations/TemplateCard.vue +109 -0
  40. package/dist/runtime/components/Integrations/TemplateCard.vue.d.ts +18 -0
  41. package/dist/runtime/components/Integrations/TemplateFlowPreview.d.vue.ts +2 -2
  42. package/dist/runtime/components/Integrations/TemplateFlowPreview.vue +7 -10
  43. package/dist/runtime/components/Integrations/TemplateFlowPreview.vue.d.ts +2 -2
  44. package/dist/runtime/components/Integrations/WorkflowCreateDialog.vue +21 -37
  45. package/dist/runtime/components/Integrations/WorkflowEdgeView.d.vue.ts +18 -4
  46. package/dist/runtime/components/Integrations/WorkflowEdgeView.vue +23 -7
  47. package/dist/runtime/components/Integrations/WorkflowEdgeView.vue.d.ts +18 -4
  48. package/dist/runtime/components/Integrations/WorkflowEditor.d.vue.ts +20 -1
  49. package/dist/runtime/components/Integrations/WorkflowEditor.vue +167 -46
  50. package/dist/runtime/components/Integrations/WorkflowEditor.vue.d.ts +20 -1
  51. package/dist/runtime/components/Integrations/WorkflowHistoryPanel.vue +4 -20
  52. package/dist/runtime/components/Integrations/WorkflowIconPicker.d.vue.ts +19 -1
  53. package/dist/runtime/components/Integrations/WorkflowIconPicker.vue +11 -2
  54. package/dist/runtime/components/Integrations/WorkflowIconPicker.vue.d.ts +19 -1
  55. package/dist/runtime/components/Integrations/WorkflowNodeView.d.vue.ts +4 -0
  56. package/dist/runtime/components/Integrations/WorkflowNodeView.vue +189 -46
  57. package/dist/runtime/components/Integrations/WorkflowNodeView.vue.d.ts +4 -0
  58. package/dist/runtime/components/Integrations/WorkflowRunReplayPanel.vue +4 -20
  59. package/dist/runtime/components/Integrations/WorkflowSettingsDialog.vue +15 -9
  60. package/dist/runtime/components/Integrations/WorkflowTagsInput.d.vue.ts +1 -0
  61. package/dist/runtime/components/Integrations/WorkflowTagsInput.vue +7 -2
  62. package/dist/runtime/components/Integrations/WorkflowTagsInput.vue.d.ts +1 -0
  63. package/dist/runtime/components/Integrations/WorkflowTriggerView.vue +4 -2
  64. package/dist/runtime/components/Integrations/WorkflowVariablesDialog.vue +30 -17
  65. package/dist/runtime/components/Integrations/WorkflowsTable.vue +47 -7
  66. package/dist/runtime/composables/useCredentialTypes.js +4 -4
  67. package/dist/runtime/composables/useIntegrationsAudit.js +4 -3
  68. package/dist/runtime/composables/useIntegrationsCredentials.js +4 -4
  69. package/dist/runtime/composables/useIntegrationsDeadLetters.js +7 -6
  70. package/dist/runtime/composables/useIntegrationsNodes.js +4 -4
  71. package/dist/runtime/composables/useIntegrationsRuns.js +11 -6
  72. package/dist/runtime/composables/useIntegrationsSchedules.d.ts +4 -0
  73. package/dist/runtime/composables/useIntegrationsSchedules.js +10 -3
  74. package/dist/runtime/composables/useIntegrationsSecrets.js +10 -7
  75. package/dist/runtime/composables/useIntegrationsTemplates.d.ts +1 -1
  76. package/dist/runtime/composables/useIntegrationsTemplates.js +12 -8
  77. package/dist/runtime/composables/useIntegrationsTriggers.js +6 -6
  78. package/dist/runtime/composables/useIntegrationsWorkflows.js +8 -4
  79. package/dist/runtime/composables/useLoadGate.d.ts +17 -0
  80. package/dist/runtime/composables/useLoadGate.js +8 -0
  81. package/dist/runtime/data/branchReorder.d.ts +37 -0
  82. package/dist/runtime/data/branchReorder.js +71 -0
  83. package/dist/runtime/data/handleId.d.ts +23 -0
  84. package/dist/runtime/data/handleId.js +6 -0
  85. package/dist/runtime/data/integrationTemplates.d.ts +39 -0
  86. package/dist/runtime/data/integrationTemplates.js +15 -0
  87. package/dist/runtime/data/integrationWorkflowSamples.js +1 -1
  88. package/dist/runtime/data/nodeIcons.d.ts +2 -0
  89. package/dist/runtime/data/nodeIcons.js +73 -0
  90. package/dist/runtime/data/nodeInputData.d.ts +16 -4
  91. package/dist/runtime/data/nodeInputData.js +4 -0
  92. package/dist/runtime/data/payloadForm.d.ts +9 -2
  93. package/dist/runtime/data/payloadForm.js +27 -3
  94. package/dist/runtime/data/portTone.d.ts +17 -0
  95. package/dist/runtime/data/portTone.js +7 -0
  96. package/dist/runtime/data/webhookAuth.d.ts +18 -0
  97. package/dist/runtime/data/webhookAuth.js +19 -0
  98. package/dist/runtime/data/workflowIconCatalog.js +2 -5
  99. package/dist/runtime/pages/integrations/audit.vue +91 -6
  100. package/dist/runtime/pages/integrations/credentials/oauth-complete.vue +6 -4
  101. package/dist/runtime/pages/integrations/credentials.vue +119 -21
  102. package/dist/runtime/pages/integrations/index.vue +49 -44
  103. package/dist/runtime/pages/integrations/runs.vue +1 -1
  104. package/dist/runtime/pages/integrations/schedules.vue +51 -7
  105. package/dist/runtime/pages/integrations/secrets.vue +29 -7
  106. package/dist/runtime/pages/integrations/templates/[slug].vue +52 -30
  107. package/dist/runtime/pages/integrations/templates/index.vue +57 -86
  108. package/dist/runtime/pages/integrations/workflows/[id].vue +196 -8
  109. package/dist/runtime/pages/integrations/workflows/index.vue +15 -2
  110. package/dist/runtime/types/integrations.d.ts +65 -6
  111. package/dist/runtime/utils/apiError.d.ts +47 -0
  112. package/dist/runtime/utils/apiError.js +32 -0
  113. package/dist/runtime/utils/cockpitSources.d.ts +153 -0
  114. package/dist/runtime/utils/cockpitSources.js +14 -0
  115. package/dist/runtime/utils/loadGate.d.ts +61 -0
  116. package/dist/runtime/utils/loadGate.js +22 -0
  117. package/dist/runtime/utils/localize.d.ts +29 -0
  118. package/dist/runtime/utils/localize.js +4 -0
  119. package/dist/runtime/utils/notify.d.ts +59 -0
  120. package/dist/runtime/utils/notify.js +21 -0
  121. package/dist/runtime/utils/revenexxCredentialSource.d.ts +26 -0
  122. package/dist/runtime/utils/revenexxCredentialSource.js +8 -0
  123. package/dist/runtime/utils/schedule.d.ts +141 -0
  124. package/dist/runtime/utils/schedule.js +207 -0
  125. package/dist/runtime/utils/validationFindings.d.ts +101 -0
  126. package/dist/runtime/utils/validationFindings.js +53 -0
  127. package/dist/runtime/utils/whenIdle.d.ts +42 -0
  128. package/dist/runtime/utils/whenIdle.js +12 -0
  129. package/package.json +8 -2
@@ -64,6 +64,53 @@ function copy(t) {
64
64
  toast.success("Copied");
65
65
  }
66
66
  }
67
+ const panelEl = ref(null);
68
+ let opener = null;
69
+ function tabStops() {
70
+ const root = panelEl.value;
71
+ if (!root) return [];
72
+ const sel = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
73
+ return [...root.querySelectorAll(sel)].filter((el) => el.offsetParent !== null);
74
+ }
75
+ function onKeydown(e) {
76
+ if (e.key === "Escape") {
77
+ selected.value = null;
78
+ return;
79
+ }
80
+ if (e.key !== "Tab") return;
81
+ const root = panelEl.value;
82
+ if (!root) return;
83
+ const stops = tabStops();
84
+ if (!stops.length) {
85
+ e.preventDefault();
86
+ root.focus();
87
+ return;
88
+ }
89
+ const first = stops[0];
90
+ const last = stops[stops.length - 1];
91
+ const active = document.activeElement;
92
+ const inside = active instanceof Node && root.contains(active);
93
+ if (e.shiftKey && (!inside || active === first || active === root)) {
94
+ e.preventDefault();
95
+ last.focus();
96
+ } else if (!e.shiftKey && (!inside || active === last)) {
97
+ e.preventDefault();
98
+ first.focus();
99
+ }
100
+ }
101
+ watch(selected, async (entry, previous) => {
102
+ if (entry) {
103
+ document.addEventListener("keydown", onKeydown);
104
+ if (!previous) opener = document.activeElement;
105
+ await nextTick();
106
+ panelEl.value?.focus();
107
+ } else {
108
+ document.removeEventListener("keydown", onKeydown);
109
+ if (opener?.isConnected) opener.focus();
110
+ opener = null;
111
+ }
112
+ });
113
+ onUnmounted(() => document.removeEventListener("keydown", onKeydown));
67
114
  </script>
68
115
 
69
116
  <template>
@@ -73,11 +120,18 @@ function copy(t) {
73
120
  <div class="p-4 space-y-3">
74
121
  <p v-if="error" class="text-sm text-rose-700">{{ error }}</p>
75
122
 
123
+ <!-- `:pagination="false"` — one pagination, not two. The feed is loaded a
124
+ page at a time by "Load more" below; the table's own pager (on by
125
+ default, twenty rows) would then hold the entries just fetched behind a
126
+ control nobody asked for, so the operator presses "Load more" and sees
127
+ nothing change. `templates/[slug].vue` and `workflows/[id].vue` switch
128
+ it off for the same reason. -->
76
129
  <DataTable
77
130
  :data="entries"
78
131
  :columns="columns"
79
132
  :row-key="(e) => e.id"
80
133
  :default-sort="{ column: 'when', direction: 'desc' }"
134
+ :pagination="false"
81
135
  :search="{ placeholder: 'Search action, resource, actor\u2026' }"
82
136
  :filter-tabs="filterTabs"
83
137
  :loading="loading"
@@ -111,13 +165,44 @@ function copy(t) {
111
165
 
112
166
  <!-- Detail flyout -->
113
167
  <Transition name="drawer">
114
- <div v-if="selected" class="fixed inset-0 z-50 flex" @keydown.esc="selected = null">
115
- <div class="flex-1 bg-black/20 backdrop-blur-[1px]" @click="selected = null" />
116
- <aside class="w-full max-w-md overflow-y-auto border-l border-black/[0.06] bg-white shadow-xl">
168
+ <div v-if="selected" class="fixed inset-0 z-50 flex">
169
+ <!-- Clicking away dismisses the panel, and that is a pointer gesture by
170
+ design: the two routes that do not need a pointer — Escape and
171
+ `Close entry details` — are both inside the panel, where the Tab trap
172
+ keeps the focus. So this element is deliberately out of reach.
173
+ `tabindex="-1"` says so at the element rather than leaving it to the
174
+ trap, and `aria-modal` on the panel beside it already keeps the whole
175
+ overlay out of what a screen reader is offered. It stays a button and
176
+ keeps its name so that the day the trap changes, the fallback is a
177
+ named control rather than a clickable div. -->
178
+ <button
179
+ type="button"
180
+ tabindex="-1"
181
+ aria-label="Dismiss entry details"
182
+ class="flex-1 bg-black/20 backdrop-blur-[1px]"
183
+ @click="selected = null"
184
+ />
185
+ <!-- A dialog rather than a complementary landmark (PO-245). It covers the
186
+ page, holds the focus and closes on Escape, which is what a dialog is;
187
+ announcing it as supporting content beside the page while trapping the
188
+ focus inside it would be two answers to one question. `aria-modal` is
189
+ what tells a screen reader not to wander into what the overlay hides.
190
+ `tabindex="-1"` is what makes the container focusable at all, so
191
+ opening the panel can put the operator in it — see `onKeydown` above.
192
+ Still named: the shell has an <aside> for the sidebar, and this panel
193
+ needs to be tellable from it and addressable by a test. -->
194
+ <div
195
+ ref="panelEl"
196
+ role="dialog"
197
+ aria-modal="true"
198
+ aria-label="Audit entry details"
199
+ tabindex="-1"
200
+ class="drawer-panel w-full max-w-md overflow-y-auto border-l border-black/[0.06] bg-white shadow-xl outline-none"
201
+ >
117
202
  <div class="sticky top-0 z-10 flex items-center gap-2 border-b border-black/[0.06] bg-white/90 backdrop-blur px-4 py-3">
118
203
  <Badge variant="outline" :class="['border text-xs font-medium uppercase', actionTone(selected.action)]">{{ prettyAction(selected.action) }}</Badge>
119
204
  <span class="text-sm font-semibold text-[#0B0522] capitalize">{{ prettyType(selected.resource_type) }}</span>
120
- <Button variant="ghost" size="sm" class="ml-auto h-7 w-7 p-0" @click="selected = null"><Close :size="15" /></Button>
205
+ <Button variant="ghost" size="sm" aria-label="Close entry details" class="ml-auto h-7 w-7 p-0" @click="selected = null"><Close :size="15" /></Button>
121
206
  </div>
122
207
  <div class="p-4 space-y-4 text-sm">
123
208
  <div class="space-y-1.5">
@@ -135,12 +220,12 @@ function copy(t) {
135
220
  <pre class="overflow-x-auto rounded-md bg-[#0B0522] p-3 text-xs leading-relaxed text-[#E6E1F5]"><code>{{ JSON.stringify(selected.metadata, null, 2) }}</code></pre>
136
221
  </div>
137
222
  </div>
138
- </aside>
223
+ </div>
139
224
  </div>
140
225
  </Transition>
141
226
  </IntegrationsLayout>
142
227
  </template>
143
228
 
144
229
  <style scoped>
145
- .drawer-enter-active,.drawer-leave-active{transition:opacity .2s ease}.drawer-enter-active aside,.drawer-leave-active aside{transition:transform .25s cubic-bezier(.22,1,.36,1)}.drawer-enter-from,.drawer-leave-to{opacity:0}.drawer-enter-from aside,.drawer-leave-to aside{transform:translateX(100%)}
230
+ .drawer-enter-active,.drawer-leave-active{transition:opacity .2s ease}.drawer-enter-active .drawer-panel,.drawer-leave-active .drawer-panel{transition:transform .25s cubic-bezier(.22,1,.36,1)}.drawer-enter-from,.drawer-leave-to{opacity:0}.drawer-enter-from .drawer-panel,.drawer-leave-to .drawer-panel{transform:translateX(100%)}
146
231
  </style>
@@ -15,14 +15,16 @@ onMounted(() => {
15
15
  </script>
16
16
 
17
17
  <template>
18
- <div class="flex min-h-screen items-center justify-center bg-white text-center">
18
+ <!-- Own landmark and heading: this popup renders without the studio shell
19
+ (`layout: false`), so it would otherwise be the one page with neither. -->
20
+ <main class="flex min-h-screen items-center justify-center bg-white text-center">
19
21
  <div class="space-y-2">
20
- <p class="text-sm font-medium text-foreground">
22
+ <h1 class="text-sm font-medium text-foreground">
21
23
  Connection complete
22
- </p>
24
+ </h1>
23
25
  <p class="text-sm text-muted-foreground">
24
26
  {{ closing ? "You can close this window." : "Done \u2014 closing\u2026" }}
25
27
  </p>
26
28
  </div>
27
- </div>
29
+ </main>
28
30
  </template>
@@ -7,6 +7,12 @@ import {
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,8 +30,11 @@ import {
24
30
  SelectTrigger,
25
31
  SelectValue
26
32
  } from "@revenexx/studio";
27
- import { toast } from "vue-sonner";
33
+ import { toast } from "../../utils/notify";
34
+ import { apiFieldErrors, refusalReason } from "../../utils/apiError";
28
35
  import { credentialState } from "../../utils/credentialState";
36
+ import { hasRevenexxSource } from "../../utils/revenexxCredentialSource";
37
+ import { resolveLocalized } from "../../utils/localize";
29
38
  useHead({ title: "Credentials \xB7 Revenexx Cockpit" });
30
39
  const { credentials, loading, error, load, create, update, remove, testInline, test, oauthAuthorizeUrl } = useIntegrationsCredentials();
31
40
  const { types, load: loadTypes, bySlug } = useCredentialTypes();
@@ -35,14 +44,8 @@ onMounted(async () => {
35
44
  function typeFor(slug) {
36
45
  return bySlug(slug);
37
46
  }
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
47
  function typeName(slug) {
45
- return loc(typeFor(slug)?.name) || slug;
48
+ return resolveLocalized(typeFor(slug)?.name) || slug;
46
49
  }
47
50
  function isOAuthAuthcode(t) {
48
51
  return t?.auth_kind === "oauth2-authcode";
@@ -71,17 +74,27 @@ const form = reactive({ name: "", config: {} });
71
74
  const saving = ref(false);
72
75
  const testing = ref(false);
73
76
  const testResult = ref(null);
77
+ const saveError = ref(null);
78
+ const fieldErrors = ref({});
74
79
  const sourceActive = ref(false);
75
80
  const isEdit = computed(() => editing.value !== null);
76
81
  function fieldsOf(t) {
77
82
  return t?.fields ?? [];
78
83
  }
84
+ function configFieldAria(key) {
85
+ return fieldErrors.value[`config.${key}`] ? { "aria-invalid": "true", "aria-describedby": `cred-field-${key}-error` } : {};
86
+ }
87
+ function clearRefusal() {
88
+ saveError.value = null;
89
+ fieldErrors.value = {};
90
+ }
79
91
  function openCreate(t) {
80
92
  editing.value = null;
81
93
  pickedType.value = t ?? null;
82
94
  form.name = "";
83
95
  form.config = {};
84
96
  testResult.value = null;
97
+ clearRefusal();
85
98
  sourceActive.value = false;
86
99
  dialogOpen.value = true;
87
100
  }
@@ -116,6 +129,7 @@ async function runTest() {
116
129
  if (!pickedType.value) return;
117
130
  testing.value = true;
118
131
  testResult.value = null;
132
+ clearRefusal();
119
133
  try {
120
134
  testResult.value = isEdit.value && editing.value ? await test(editing.value.id) : await testInline(pickedType.value.slug, buildConfigPayload());
121
135
  } finally {
@@ -125,9 +139,11 @@ async function runTest() {
125
139
  async function submit() {
126
140
  if (!pickedType.value || !form.name.trim()) return;
127
141
  saving.value = true;
142
+ clearRefusal();
128
143
  try {
129
144
  if (isEdit.value && editing.value) {
130
145
  await update(editing.value.id, { name: form.name.trim(), config: buildConfigPayload() });
146
+ dialogOpen.value = false;
131
147
  toast.success("Credential updated");
132
148
  } else {
133
149
  const created = await create({
@@ -135,14 +151,16 @@ async function submit() {
135
151
  name: form.name.trim(),
136
152
  config: buildConfigPayload()
137
153
  });
154
+ dialogOpen.value = false;
138
155
  toast.success("Credential created");
139
156
  if (isOAuthAuthcode(pickedType.value)) {
140
157
  await connect(created);
141
158
  }
142
159
  }
143
- dialogOpen.value = false;
144
160
  } catch (e) {
145
- toast.error(e?.message ?? "Could not save credential.");
161
+ fieldErrors.value = apiFieldErrors(e);
162
+ saveError.value = refusalReason(e, "Could not save credential.");
163
+ toast.error(saveError.value);
146
164
  } finally {
147
165
  saving.value = false;
148
166
  }
@@ -174,13 +192,20 @@ async function connect(c) {
174
192
  connecting.value = null;
175
193
  }
176
194
  }
177
- async function destroy(c) {
178
- if (!confirm(`Delete credential "${c.name}"? Workflows referencing it will fail until re-bound.`)) return;
195
+ const pendingDelete = ref(null);
196
+ const deleting = ref(false);
197
+ async function confirmDelete() {
198
+ const c = pendingDelete.value;
199
+ if (!c) return;
200
+ deleting.value = true;
179
201
  try {
180
202
  await remove(c.id);
181
203
  toast.success("Credential deleted");
204
+ pendingDelete.value = null;
182
205
  } catch (e) {
183
206
  toast.error(e?.message ?? "Could not delete credential.");
207
+ } finally {
208
+ deleting.value = false;
184
209
  }
185
210
  }
186
211
  </script>
@@ -227,11 +252,15 @@ async function destroy(c) {
227
252
  </template>
228
253
 
229
254
  <template #cell-status="{ row: c }">
255
+ <!-- The name is part of the accessible label: on its own, "Connected"
256
+ says nothing about *which* credential is, and collides with the
257
+ column header of the same name. -->
230
258
  <Badge
231
259
  variant="outline"
232
260
  class="border text-xs font-medium uppercase"
233
261
  :class="credentialState(c).toneClass"
234
262
  :title="credentialState(c).message ?? void 0"
263
+ :aria-label="`${c.name}: ${credentialState(c).label}`"
235
264
  >
236
265
  {{ credentialState(c).label }}
237
266
  </Badge>
@@ -259,11 +288,12 @@ async function destroy(c) {
259
288
  size="sm"
260
289
  class="h-7 text-amber-600"
261
290
  :disabled="connecting === c.id"
291
+ :aria-label="`Reconnect ${c.name}`"
262
292
  @click="connect(c)"
263
293
  >
264
294
  Reconnect
265
295
  </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)">
296
+ <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 ${c.name}`" :aria-label="`Delete ${c.name}`" @click="pendingDelete = c">
267
297
  <Trash2 :size="13" />
268
298
  </button>
269
299
  </div>
@@ -287,7 +317,8 @@ async function destroy(c) {
287
317
  :types="types"
288
318
  @pick="(t) => {
289
319
  pickedType = t;
290
- form.name = loc(t.name);
320
+ form.name = resolveLocalized(t.name);
321
+ clearRefusal();
291
322
  }"
292
323
  />
293
324
 
@@ -295,40 +326,60 @@ async function destroy(c) {
295
326
  <div v-else class="space-y-3 py-1 max-h-[28rem] overflow-y-auto pr-1">
296
327
  <div class="space-y-1.5">
297
328
  <Label for="cred-name">Name</Label>
298
- <Input id="cred-name" v-model="form.name" class="bg-white" placeholder="e.g. Production SMTP" />
329
+ <Input
330
+ id="cred-name"
331
+ v-model="form.name"
332
+ class="bg-white"
333
+ placeholder="e.g. Production SMTP"
334
+ :aria-invalid="fieldErrors.name ? 'true' : void 0"
335
+ :aria-describedby="fieldErrors.name ? 'cred-name-error' : void 0"
336
+ />
337
+ <p v-if="fieldErrors.name" id="cred-name-error" class="text-sm text-rose-700">
338
+ {{ fieldErrors.name }}
339
+ </p>
299
340
  </div>
300
341
 
301
- <!-- "Use revenexx …" source picker (SFTP / Mail / API key) -->
342
+ <!-- "Use revenexx …" source picker (SFTP / Mail / API key) — mounted
343
+ only for the types it serves: it resolves cockpit-only sources at
344
+ setup, so mounting it elsewhere would take this form down. -->
302
345
  <IntegrationsCredentialRevenexxSource
346
+ v-if="hasRevenexxSource(pickedType?.slug)"
303
347
  :type="pickedType"
304
348
  @update:active="(v) => sourceActive = v"
305
349
  @apply="(c2) => Object.assign(form.config, c2)"
306
350
  />
307
351
 
352
+ <!-- Each label names its own control (`for`/`id`): without it a screen
353
+ reader reads the field as unlabelled, and the label text cannot be
354
+ used to reach it at all. -->
308
355
  <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>
356
+ <Label :for="`cred-field-${f.key}`">{{ resolveLocalized(f.label) }}{{ f.required ? " *" : "" }}</Label>
310
357
 
311
358
  <!-- secret -->
312
359
  <Input
313
360
  v-if="f.type === 'secret'"
361
+ :id="`cred-field-${f.key}`"
314
362
  :model-value="form.config[f.key] ?? ''"
315
363
  type="password"
316
364
  autocomplete="new-password"
317
365
  :placeholder="isEdit ? 'Leave blank to keep current' : ''"
318
366
  class="bg-white"
367
+ v-bind="configFieldAria(f.key)"
319
368
  @update:model-value="(v) => form.config[f.key] = v"
320
369
  />
321
370
  <!-- number -->
322
371
  <Input
323
372
  v-else-if="f.type === 'number'"
373
+ :id="`cred-field-${f.key}`"
324
374
  type="number"
325
375
  :model-value="form.config[f.key] ?? ''"
326
376
  class="bg-white"
377
+ v-bind="configFieldAria(f.key)"
327
378
  @update:model-value="(v) => form.config[f.key] = v === '' ? void 0 : Number(v)"
328
379
  />
329
380
  <!-- boolean -->
330
381
  <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" />
382
+ <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
383
  </div>
333
384
  <!-- select -->
334
385
  <Select
@@ -336,22 +387,32 @@ async function destroy(c) {
336
387
  :model-value="form.config[f.key] ?? ''"
337
388
  @update:model-value="(v) => form.config[f.key] = v"
338
389
  >
339
- <SelectTrigger class="bg-white">
390
+ <SelectTrigger :id="`cred-field-${f.key}`" class="bg-white" v-bind="configFieldAria(f.key)">
340
391
  <SelectValue placeholder="Pick one" />
341
392
  </SelectTrigger>
342
393
  <SelectContent>
343
394
  <SelectItem v-for="opt in f.options ?? []" :key="String(opt.value)" :value="String(opt.value)">
344
- {{ loc(opt.label) }}
395
+ {{ resolveLocalized(opt.label) }}
345
396
  </SelectItem>
346
397
  </SelectContent>
347
398
  </Select>
348
399
  <!-- string (default) -->
349
400
  <Input
350
401
  v-else
402
+ :id="`cred-field-${f.key}`"
351
403
  :model-value="form.config[f.key] ?? ''"
352
404
  class="bg-white"
405
+ v-bind="configFieldAria(f.key)"
353
406
  @update:model-value="(v) => form.config[f.key] = v"
354
407
  />
408
+
409
+ <!-- The service keys a config refusal as `config.<key>`, which is this
410
+ field. Same rule as the name above: the message belongs where the
411
+ input is, not only in the summary, and the id is what the control's
412
+ `aria-describedby` points at. -->
413
+ <p v-if="fieldErrors[`config.${f.key}`]" :id="`cred-field-${f.key}-error`" class="text-sm text-rose-700">
414
+ {{ fieldErrors[`config.${f.key}`] }}
415
+ </p>
355
416
  </div>
356
417
 
357
418
  <p v-if="isOAuthAuthcode(pickedType) && !isEdit" class="text-sm text-muted-foreground">
@@ -365,10 +426,22 @@ async function destroy(c) {
365
426
  >
366
427
  {{ testResult.ok ? "Connection ok." : testResult.error ?? "Connection failed." }}
367
428
  </div>
429
+
430
+ </div>
431
+
432
+ <!-- The save was refused. Outside the scrolling form on purpose: the Save
433
+ button sits in the footer below, so a summary at the end of a form that
434
+ scrolls — five fields already do — would be a refusal rendered under the
435
+ fold, which is the defect this whole change is about. Stated here as well
436
+ as at the field it belongs to, so a refusal with no field — a conflict, a
437
+ gateway error — is not silent either. -->
438
+ <div v-if="saveError && pickedType" role="alert" class="rounded-md bg-rose-500/10 px-3 py-2 text-sm text-rose-700">
439
+ {{ saveError }}
368
440
  </div>
369
441
 
370
442
  <DialogFooter v-if="pickedType" class="sm:justify-between">
371
- <Button v-if="!isEdit" variant="ghost" size="sm" :disabled="saving || testing" @click="pickedType = null">
443
+ <Button v-if="!isEdit" variant="ghost" size="sm" :disabled="saving || testing" @click="pickedType = null;
444
+ clearRefusal()">
372
445
  Back
373
446
  </Button>
374
447
  <span v-else />
@@ -387,6 +460,31 @@ async function destroy(c) {
387
460
  </DialogContent>
388
461
  </Dialog>
389
462
 
463
+ <AlertDialog :open="!!pendingDelete" @update:open="(v) => {
464
+ if (!v) pendingDelete = null;
465
+ }">
466
+ <AlertDialogContent>
467
+ <AlertDialogHeader>
468
+ <AlertDialogTitle>Delete credential?</AlertDialogTitle>
469
+ <AlertDialogDescription>
470
+ <span v-if="pendingDelete">
471
+ <span class="font-medium text-foreground">{{ pendingDelete.name }}</span>
472
+ will be removed. Workflows that reference it will fail until they are re-bound to another credential.
473
+ </span>
474
+ </AlertDialogDescription>
475
+ </AlertDialogHeader>
476
+ <AlertDialogFooter>
477
+ <Button variant="outline" :disabled="deleting" @click="pendingDelete = null">
478
+ Cancel
479
+ </Button>
480
+ <Button class="bg-destructive text-white hover:bg-destructive/90" :disabled="deleting" @click="confirmDelete">
481
+ <Loader2 v-if="deleting" :size="14" class="animate-spin" />
482
+ {{ deleting ? "Deleting\u2026" : "Delete" }}
483
+ </Button>
484
+ </AlertDialogFooter>
485
+ </AlertDialogContent>
486
+ </AlertDialog>
487
+
390
488
  <!-- Detail / edit view (single-page: connection state + fields + metadata) -->
391
489
  <IntegrationsCredentialDetailDialog
392
490
  v-model:open="detailOpen"