@revenexx/studio-integrations 3.1.0 → 3.3.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 +22 -0
- package/dist/module.json +1 -1
- package/dist/runtime/components/Integrations/CredentialCreateDialog.vue +34 -6
- package/dist/runtime/components/Integrations/CredentialDetailDialog.vue +42 -10
- package/dist/runtime/components/Integrations/CredentialNetworkSection.d.vue.ts +21 -0
- package/dist/runtime/components/Integrations/CredentialNetworkSection.vue +28 -0
- package/dist/runtime/components/Integrations/CredentialNetworkSection.vue.d.ts +21 -0
- package/dist/runtime/components/Integrations/NodeConfigField.vue +46 -3
- package/dist/runtime/components/Integrations/RunDetailPanel.vue +3 -2
- package/dist/runtime/components/Integrations/TemplateInstallStep.vue +28 -24
- package/dist/runtime/components/Integrations/VpnRoutePicker.d.vue.ts +20 -0
- package/dist/runtime/components/Integrations/VpnRoutePicker.vue +194 -0
- package/dist/runtime/components/Integrations/VpnRoutePicker.vue.d.ts +20 -0
- package/dist/runtime/components/Integrations/WorkflowRunReplayPanel.vue +14 -2
- package/dist/runtime/composables/useIntegrationsCredentials.d.ts +15 -9
- package/dist/runtime/composables/useIntegrationsCredentials.js +9 -4
- package/dist/runtime/composables/useIntegrationsRuns.d.ts +1 -1
- package/dist/runtime/composables/useIntegrationsTemplates.d.ts +2 -0
- package/dist/runtime/composables/useIntegrationsTemplates.js +15 -9
- package/dist/runtime/composables/useVpnConnections.d.ts +19 -0
- package/dist/runtime/composables/useVpnConnections.js +28 -0
- package/dist/runtime/i18n/messages/de.json +39 -0
- package/dist/runtime/i18n/messages/en.json +39 -0
- package/dist/runtime/pages/integrations/credentials.vue +67 -15
- package/dist/runtime/pages/integrations/templates/[slug].vue +21 -22
- package/dist/runtime/types/integrations.d.ts +74 -2
- package/dist/runtime/utils/configDefaults.d.ts +5 -2
- package/dist/runtime/utils/stepError.d.ts +13 -0
- package/dist/runtime/utils/stepError.js +24 -0
- package/dist/runtime/utils/vpnRoute.d.ts +69 -0
- package/dist/runtime/utils/vpnRoute.js +79 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# @revenexx/studio-integrations
|
|
2
2
|
|
|
3
|
+
## 3.3.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 1318abe: A credential can reach its system through a VPN route instead of the public internet. For a credential type that declares which of its fields a route stands in for, both credential forms gain a Network section: public internet, or one of the tenant's VPN connections (with its status) and a route of the kind the type speaks. The fields the route replaces turn read-only and show what they reach; an HTTPS target notes that its certificate is not verified at the gateway; a tenant without connections is pointed at the VPN connections page where the host has one. A credential that goes through a VPN carries a badge in the credentials list and in a node's credential picker, naming the connection, its state when it is not active, and a route that no longer exists. A node setting of the new type `vpn-route` is drawn as the same choice and stores `{ connection, route }`, or nothing for the public internet. Needs the Integrations service's `GET /api/v1/vpn-connections` and the credential `network` field (PO-825).
|
|
8
|
+
|
|
9
|
+
### Patch Changes
|
|
10
|
+
|
|
11
|
+
- f73ff6c: A new credential's form starts from the defaults its system declares. A list read *Select* and a number stood empty even where the system had chosen a value — an FTP credential's encryption, its port — so the screen said nothing had been chosen; they now show that value, and saving without touching them stores it. Picking the system again after going back starts from those defaults instead of the last attempt's values. Both places a credential is added work the same: the credentials list and a node's credential setting. Opening a stored credential shows the default for a field it never stored, rather than an empty field or a switch drawn off; a value it did store still wins, and an untouched form still tests the stored credential.
|
|
12
|
+
|
|
13
|
+
## 3.2.0
|
|
14
|
+
|
|
15
|
+
### Minor Changes
|
|
16
|
+
|
|
17
|
+
- 60e6143: A template whose requirements the platform could not answer for is no longer shown as ready to install. The template page's setup rail and the install step now say they could not check what the template needs, with the platform's reason, instead of "Everything is ready to install" and "This template needs no credentials"; installing stays possible. While the answer is still on its way, the rail claims nothing.
|
|
18
|
+
|
|
19
|
+
For hosts: `useIntegrationsTemplates()` gains `checkRequirements(slug)`, which rejects with the platform's reason where `requirements(slug)` resolves to `null`, and `uncheckedReason(error)`, which turns that rejection into the sentence the studio shows. `requirements` still behaves as before but is now deprecated: its `null` cannot tell "could not check" from "needs nothing", so a host that reads it as an empty answer shows an unchecked template as ready. Switch to `checkRequirements`.
|
|
20
|
+
|
|
21
|
+
### Patch Changes
|
|
22
|
+
|
|
23
|
+
- e62ae58: A failed step now says why it failed in one sentence. The run record's step list showed the whole error the platform sends, as a block of JSON; it now shows the message. The replay's debug panel shows the message and the failure's type, and the places in the node's own code where it failed as a list that stays folded until opened.
|
|
24
|
+
|
|
3
25
|
## 3.1.0
|
|
4
26
|
|
|
5
27
|
### Minor Changes
|
package/dist/module.json
CHANGED
|
@@ -20,10 +20,12 @@ import {
|
|
|
20
20
|
} from "@revenexx/studio";
|
|
21
21
|
import { toast } from "vue-sonner";
|
|
22
22
|
import { resolveLocalized } from "../../utils/localize";
|
|
23
|
+
import { defaultsFor } from "../../utils/configDefaults";
|
|
23
24
|
import { fromModelValue, toItemValue, toModelValue } from "../../utils/selectOptions";
|
|
24
25
|
import { errorReason } from "../../utils/apiError";
|
|
25
26
|
import { keepOpenBeside } from "../../utils/dialog";
|
|
26
27
|
import { testOutcomeView } from "../../utils/credentialTest";
|
|
28
|
+
import { credentialNetworkPayload, replacedFieldTexts } from "../../utils/vpnRoute";
|
|
27
29
|
const props = defineProps({
|
|
28
30
|
open: { type: Boolean, required: true },
|
|
29
31
|
credentialType: { type: [String, Array], required: false }
|
|
@@ -61,6 +63,13 @@ const sourceActive = ref(false);
|
|
|
61
63
|
const saving = ref(false);
|
|
62
64
|
const testing = ref(false);
|
|
63
65
|
const testResult = ref(null);
|
|
66
|
+
const { connections: vpnConnections } = useVpnConnections();
|
|
67
|
+
const network = ref(null);
|
|
68
|
+
const networkComplete = ref(true);
|
|
69
|
+
const replaced = computed(() => replacedFieldTexts(pickedType.value, network.value, vpnConnections.value));
|
|
70
|
+
function networkPayload() {
|
|
71
|
+
return pickedType.value?.network ? credentialNetworkPayload(network.value) : void 0;
|
|
72
|
+
}
|
|
64
73
|
function fieldsOf(t2) {
|
|
65
74
|
return t2?.fields ?? [];
|
|
66
75
|
}
|
|
@@ -72,10 +81,12 @@ watch(() => props.open, async (o) => {
|
|
|
72
81
|
testResult.value = null;
|
|
73
82
|
sourceActive.value = false;
|
|
74
83
|
form.config = {};
|
|
84
|
+
network.value = null;
|
|
75
85
|
await loadTypes();
|
|
76
86
|
if (allowedTypes.value.length === 1) {
|
|
77
87
|
pickedType.value = allowedTypes.value[0];
|
|
78
88
|
form.name = resolveLocalized(pickedType.value.name);
|
|
89
|
+
form.config = defaultsFor(fieldsOf(pickedType.value));
|
|
79
90
|
} else {
|
|
80
91
|
pickedType.value = null;
|
|
81
92
|
form.name = "";
|
|
@@ -83,9 +94,10 @@ watch(() => props.open, async (o) => {
|
|
|
83
94
|
}, { immediate: true });
|
|
84
95
|
function pickType(t2) {
|
|
85
96
|
pickedType.value = t2;
|
|
86
|
-
form.config =
|
|
97
|
+
form.config = defaultsFor(fieldsOf(t2));
|
|
87
98
|
testResult.value = null;
|
|
88
99
|
sourceActive.value = false;
|
|
100
|
+
network.value = null;
|
|
89
101
|
if (!form.name) form.name = resolveLocalized(t2.name);
|
|
90
102
|
}
|
|
91
103
|
function buildConfigPayload() {
|
|
@@ -101,7 +113,7 @@ async function runTest() {
|
|
|
101
113
|
testing.value = true;
|
|
102
114
|
testResult.value = null;
|
|
103
115
|
try {
|
|
104
|
-
testResult.value = await testInline(pickedType.value.slug, buildConfigPayload());
|
|
116
|
+
testResult.value = await testInline(pickedType.value.slug, buildConfigPayload(), networkPayload());
|
|
105
117
|
} finally {
|
|
106
118
|
testing.value = false;
|
|
107
119
|
}
|
|
@@ -119,7 +131,8 @@ async function submit() {
|
|
|
119
131
|
const created = await create({
|
|
120
132
|
credential_type_slug: pickedType.value.slug,
|
|
121
133
|
name: form.name.trim(),
|
|
122
|
-
config: buildConfigPayload()
|
|
134
|
+
config: buildConfigPayload(),
|
|
135
|
+
network: networkPayload()
|
|
123
136
|
});
|
|
124
137
|
await load(true);
|
|
125
138
|
toast.success(t("integration.credentials.toast.created"));
|
|
@@ -193,13 +206,28 @@ async function submit() {
|
|
|
193
206
|
@apply="(c) => Object.assign(form.config, c)"
|
|
194
207
|
/>
|
|
195
208
|
|
|
209
|
+
<IntegrationsCredentialNetworkSection
|
|
210
|
+
v-model="network"
|
|
211
|
+
:type="pickedType"
|
|
212
|
+
id-prefix="create-network"
|
|
213
|
+
@update:complete="(c) => networkComplete = c"
|
|
214
|
+
/>
|
|
215
|
+
|
|
196
216
|
<!-- Each label names its own control (`for`/`id`): without it a screen
|
|
197
217
|
reader reads the field as unlabelled, and the label text cannot be
|
|
198
218
|
used to reach it at all. -->
|
|
199
219
|
<div v-for="f in fieldsOf(pickedType)" v-show="!sourceActive" :key="f.key" class="space-y-1.5">
|
|
200
220
|
<Label :for="`create-field-${f.key}`">{{ resolveLocalized(f.label) }}{{ f.required ? " *" : "" }}</Label>
|
|
221
|
+
<!-- stood in for by the chosen VPN route: what it reaches, not editable -->
|
|
222
|
+
<Input
|
|
223
|
+
v-if="replaced[f.key]"
|
|
224
|
+
:id="`create-field-${f.key}`"
|
|
225
|
+
:model-value="replaced[f.key]"
|
|
226
|
+
readonly
|
|
227
|
+
class="bg-muted/40"
|
|
228
|
+
/>
|
|
201
229
|
<Input
|
|
202
|
-
v-if="f.type === 'secret'"
|
|
230
|
+
v-else-if="f.type === 'secret'"
|
|
203
231
|
:id="`create-field-${f.key}`"
|
|
204
232
|
:model-value="form.config[f.key] ?? ''"
|
|
205
233
|
type="password"
|
|
@@ -259,12 +287,12 @@ async function submit() {
|
|
|
259
287
|
|
|
260
288
|
<DialogFooter v-if="pickedType">
|
|
261
289
|
<div class="flex items-center gap-2">
|
|
262
|
-
<Button variant="outline" :disabled="saving || testing" @click="runTest">
|
|
290
|
+
<Button variant="outline" :disabled="saving || testing || !networkComplete" @click="runTest">
|
|
263
291
|
<Loader2 v-if="testing" :size="14" class="mr-1 animate-spin" />
|
|
264
292
|
<TestIcon v-else :size="14" class="mr-1" />
|
|
265
293
|
{{ t("integration.credentials.shared.action.testConnection") }}
|
|
266
294
|
</Button>
|
|
267
|
-
<Button :disabled="saving || !form.name.trim()" @click="submit">
|
|
295
|
+
<Button :disabled="saving || !form.name.trim() || !networkComplete" @click="submit">
|
|
268
296
|
<Loader2 v-if="saving" :size="14" class="mr-1 animate-spin" />
|
|
269
297
|
{{ t("integration.common.action.save") }}
|
|
270
298
|
</Button>
|
|
@@ -33,6 +33,7 @@ import { toast } from "../../utils/notify";
|
|
|
33
33
|
import { apiRefusal, errorReason } from "../../utils/apiError";
|
|
34
34
|
import { credentialState } from "../../utils/credentialState";
|
|
35
35
|
import { credentialEditDiffers } from "../../utils/credentialEdit";
|
|
36
|
+
import { defaultsFor } from "../../utils/configDefaults";
|
|
36
37
|
import { relativeTime } from "../../utils/relativeTime";
|
|
37
38
|
import { useNow } from "../../composables/useNow";
|
|
38
39
|
import { hasRevenexxSource } from "../../utils/revenexxCredentialSource";
|
|
@@ -40,6 +41,7 @@ import { resolveLocalized } from "../../utils/localize";
|
|
|
40
41
|
import { fromModelValue, toItemValue, toModelValue } from "../../utils/selectOptions";
|
|
41
42
|
import { keepOpenBeside } from "../../utils/dialog";
|
|
42
43
|
import { testOutcomeView } from "../../utils/credentialTest";
|
|
44
|
+
import { credentialNetworkPayload, fromCredentialNetwork, replacedFieldTexts, sameVpnRoute } from "../../utils/vpnRoute";
|
|
43
45
|
const props = defineProps({
|
|
44
46
|
open: { type: Boolean, required: true },
|
|
45
47
|
credential: { type: [Object, null], required: true },
|
|
@@ -61,8 +63,17 @@ const deleting = ref(false);
|
|
|
61
63
|
const testing = ref(false);
|
|
62
64
|
const liveTest = ref(null);
|
|
63
65
|
const saveRefusal = ref(null);
|
|
66
|
+
const { connections: vpnConnections } = useVpnConnections();
|
|
67
|
+
const network = ref(null);
|
|
68
|
+
const networkComplete = ref(true);
|
|
69
|
+
const replaced = computed(() => replacedFieldTexts(props.type, network.value, vpnConnections.value));
|
|
70
|
+
const networkChanged = computed(() => !!props.type?.network && !sameVpnRoute(network.value, fromCredentialNetwork(props.credential?.network)));
|
|
71
|
+
function networkPayload() {
|
|
72
|
+
return networkChanged.value ? credentialNetworkPayload(network.value) : void 0;
|
|
73
|
+
}
|
|
64
74
|
const drawnKeys = computed(() => [
|
|
65
75
|
"name",
|
|
76
|
+
...props.type?.network ? ["network"] : [],
|
|
66
77
|
...sourceActive.value ? [] : fieldsOf(props.type).map((f) => `config.${f.key}`)
|
|
67
78
|
]);
|
|
68
79
|
const refusal = computed(() => apiRefusal(saveRefusal.value, drawnKeys.value));
|
|
@@ -80,7 +91,7 @@ async function focusFirstRefusedField() {
|
|
|
80
91
|
await nextTick();
|
|
81
92
|
const key = drawnKeys.value.find((k) => k in fieldErrors.value);
|
|
82
93
|
if (!key) return;
|
|
83
|
-
const id = key === "name" ? "detail-cred-name" : `detail-field-${key.slice("config.".length)}`;
|
|
94
|
+
const id = key === "name" ? "detail-cred-name" : key === "network" ? "detail-network-public" : `detail-field-${key.slice("config.".length)}`;
|
|
84
95
|
document.getElementById(id)?.focus();
|
|
85
96
|
}
|
|
86
97
|
function clearRefusal() {
|
|
@@ -102,14 +113,16 @@ function formatDateTime(iso) {
|
|
|
102
113
|
}
|
|
103
114
|
}
|
|
104
115
|
let viewOpening = 0;
|
|
116
|
+
const storedConfig = computed(() => ({ ...defaultsFor(fieldsOf(props.type)), ...props.credential?.public_config ?? {} }));
|
|
105
117
|
watch(
|
|
106
|
-
[() => props.open, () => props.credential?.id],
|
|
118
|
+
[() => props.open, () => props.credential?.id, () => props.type?.slug],
|
|
107
119
|
() => {
|
|
108
120
|
viewOpening++;
|
|
109
121
|
if (!props.open || !props.credential) return;
|
|
110
122
|
tab.value = "credentials";
|
|
111
123
|
form.name = props.credential.name;
|
|
112
|
-
form.config = { ...
|
|
124
|
+
form.config = { ...storedConfig.value };
|
|
125
|
+
network.value = fromCredentialNetwork(props.credential.network);
|
|
113
126
|
sourceActive.value = false;
|
|
114
127
|
liveTest.value = null;
|
|
115
128
|
clearRefusal();
|
|
@@ -133,8 +146,8 @@ async function runTest() {
|
|
|
133
146
|
clearRefusal();
|
|
134
147
|
try {
|
|
135
148
|
const payload = buildConfigPayload();
|
|
136
|
-
const edited = credentialEditDiffers(fieldsOf(props.type), payload,
|
|
137
|
-
const result = await test(props.credential.id, edited ? payload : void 0);
|
|
149
|
+
const edited = credentialEditDiffers(fieldsOf(props.type), payload, storedConfig.value) || networkChanged.value;
|
|
150
|
+
const result = await test(props.credential.id, edited ? payload : void 0, edited ? networkPayload() : void 0);
|
|
138
151
|
if (result.refusal) {
|
|
139
152
|
if (opening !== viewOpening) return;
|
|
140
153
|
saveRefusal.value = result.refusal;
|
|
@@ -161,10 +174,10 @@ async function save() {
|
|
|
161
174
|
clearRefusal();
|
|
162
175
|
const id = props.credential.id;
|
|
163
176
|
const payload = buildConfigPayload();
|
|
164
|
-
const edited = credentialEditDiffers(fieldsOf(props.type), payload,
|
|
177
|
+
const edited = credentialEditDiffers(fieldsOf(props.type), payload, storedConfig.value) || networkChanged.value;
|
|
165
178
|
try {
|
|
166
179
|
const wasConnected = props.credential.status !== "needs_reauth";
|
|
167
|
-
const updated = await update(id, { name: form.name.trim(), config: payload });
|
|
180
|
+
const updated = await update(id, { name: form.name.trim(), config: payload, network: networkPayload() });
|
|
168
181
|
const lostConsent = wasConnected && updated.status === "needs_reauth";
|
|
169
182
|
emit("changed");
|
|
170
183
|
emit("update:open", false);
|
|
@@ -315,14 +328,33 @@ async function connect() {
|
|
|
315
328
|
@apply="(c) => Object.assign(form.config, c)"
|
|
316
329
|
/>
|
|
317
330
|
|
|
331
|
+
<!-- Keyed by the credential: the picker holds a half-made choice of
|
|
332
|
+
its own, which belongs to the credential it was made on. -->
|
|
333
|
+
<IntegrationsCredentialNetworkSection
|
|
334
|
+
:key="credential.id"
|
|
335
|
+
v-model="network"
|
|
336
|
+
:type="type"
|
|
337
|
+
id-prefix="detail-network"
|
|
338
|
+
:error="fieldErrors.network"
|
|
339
|
+
@update:complete="(c) => networkComplete = c"
|
|
340
|
+
/>
|
|
341
|
+
|
|
318
342
|
<!-- Each label names its own control (`for`/`id`): without it a
|
|
319
343
|
screen reader reads the field as unlabelled, and the label text
|
|
320
344
|
cannot be used to reach it at all. -->
|
|
321
345
|
<div v-for="f in fieldsOf(type)" v-show="!sourceActive" :key="f.key" class="space-y-1.5">
|
|
322
346
|
<Label :for="`detail-field-${f.key}`">{{ resolveLocalized(f.label) }}{{ f.required ? " *" : "" }}</Label>
|
|
323
347
|
|
|
348
|
+
<!-- stood in for by the chosen VPN route: what it reaches, not editable -->
|
|
349
|
+
<Input
|
|
350
|
+
v-if="replaced[f.key]"
|
|
351
|
+
:id="`detail-field-${f.key}`"
|
|
352
|
+
:model-value="replaced[f.key]"
|
|
353
|
+
readonly
|
|
354
|
+
class="bg-muted/40"
|
|
355
|
+
/>
|
|
324
356
|
<Input
|
|
325
|
-
v-if="f.type === 'secret'"
|
|
357
|
+
v-else-if="f.type === 'secret'"
|
|
326
358
|
:id="`detail-field-${f.key}`"
|
|
327
359
|
:model-value="form.config[f.key] ?? ''"
|
|
328
360
|
type="password"
|
|
@@ -405,12 +437,12 @@ async function connect() {
|
|
|
405
437
|
<Button variant="ghost" :disabled="saving" @click="emit('update:open', false)">
|
|
406
438
|
{{ t("integration.common.action.cancel") }}
|
|
407
439
|
</Button>
|
|
408
|
-
<Button variant="outline" :disabled="testing || saving" @click="runTest">
|
|
440
|
+
<Button variant="outline" :disabled="testing || saving || !networkComplete" @click="runTest">
|
|
409
441
|
<Loader2 v-if="testing" :size="14" class="mr-1 animate-spin" />
|
|
410
442
|
<TestIcon v-else :size="14" class="mr-1" />
|
|
411
443
|
{{ t("integration.credentials.shared.action.testConnection") }}
|
|
412
444
|
</Button>
|
|
413
|
-
<Button :disabled="saving || !form.name.trim()" @click="save">
|
|
445
|
+
<Button :disabled="saving || !form.name.trim() || !networkComplete" @click="save">
|
|
414
446
|
<Loader2 v-if="saving" :size="14" class="mr-1 animate-spin" />
|
|
415
447
|
{{ t("integration.common.action.save") }}
|
|
416
448
|
</Button>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Network section of a credential form: how the credential reaches its system —
|
|
3
|
+
* over the public internet, or through a route of one of the tenant's VPN
|
|
4
|
+
* connections (specs/credential-network.md). Drawn only for a type that declares
|
|
5
|
+
* which of its fields a route stands in for; every other type has nothing to choose.
|
|
6
|
+
*/
|
|
7
|
+
import type { CredentialType, VpnRouteValue } from '../../types/integrations.js';
|
|
8
|
+
type __VLS_Props = {
|
|
9
|
+
type: CredentialType | null;
|
|
10
|
+
modelValue: VpnRouteValue | null;
|
|
11
|
+
idPrefix: string;
|
|
12
|
+
error?: string;
|
|
13
|
+
};
|
|
14
|
+
declare const _default: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
|
|
15
|
+
"update:complete": (args_0: boolean) => any;
|
|
16
|
+
"update:modelValue": (args_0: VpnRouteValue | null) => any;
|
|
17
|
+
}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
|
|
18
|
+
"onUpdate:complete"?: ((args_0: boolean) => any) | undefined;
|
|
19
|
+
"onUpdate:modelValue"?: ((args_0: VpnRouteValue | null) => any) | undefined;
|
|
20
|
+
}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
|
|
21
|
+
export default _default;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
defineProps({
|
|
3
|
+
type: { type: [Object, null], required: true },
|
|
4
|
+
modelValue: { type: [Object, null], required: true },
|
|
5
|
+
idPrefix: { type: String, required: true },
|
|
6
|
+
error: { type: String, required: false }
|
|
7
|
+
});
|
|
8
|
+
const emit = defineEmits(["update:modelValue", "update:complete"]);
|
|
9
|
+
const t = useIntegrationsT();
|
|
10
|
+
</script>
|
|
11
|
+
|
|
12
|
+
<template>
|
|
13
|
+
<section
|
|
14
|
+
v-if="type?.network"
|
|
15
|
+
:aria-labelledby="`${idPrefix}-heading`"
|
|
16
|
+
class="space-y-2 rounded-lg border border-black/[0.08] p-3"
|
|
17
|
+
>
|
|
18
|
+
<h3 :id="`${idPrefix}-heading`" class="text-sm font-semibold">{{ t("integration.vpn.section.title") }}</h3>
|
|
19
|
+
<IntegrationsVpnRoutePicker
|
|
20
|
+
:model-value="modelValue"
|
|
21
|
+
:kind="type.network.kind"
|
|
22
|
+
:id-prefix="idPrefix"
|
|
23
|
+
:error="error"
|
|
24
|
+
@update:model-value="(v) => emit('update:modelValue', v)"
|
|
25
|
+
@update:complete="(c) => emit('update:complete', c)"
|
|
26
|
+
/>
|
|
27
|
+
</section>
|
|
28
|
+
</template>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Network section of a credential form: how the credential reaches its system —
|
|
3
|
+
* over the public internet, or through a route of one of the tenant's VPN
|
|
4
|
+
* connections (specs/credential-network.md). Drawn only for a type that declares
|
|
5
|
+
* which of its fields a route stands in for; every other type has nothing to choose.
|
|
6
|
+
*/
|
|
7
|
+
import type { CredentialType, VpnRouteValue } from '../../types/integrations.js';
|
|
8
|
+
type __VLS_Props = {
|
|
9
|
+
type: CredentialType | null;
|
|
10
|
+
modelValue: VpnRouteValue | null;
|
|
11
|
+
idPrefix: string;
|
|
12
|
+
error?: string;
|
|
13
|
+
};
|
|
14
|
+
declare const _default: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
|
|
15
|
+
"update:complete": (args_0: boolean) => any;
|
|
16
|
+
"update:modelValue": (args_0: VpnRouteValue | null) => any;
|
|
17
|
+
}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
|
|
18
|
+
"onUpdate:complete"?: ((args_0: boolean) => any) | undefined;
|
|
19
|
+
"onUpdate:modelValue"?: ((args_0: VpnRouteValue | null) => any) | undefined;
|
|
20
|
+
}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
|
|
21
|
+
export default _default;
|
|
@@ -20,6 +20,7 @@ import { credentialState } from "../../utils/credentialState";
|
|
|
20
20
|
import { defaultsFor } from "../../utils/configDefaults";
|
|
21
21
|
import { applyingFields, settingApplies, staleKeys } from "../../utils/settingCondition";
|
|
22
22
|
import { coerceSettingValue } from "../../utils/settingValue";
|
|
23
|
+
import { credentialVpnBadge, isVpnRouteValue } from "../../utils/vpnRoute";
|
|
23
24
|
const props = defineProps({
|
|
24
25
|
field: { type: Object, required: true },
|
|
25
26
|
config: { type: Object, required: true },
|
|
@@ -110,6 +111,14 @@ const { credentials, loaded: credentialsLoaded, load: loadCredentials, refresh:
|
|
|
110
111
|
onMounted(() => {
|
|
111
112
|
void loadCredentials();
|
|
112
113
|
});
|
|
114
|
+
const { connections: vpnConnections, loaded: vpnLoaded, load: loadVpn } = useVpnConnections();
|
|
115
|
+
watch(
|
|
116
|
+
() => props.field.type === "credentials-ref" && credsForField(props.field).some((c) => c.network),
|
|
117
|
+
(needed) => {
|
|
118
|
+
if (needed) void loadVpn();
|
|
119
|
+
},
|
|
120
|
+
{ immediate: true }
|
|
121
|
+
);
|
|
113
122
|
function onCredentialPickerOpen(open) {
|
|
114
123
|
if (open) void refreshCredentials();
|
|
115
124
|
}
|
|
@@ -132,14 +141,15 @@ function credsForField(field) {
|
|
|
132
141
|
const bound = computed(() => {
|
|
133
142
|
const id = value.value;
|
|
134
143
|
const c = typeof id === "string" && id ? credentials.value.find((x) => x.id === id) : void 0;
|
|
135
|
-
return c ? { credential: c, state: credentialState(c) } : null;
|
|
144
|
+
return c ? { credential: c, state: credentialState(c), vpn: credentialVpnBadge(c.network, vpnConnections.value, vpnLoaded.value) } : null;
|
|
136
145
|
});
|
|
137
146
|
const credOptions = computed(
|
|
138
|
-
() => credsForField(props.field).map((c) => ({ credential: c, state: credentialState(c) }))
|
|
147
|
+
() => credsForField(props.field).map((c) => ({ credential: c, state: credentialState(c), vpn: credentialVpnBadge(c.network, vpnConnections.value, vpnLoaded.value) }))
|
|
139
148
|
);
|
|
140
149
|
const UNKNOWN_CREDENTIAL = computed(() => t("integration.nodeConfig.credential.unknown"));
|
|
141
150
|
const credTriggerLabel = computed(() => {
|
|
142
151
|
const label = controlName.value;
|
|
152
|
+
if (bound.value?.vpn) return t("integration.nodeConfig.credential.trigger.boundVpn", { label, name: bound.value.credential.name, state: bound.value.state.label, vpn: bound.value.vpn.text });
|
|
143
153
|
if (bound.value) return t("integration.nodeConfig.credential.trigger.bound", { label, name: bound.value.credential.name, state: bound.value.state.label });
|
|
144
154
|
if (value.value && credentialsLoaded.value) return t("integration.nodeConfig.credential.trigger.unknown", { label, unknown: UNKNOWN_CREDENTIAL.value });
|
|
145
155
|
return label;
|
|
@@ -394,8 +404,21 @@ function setArrayItemField(index, fieldKey, v) {
|
|
|
394
404
|
inside string values). Wins over expressionAllowed — an object value
|
|
395
405
|
in a text input would render as [object Object]. -->
|
|
396
406
|
<!-- queries parameter: filter builder -->
|
|
407
|
+
<!-- vpn-route — public internet, or a route of one of the tenant's VPN
|
|
408
|
+
connections, of the kind the node asks for (specs/credential-network.md
|
|
409
|
+
AC-8). Ahead of every other branch: its value is an object, which the JSON
|
|
410
|
+
and expression branches below would otherwise claim. -->
|
|
411
|
+
<IntegrationsVpnRoutePicker
|
|
412
|
+
v-if="field.type === 'vpn-route'"
|
|
413
|
+
:model-value="isVpnRouteValue(value) ? { connection: value.connection, route: value.route } : null"
|
|
414
|
+
:kind="field.routeKind ?? 'http'"
|
|
415
|
+
:id-prefix="`vpn-${field.key}-${rowIndex ?? 0}`"
|
|
416
|
+
:label="controlName"
|
|
417
|
+
@update:model-value="(v) => emit('set', field.key, v)"
|
|
418
|
+
/>
|
|
419
|
+
|
|
397
420
|
<IntegrationsQueryBuilder
|
|
398
|
-
v-if="isQueryField"
|
|
421
|
+
v-else-if="isQueryField"
|
|
399
422
|
:model-value="value"
|
|
400
423
|
@update:model-value="(v) => write(v)"
|
|
401
424
|
/>
|
|
@@ -651,6 +674,13 @@ function setArrayItemField(index, fieldKey, v) {
|
|
|
651
674
|
>
|
|
652
675
|
{{ bound.state.label }}
|
|
653
676
|
</span>
|
|
677
|
+
<span
|
|
678
|
+
v-if="bound.vpn"
|
|
679
|
+
class="shrink-0 rounded-full border px-1.5 py-px text-[10px] font-medium"
|
|
680
|
+
:class="bound.vpn.tone"
|
|
681
|
+
>
|
|
682
|
+
{{ bound.vpn.text }}
|
|
683
|
+
</span>
|
|
654
684
|
</span>
|
|
655
685
|
<!-- A stored id the tenant's credentials no longer contain: the
|
|
656
686
|
credential was deleted, and the field would otherwise look untouched
|
|
@@ -677,6 +707,13 @@ function setArrayItemField(index, fieldKey, v) {
|
|
|
677
707
|
:class="o.state.toneClass"
|
|
678
708
|
:title="o.state.message ?? void 0"
|
|
679
709
|
>{{ o.state.label }}</span>
|
|
710
|
+
<!-- Reached through a VPN route: the connection, and its state where it
|
|
711
|
+
is not carrying traffic (specs/credential-network.md AC-6). -->
|
|
712
|
+
<span
|
|
713
|
+
v-if="o.vpn"
|
|
714
|
+
class="shrink-0 rounded-full border px-1.5 py-px text-[10px] font-medium"
|
|
715
|
+
:class="o.vpn.tone"
|
|
716
|
+
>{{ o.vpn.text }}</span>
|
|
680
717
|
</span>
|
|
681
718
|
</SelectItem>
|
|
682
719
|
<div v-if="!credOptions.length" class="px-2 py-1.5 text-sm text-muted-foreground">
|
|
@@ -699,6 +736,12 @@ function setArrayItemField(index, fieldKey, v) {
|
|
|
699
736
|
<p v-else-if="bound?.state.key === 'failed' && bound.state.message" class="text-sm break-words text-rose-600">
|
|
700
737
|
{{ t("integration.nodeConfig.credential.state.failed", { message: bound.state.message }) }}
|
|
701
738
|
</p>
|
|
739
|
+
<p v-if="bound?.vpn?.broken && bound.credential.network" class="text-sm break-words text-rose-600">
|
|
740
|
+
{{ t("integration.vpn.reference.missing", { connection: bound.credential.network.connection, route: bound.credential.network.route }) }}
|
|
741
|
+
</p>
|
|
742
|
+
<p v-else-if="bound?.vpn && !bound.vpn.active" class="text-sm text-amber-600">
|
|
743
|
+
{{ t("integration.nodeConfig.credential.vpnInactive") }}
|
|
744
|
+
</p>
|
|
702
745
|
<IntegrationsCredentialCreateDialog
|
|
703
746
|
v-model:open="credCreateOpen"
|
|
704
747
|
:credential-type="field.credentialType"
|
|
@@ -4,6 +4,7 @@ import { Download as DownloadIcon, RefreshCircle as RefreshIcon } from "#solar-i
|
|
|
4
4
|
import { Badge, Button, Dialog, DialogContent, DialogHeader, DialogTitle } from "@revenexx/studio";
|
|
5
5
|
import { runStatusClass, runStatusLabel } from "../../utils/statusStyle";
|
|
6
6
|
import { isSkippedStep, skipReasonMessage, stepStatusLabel } from "../../utils/skippedStep";
|
|
7
|
+
import { stepError } from "../../utils/stepError";
|
|
7
8
|
import { IntegrationsApiError, errorReason } from "../../utils/apiError";
|
|
8
9
|
import { createPollBudget, SAFETY_POLL_MS } from "../../utils/pollBudget";
|
|
9
10
|
import { formatDuration } from "../../utils/formatDuration";
|
|
@@ -764,8 +765,8 @@ async function confirmAction() {
|
|
|
764
765
|
<span class="text-xs text-muted-foreground tabular-nums shrink-0">{{ fmtMs(st.duration_ms) }}</span>
|
|
765
766
|
<span v-if="!isSkippedStep(st)" class="text-[10px] text-muted-foreground shrink-0">{{ expandedStep === stepKey(st, i) ? "\u25BC" : "\u25B6" }}</span>
|
|
766
767
|
</button>
|
|
767
|
-
<div v-if="st.error" class="px-3 pb-2 -mt-1">
|
|
768
|
-
<div class="rounded-md border border-rose-500/20 bg-rose-500/5 px-2 py-1 text-xs text-rose-700 break-words">{{ st.error }}</div>
|
|
768
|
+
<div v-if="stepError(st.error)" class="px-3 pb-2 -mt-1">
|
|
769
|
+
<div class="rounded-md border border-rose-500/20 bg-rose-500/5 px-2 py-1 text-xs text-rose-700 break-words whitespace-pre-wrap" data-testid="step-error">{{ stepError(st.error).message }}</div>
|
|
769
770
|
</div>
|
|
770
771
|
<div v-if="expandedStep === stepKey(st, i)" class="px-3 pb-3 space-y-2">
|
|
771
772
|
<template v-if="hasPayload(st)">
|
|
@@ -20,10 +20,11 @@ const props = defineProps({
|
|
|
20
20
|
});
|
|
21
21
|
const emit = defineEmits(["installed", "update:loading", "update:installing"]);
|
|
22
22
|
const t = useIntegrationsT();
|
|
23
|
-
const { get, load, fetchDetail,
|
|
23
|
+
const { get, load, fetchDetail, checkRequirements, uncheckedReason, instantiate } = useIntegrationsTemplates();
|
|
24
24
|
const { keys: secretKeys, load: loadSecrets } = useIntegrationsSecrets();
|
|
25
25
|
const { ofType, refresh: refreshCredentials } = useIntegrationsCredentials();
|
|
26
26
|
const reqs = ref(null);
|
|
27
|
+
const reqsError = ref(null);
|
|
27
28
|
const loading = ref(true);
|
|
28
29
|
const installing = ref(false);
|
|
29
30
|
const error = ref(null);
|
|
@@ -35,40 +36,37 @@ watch(() => props.slug, async (slug) => {
|
|
|
35
36
|
const mine = ++run;
|
|
36
37
|
loading.value = true;
|
|
37
38
|
reqs.value = null;
|
|
39
|
+
reqsError.value = null;
|
|
38
40
|
error.value = null;
|
|
39
41
|
name.value = "";
|
|
40
42
|
for (const k of Object.keys(bindings)) delete bindings[k];
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
43
|
+
let answer = null;
|
|
44
|
+
let failure = null;
|
|
45
|
+
try {
|
|
46
|
+
await load();
|
|
47
|
+
if (mine !== run) return;
|
|
48
|
+
if (name.value === "") name.value = get(slug)?.name ?? "";
|
|
49
|
+
await Promise.all([fetchDetail(slug), refreshCredentials(), loadSecrets()]);
|
|
50
|
+
if (mine !== run) return;
|
|
51
|
+
answer = await checkRequirements(slug);
|
|
52
|
+
} catch (e) {
|
|
53
|
+
failure = uncheckedReason(e);
|
|
54
|
+
}
|
|
47
55
|
if (mine !== run) return;
|
|
48
56
|
reqs.value = answer;
|
|
57
|
+
reqsError.value = failure;
|
|
49
58
|
loading.value = false;
|
|
50
59
|
}, { immediate: true });
|
|
51
60
|
watch(loading, (v) => emit("update:loading", v), { immediate: true });
|
|
52
61
|
watch(installing, (v) => emit("update:installing", v));
|
|
53
62
|
onBeforeUnmount(() => emit("update:loading", false));
|
|
54
|
-
const tpl = computed(() => get(props.slug));
|
|
55
63
|
const credentialReqs = computed(() => reqs.value?.credentialTypes ?? []);
|
|
56
64
|
const secretReqs = computed(() => reqs.value?.secrets ?? []);
|
|
57
|
-
const secretChecks = computed(() => {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
label: s.key ?? t("integration.templates.detail.secrets.chosenAtInstall"),
|
|
63
|
-
present: s.exists || (s.key ? secretKeys.value.includes(s.key) : false)
|
|
64
|
-
}));
|
|
65
|
-
}
|
|
66
|
-
return (tpl.value?.requiredSecrets ?? []).map((key) => ({
|
|
67
|
-
key,
|
|
68
|
-
label: key,
|
|
69
|
-
present: secretKeys.value.includes(key)
|
|
70
|
-
}));
|
|
71
|
-
});
|
|
65
|
+
const secretChecks = computed(() => secretReqs.value.map((s) => ({
|
|
66
|
+
key: s.key,
|
|
67
|
+
label: s.key ?? t("integration.templates.detail.secrets.chosenAtInstall"),
|
|
68
|
+
present: s.exists || (s.key ? secretKeys.value.includes(s.key) : false)
|
|
69
|
+
})));
|
|
72
70
|
function slotsFor(usedBy) {
|
|
73
71
|
const all = credentialReqs.value.flatMap((r) => r.usedBy);
|
|
74
72
|
const seen = /* @__PURE__ */ new Set();
|
|
@@ -128,7 +126,13 @@ async function confirmInstall() {
|
|
|
128
126
|
<div class="text-mono text-muted-foreground border-b border-black/[0.06] pb-1.5">
|
|
129
127
|
{{ t("integration.templates.detail.credentials.title") }}
|
|
130
128
|
</div>
|
|
131
|
-
|
|
129
|
+
<!-- Two lines, not one sentence around {reason}: the platform's message
|
|
130
|
+
ends in a full stop or not, and a template cannot know which. -->
|
|
131
|
+
<div v-if="reqsError" class="rounded-md border-l-2 border-amber-500/40 bg-amber-500/5 px-2.5 py-1.5 text-sm text-amber-700" data-testid="template-install-unchecked">
|
|
132
|
+
<p>{{ t("integration.templates.detail.setup.unchecked", { reason: reqsError }) }}</p>
|
|
133
|
+
<p>{{ t("integration.templates.detail.install.stillInstallable") }}</p>
|
|
134
|
+
</div>
|
|
135
|
+
<p v-else-if="!credentialReqs.length" class="text-sm text-muted-foreground italic">
|
|
132
136
|
{{ t("integration.templates.detail.install.noCredentials") }}
|
|
133
137
|
</p>
|
|
134
138
|
<div v-for="req in credentialReqs" :key="req.slug" class="space-y-2">
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { VpnRouteKind, VpnRouteValue } from '../../types/integrations.js';
|
|
2
|
+
type __VLS_Props = {
|
|
3
|
+
modelValue: VpnRouteValue | null;
|
|
4
|
+
/** The kind of route on offer — the credential type's declared kind, or the setting's `routeKind`. */
|
|
5
|
+
kind: VpnRouteKind;
|
|
6
|
+
/** Prefix for the ids of the controls, unique on the page. */
|
|
7
|
+
idPrefix: string;
|
|
8
|
+
/** The setting this picker belongs to, prefixed to every control's name; absent in the credential forms. */
|
|
9
|
+
label?: string;
|
|
10
|
+
/** Why the last save refused the choice, if it did. */
|
|
11
|
+
error?: string;
|
|
12
|
+
};
|
|
13
|
+
declare const _default: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
|
|
14
|
+
"update:complete": (args_0: boolean) => any;
|
|
15
|
+
"update:modelValue": (args_0: VpnRouteValue | null) => any;
|
|
16
|
+
}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
|
|
17
|
+
"onUpdate:complete"?: ((args_0: boolean) => any) | undefined;
|
|
18
|
+
"onUpdate:modelValue"?: ((args_0: VpnRouteValue | null) => any) | undefined;
|
|
19
|
+
}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
|
|
20
|
+
export default _default;
|