@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.
- package/CHANGELOG.md +201 -0
- package/README.md +17 -4
- package/dist/module.json +1 -1
- package/dist/runtime/components/Integrations/CredentialCreateDialog.vue +15 -14
- package/dist/runtime/components/Integrations/CredentialDetailDialog.vue +91 -19
- package/dist/runtime/components/Integrations/CredentialRevenexxSource.vue +58 -21
- package/dist/runtime/components/Integrations/CredentialTypePicker.vue +4 -8
- package/dist/runtime/components/Integrations/EventTypeCombobox.d.vue.ts +1 -0
- package/dist/runtime/components/Integrations/EventTypeCombobox.vue +4 -2
- package/dist/runtime/components/Integrations/EventTypeCombobox.vue.d.ts +1 -0
- package/dist/runtime/components/Integrations/ExpressionInput.d.vue.ts +4 -0
- package/dist/runtime/components/Integrations/ExpressionInput.vue +54 -25
- package/dist/runtime/components/Integrations/ExpressionInput.vue.d.ts +4 -0
- package/dist/runtime/components/Integrations/GroupFrameView.vue +12 -12
- package/dist/runtime/components/Integrations/Layout.vue +21 -2
- package/dist/runtime/components/Integrations/NodeConfigDynamicField.vue +10 -11
- package/dist/runtime/components/Integrations/NodeConfigDynamicSchema.vue +2 -5
- package/dist/runtime/components/Integrations/NodeConfigField.d.vue.ts +2 -0
- package/dist/runtime/components/Integrations/NodeConfigField.vue +160 -67
- package/dist/runtime/components/Integrations/NodeConfigField.vue.d.ts +2 -0
- package/dist/runtime/components/Integrations/NodeInspector.d.vue.ts +6 -0
- package/dist/runtime/components/Integrations/NodeInspector.vue +1075 -303
- package/dist/runtime/components/Integrations/NodeInspector.vue.d.ts +6 -0
- package/dist/runtime/components/Integrations/NodeLogo.d.vue.ts +12 -0
- package/dist/runtime/components/Integrations/NodeLogo.vue +2 -30
- package/dist/runtime/components/Integrations/NodeLogo.vue.d.ts +12 -0
- package/dist/runtime/components/Integrations/NodeMark.d.vue.ts +15 -0
- package/dist/runtime/components/Integrations/NodeMark.vue +79 -0
- package/dist/runtime/components/Integrations/NodeMark.vue.d.ts +15 -0
- package/dist/runtime/components/Integrations/NodeOutputSchema.d.vue.ts +21 -0
- package/dist/runtime/components/Integrations/NodeOutputSchema.vue +42 -0
- package/dist/runtime/components/Integrations/NodeOutputSchema.vue.d.ts +21 -0
- package/dist/runtime/components/Integrations/NodePalette.vue +100 -34
- package/dist/runtime/components/Integrations/QueryBuilder.vue +8 -1
- package/dist/runtime/components/Integrations/RunActionsMenu.vue +3 -1
- package/dist/runtime/components/Integrations/RunPayloadInput.vue +17 -4
- package/dist/runtime/components/Integrations/StickyNoteView.vue +2 -1
- package/dist/runtime/components/Integrations/TemplateCard.d.vue.ts +18 -0
- package/dist/runtime/components/Integrations/TemplateCard.vue +109 -0
- package/dist/runtime/components/Integrations/TemplateCard.vue.d.ts +18 -0
- package/dist/runtime/components/Integrations/TemplateFlowPreview.d.vue.ts +2 -2
- package/dist/runtime/components/Integrations/TemplateFlowPreview.vue +7 -10
- package/dist/runtime/components/Integrations/TemplateFlowPreview.vue.d.ts +2 -2
- package/dist/runtime/components/Integrations/WorkflowCreateDialog.vue +21 -37
- package/dist/runtime/components/Integrations/WorkflowEdgeView.d.vue.ts +18 -4
- package/dist/runtime/components/Integrations/WorkflowEdgeView.vue +23 -7
- package/dist/runtime/components/Integrations/WorkflowEdgeView.vue.d.ts +18 -4
- package/dist/runtime/components/Integrations/WorkflowEditor.d.vue.ts +20 -1
- package/dist/runtime/components/Integrations/WorkflowEditor.vue +167 -46
- package/dist/runtime/components/Integrations/WorkflowEditor.vue.d.ts +20 -1
- package/dist/runtime/components/Integrations/WorkflowHistoryPanel.vue +4 -20
- package/dist/runtime/components/Integrations/WorkflowIconPicker.d.vue.ts +19 -1
- package/dist/runtime/components/Integrations/WorkflowIconPicker.vue +11 -2
- package/dist/runtime/components/Integrations/WorkflowIconPicker.vue.d.ts +19 -1
- package/dist/runtime/components/Integrations/WorkflowNodeView.d.vue.ts +4 -0
- package/dist/runtime/components/Integrations/WorkflowNodeView.vue +189 -46
- package/dist/runtime/components/Integrations/WorkflowNodeView.vue.d.ts +4 -0
- package/dist/runtime/components/Integrations/WorkflowRunReplayPanel.vue +4 -20
- package/dist/runtime/components/Integrations/WorkflowSettingsDialog.vue +15 -9
- package/dist/runtime/components/Integrations/WorkflowTagsInput.d.vue.ts +1 -0
- package/dist/runtime/components/Integrations/WorkflowTagsInput.vue +7 -2
- package/dist/runtime/components/Integrations/WorkflowTagsInput.vue.d.ts +1 -0
- package/dist/runtime/components/Integrations/WorkflowTriggerView.vue +4 -2
- package/dist/runtime/components/Integrations/WorkflowVariablesDialog.vue +30 -17
- package/dist/runtime/components/Integrations/WorkflowsTable.vue +47 -7
- package/dist/runtime/composables/useCredentialTypes.js +4 -4
- package/dist/runtime/composables/useIntegrationsAudit.js +4 -3
- package/dist/runtime/composables/useIntegrationsCredentials.js +4 -4
- package/dist/runtime/composables/useIntegrationsDeadLetters.js +7 -6
- package/dist/runtime/composables/useIntegrationsNodes.js +4 -4
- package/dist/runtime/composables/useIntegrationsRuns.js +11 -6
- package/dist/runtime/composables/useIntegrationsSchedules.d.ts +4 -0
- package/dist/runtime/composables/useIntegrationsSchedules.js +10 -3
- package/dist/runtime/composables/useIntegrationsSecrets.js +10 -7
- package/dist/runtime/composables/useIntegrationsTemplates.d.ts +1 -1
- package/dist/runtime/composables/useIntegrationsTemplates.js +12 -8
- package/dist/runtime/composables/useIntegrationsTriggers.js +6 -6
- package/dist/runtime/composables/useIntegrationsWorkflows.js +8 -4
- package/dist/runtime/composables/useLoadGate.d.ts +17 -0
- package/dist/runtime/composables/useLoadGate.js +8 -0
- package/dist/runtime/data/branchReorder.d.ts +37 -0
- package/dist/runtime/data/branchReorder.js +71 -0
- package/dist/runtime/data/handleId.d.ts +23 -0
- package/dist/runtime/data/handleId.js +6 -0
- package/dist/runtime/data/integrationTemplates.d.ts +39 -0
- package/dist/runtime/data/integrationTemplates.js +15 -0
- package/dist/runtime/data/integrationWorkflowSamples.js +1 -1
- package/dist/runtime/data/nodeIcons.d.ts +2 -0
- package/dist/runtime/data/nodeIcons.js +73 -0
- package/dist/runtime/data/nodeInputData.d.ts +16 -4
- package/dist/runtime/data/nodeInputData.js +4 -0
- package/dist/runtime/data/payloadForm.d.ts +9 -2
- package/dist/runtime/data/payloadForm.js +27 -3
- package/dist/runtime/data/portTone.d.ts +17 -0
- package/dist/runtime/data/portTone.js +7 -0
- package/dist/runtime/data/webhookAuth.d.ts +18 -0
- package/dist/runtime/data/webhookAuth.js +19 -0
- package/dist/runtime/data/workflowIconCatalog.js +2 -5
- package/dist/runtime/pages/integrations/audit.vue +91 -6
- package/dist/runtime/pages/integrations/credentials/oauth-complete.vue +6 -4
- package/dist/runtime/pages/integrations/credentials.vue +119 -21
- package/dist/runtime/pages/integrations/index.vue +49 -44
- package/dist/runtime/pages/integrations/runs.vue +1 -1
- package/dist/runtime/pages/integrations/schedules.vue +51 -7
- package/dist/runtime/pages/integrations/secrets.vue +29 -7
- package/dist/runtime/pages/integrations/templates/[slug].vue +52 -30
- package/dist/runtime/pages/integrations/templates/index.vue +57 -86
- package/dist/runtime/pages/integrations/workflows/[id].vue +196 -8
- package/dist/runtime/pages/integrations/workflows/index.vue +15 -2
- package/dist/runtime/types/integrations.d.ts +65 -6
- package/dist/runtime/utils/apiError.d.ts +47 -0
- package/dist/runtime/utils/apiError.js +32 -0
- package/dist/runtime/utils/cockpitSources.d.ts +153 -0
- package/dist/runtime/utils/cockpitSources.js +14 -0
- package/dist/runtime/utils/loadGate.d.ts +61 -0
- package/dist/runtime/utils/loadGate.js +22 -0
- package/dist/runtime/utils/localize.d.ts +29 -0
- package/dist/runtime/utils/localize.js +4 -0
- package/dist/runtime/utils/notify.d.ts +59 -0
- package/dist/runtime/utils/notify.js +21 -0
- package/dist/runtime/utils/revenexxCredentialSource.d.ts +26 -0
- package/dist/runtime/utils/revenexxCredentialSource.js +8 -0
- package/dist/runtime/utils/schedule.d.ts +141 -0
- package/dist/runtime/utils/schedule.js +207 -0
- package/dist/runtime/utils/validationFindings.d.ts +101 -0
- package/dist/runtime/utils/validationFindings.js +53 -0
- package/dist/runtime/utils/whenIdle.d.ts +42 -0
- package/dist/runtime/utils/whenIdle.js +12 -0
- 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"
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
</
|
|
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
|
|
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
|
-
|
|
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
|
-
<
|
|
22
|
+
<h1 class="text-sm font-medium text-foreground">
|
|
21
23
|
Connection complete
|
|
22
|
-
</
|
|
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
|
-
</
|
|
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 "
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
178
|
-
|
|
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="
|
|
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 =
|
|
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
|
|
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>{{
|
|
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
|
-
{{
|
|
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"
|