@revenexx/studio-integrations 3.2.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 +10 -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/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/composables/useIntegrationsCredentials.d.ts +15 -9
- package/dist/runtime/composables/useIntegrationsCredentials.js +9 -4
- package/dist/runtime/composables/useVpnConnections.d.ts +19 -0
- package/dist/runtime/composables/useVpnConnections.js +28 -0
- package/dist/runtime/i18n/messages/de.json +35 -0
- package/dist/runtime/i18n/messages/en.json +35 -0
- package/dist/runtime/pages/integrations/credentials.vue +67 -15
- package/dist/runtime/types/integrations.d.ts +58 -1
- package/dist/runtime/utils/configDefaults.d.ts +5 -2
- package/dist/runtime/utils/vpnRoute.d.ts +69 -0
- package/dist/runtime/utils/vpnRoute.js +79 -0
- package/package.json +1 -1
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
|
|
3
|
+
import {
|
|
4
|
+
Button,
|
|
5
|
+
Label,
|
|
6
|
+
RadioGroup,
|
|
7
|
+
RadioGroupItem,
|
|
8
|
+
Select,
|
|
9
|
+
SelectContent,
|
|
10
|
+
SelectItem,
|
|
11
|
+
SelectTrigger,
|
|
12
|
+
SelectValue
|
|
13
|
+
} from "@revenexx/studio";
|
|
14
|
+
import {
|
|
15
|
+
connectionLabel,
|
|
16
|
+
connectionStatusLabel,
|
|
17
|
+
connectionStatusTone,
|
|
18
|
+
isHttpsTarget,
|
|
19
|
+
resolveVpnReference,
|
|
20
|
+
routesOfKind
|
|
21
|
+
} from "../../utils/vpnRoute";
|
|
22
|
+
import { servedPaths } from "../../utils/servedPaths";
|
|
23
|
+
const props = defineProps({
|
|
24
|
+
modelValue: { type: [Object, null], required: true },
|
|
25
|
+
kind: { type: String, required: true },
|
|
26
|
+
idPrefix: { type: String, required: true },
|
|
27
|
+
label: { type: String, required: false },
|
|
28
|
+
error: { type: String, required: false }
|
|
29
|
+
});
|
|
30
|
+
const emit = defineEmits(["update:modelValue", "update:complete"]);
|
|
31
|
+
const t = useIntegrationsT();
|
|
32
|
+
const { connections, loaded, loading, error: loadError, load } = useVpnConnections();
|
|
33
|
+
onMounted(() => {
|
|
34
|
+
void load(true);
|
|
35
|
+
});
|
|
36
|
+
const viaVpn = ref(!!props.modelValue);
|
|
37
|
+
const pickedConnection = ref(props.modelValue?.connection ?? "");
|
|
38
|
+
watch(() => props.modelValue ? `${props.modelValue.connection}\0${props.modelValue.route}` : "", () => {
|
|
39
|
+
const v = props.modelValue;
|
|
40
|
+
if (v) {
|
|
41
|
+
viaVpn.value = true;
|
|
42
|
+
pickedConnection.value = v.connection;
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
const current = computed(() => props.modelValue?.connection === pickedConnection.value ? props.modelValue : null);
|
|
46
|
+
const complete = computed(() => !viaVpn.value || !!current.value);
|
|
47
|
+
watch(complete, (c) => emit("update:complete", c), { immediate: true });
|
|
48
|
+
onUnmounted(() => emit("update:complete", true));
|
|
49
|
+
function name(key) {
|
|
50
|
+
const base = t(key);
|
|
51
|
+
return props.label ? t("integration.vpn.picker.ariaFor", { label: props.label, control: base }) : base;
|
|
52
|
+
}
|
|
53
|
+
function setMode(mode) {
|
|
54
|
+
if (mode === "vpn") {
|
|
55
|
+
viaVpn.value = true;
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
viaVpn.value = false;
|
|
59
|
+
pickedConnection.value = "";
|
|
60
|
+
emit("update:modelValue", null);
|
|
61
|
+
}
|
|
62
|
+
const connection = computed(() => connections.value.find((c) => c.name === pickedConnection.value));
|
|
63
|
+
const routes = computed(() => routesOfKind(connection.value, props.kind));
|
|
64
|
+
function pickConnection(v) {
|
|
65
|
+
const next = String(v ?? "");
|
|
66
|
+
if (next === pickedConnection.value) return;
|
|
67
|
+
pickedConnection.value = next;
|
|
68
|
+
}
|
|
69
|
+
function pickRoute(v) {
|
|
70
|
+
const route = String(v ?? "");
|
|
71
|
+
if (!route || !pickedConnection.value) return;
|
|
72
|
+
emit("update:modelValue", { connection: pickedConnection.value, route });
|
|
73
|
+
}
|
|
74
|
+
const reference = computed(() => current.value && loaded.value ? resolveVpnReference(connections.value, current.value) : null);
|
|
75
|
+
const chosenRoute = computed(() => reference.value?.state === "ok" ? reference.value.route : void 0);
|
|
76
|
+
const noConnections = computed(() => loaded.value && !connections.value.length);
|
|
77
|
+
const pending = computed(() => {
|
|
78
|
+
if (complete.value) return "";
|
|
79
|
+
const stored = props.modelValue;
|
|
80
|
+
if (!stored) return t("integration.vpn.picker.pending.public");
|
|
81
|
+
const via = connections.value.find((c) => c.name === stored.connection);
|
|
82
|
+
return t("integration.vpn.picker.pending.route", { route: stored.route, connection: via ? connectionLabel(via) : stored.connection });
|
|
83
|
+
});
|
|
84
|
+
const VPN_PAGE = "/integrations/vpn";
|
|
85
|
+
const router = useRouter();
|
|
86
|
+
function vpnPageServed() {
|
|
87
|
+
return servedPaths(router.getRoutes().map((r) => r.path)).has(VPN_PAGE);
|
|
88
|
+
}
|
|
89
|
+
</script>
|
|
90
|
+
|
|
91
|
+
<template>
|
|
92
|
+
<div class="space-y-2">
|
|
93
|
+
<RadioGroup
|
|
94
|
+
:model-value="viaVpn ? 'vpn' : 'public'"
|
|
95
|
+
:aria-label="name('integration.vpn.picker.group')"
|
|
96
|
+
class="gap-1.5"
|
|
97
|
+
@update:model-value="(v) => setMode(String(v))"
|
|
98
|
+
>
|
|
99
|
+
<div class="flex items-center gap-2">
|
|
100
|
+
<RadioGroupItem :id="`${idPrefix}-public`" value="public" />
|
|
101
|
+
<Label :for="`${idPrefix}-public`" class="font-normal">{{ t("integration.vpn.picker.public") }}</Label>
|
|
102
|
+
</div>
|
|
103
|
+
<div class="flex items-center gap-2">
|
|
104
|
+
<!-- Not offered where there is nothing to pick from — unless a stored route
|
|
105
|
+
already points through one, which has to stay readable as such. -->
|
|
106
|
+
<RadioGroupItem :id="`${idPrefix}-vpn`" value="vpn" :disabled="noConnections && !modelValue" />
|
|
107
|
+
<Label :for="`${idPrefix}-vpn`" class="font-normal">{{ t("integration.vpn.picker.vpn") }}</Label>
|
|
108
|
+
</div>
|
|
109
|
+
</RadioGroup>
|
|
110
|
+
|
|
111
|
+
<p v-if="noConnections" class="text-sm text-muted-foreground">
|
|
112
|
+
{{ t("integration.vpn.empty.text") }}
|
|
113
|
+
<NuxtLink v-if="vpnPageServed()" :to="VPN_PAGE" class="font-medium text-violet-600 hover:underline">{{ t("integration.vpn.empty.link") }}</NuxtLink>
|
|
114
|
+
<template v-else>{{ t("integration.vpn.empty.elsewhere") }}</template>
|
|
115
|
+
</p>
|
|
116
|
+
|
|
117
|
+
<template v-if="viaVpn">
|
|
118
|
+
<p v-if="loading && !loaded" class="text-sm text-muted-foreground">{{ t("integration.vpn.loading") }}</p>
|
|
119
|
+
<div v-else-if="loadError" role="alert" class="flex items-center gap-2 text-sm text-rose-700">
|
|
120
|
+
<span>{{ loadError }}</span>
|
|
121
|
+
<Button variant="outline" size="sm" class="h-7" @click="load(true)">{{ t("integration.common.action.retry") }}</Button>
|
|
122
|
+
</div>
|
|
123
|
+
|
|
124
|
+
<template v-if="loaded">
|
|
125
|
+
<div class="space-y-1.5">
|
|
126
|
+
<Label :for="`${idPrefix}-connection`">{{ t("integration.vpn.picker.connection") }}</Label>
|
|
127
|
+
<Select :model-value="pickedConnection" @update:model-value="pickConnection">
|
|
128
|
+
<SelectTrigger :id="`${idPrefix}-connection`" :aria-label="name('integration.vpn.picker.connection')" class="bg-white">
|
|
129
|
+
<SelectValue :placeholder="t('integration.vpn.picker.connection.placeholder')">
|
|
130
|
+
<span v-if="connection" class="flex min-w-0 items-center gap-1.5">
|
|
131
|
+
<span class="min-w-0 truncate">{{ connectionLabel(connection) }}</span>
|
|
132
|
+
<span class="shrink-0 rounded-full border px-1.5 py-px text-[10px] font-medium" :class="connectionStatusTone(String(connection.status))">
|
|
133
|
+
{{ connectionStatusLabel(String(connection.status)) }}
|
|
134
|
+
</span>
|
|
135
|
+
</span>
|
|
136
|
+
<span v-else-if="pickedConnection" class="text-rose-600">{{ pickedConnection }}</span>
|
|
137
|
+
<span v-else class="text-muted-foreground">{{ t("integration.vpn.picker.connection.placeholder") }}</span>
|
|
138
|
+
</SelectValue>
|
|
139
|
+
</SelectTrigger>
|
|
140
|
+
<SelectContent>
|
|
141
|
+
<SelectItem v-for="c in connections" :key="c.name" :value="c.name">
|
|
142
|
+
<span class="flex items-center gap-1.5">
|
|
143
|
+
<span class="min-w-0 truncate">{{ connectionLabel(c) }}</span>
|
|
144
|
+
<span class="shrink-0 rounded-full border px-1.5 py-px text-[10px] font-medium" :class="connectionStatusTone(String(c.status))">
|
|
145
|
+
{{ connectionStatusLabel(String(c.status)) }}
|
|
146
|
+
</span>
|
|
147
|
+
</span>
|
|
148
|
+
</SelectItem>
|
|
149
|
+
</SelectContent>
|
|
150
|
+
</Select>
|
|
151
|
+
</div>
|
|
152
|
+
|
|
153
|
+
<div v-if="pickedConnection" class="space-y-1.5">
|
|
154
|
+
<Label :for="`${idPrefix}-route`">{{ t("integration.vpn.picker.route") }}</Label>
|
|
155
|
+
<Select :model-value="current?.route ?? ''" @update:model-value="pickRoute">
|
|
156
|
+
<SelectTrigger :id="`${idPrefix}-route`" :aria-label="name('integration.vpn.picker.route')" class="bg-white">
|
|
157
|
+
<SelectValue :placeholder="t('integration.vpn.picker.route.placeholder')">
|
|
158
|
+
<span v-if="chosenRoute">{{ chosenRoute.name }} — {{ chosenRoute.host }}:{{ chosenRoute.port }}</span>
|
|
159
|
+
<span v-else-if="current?.route" class="text-rose-600">{{ current.route }}</span>
|
|
160
|
+
<span v-else class="text-muted-foreground">{{ t("integration.vpn.picker.route.placeholder") }}</span>
|
|
161
|
+
</SelectValue>
|
|
162
|
+
</SelectTrigger>
|
|
163
|
+
<SelectContent>
|
|
164
|
+
<SelectItem v-for="r in routes" :key="r.name" :value="r.name">
|
|
165
|
+
{{ r.name }} — {{ r.host }}:{{ r.port }}
|
|
166
|
+
</SelectItem>
|
|
167
|
+
<div v-if="!routes.length" class="px-2 py-1.5 text-sm text-muted-foreground">
|
|
168
|
+
{{ t(`integration.vpn.picker.route.empty.${kind}`) }}
|
|
169
|
+
</div>
|
|
170
|
+
</SelectContent>
|
|
171
|
+
</Select>
|
|
172
|
+
</div>
|
|
173
|
+
|
|
174
|
+
<!-- A reference the tenant's connections no longer resolve. Said in words,
|
|
175
|
+
because the choice would otherwise look merely unfinished while every
|
|
176
|
+
run through it fails. -->
|
|
177
|
+
<p v-if="pending" class="text-sm text-amber-700">
|
|
178
|
+
{{ pending }}
|
|
179
|
+
</p>
|
|
180
|
+
<p v-if="reference?.state === 'missing' && current" class="text-sm text-rose-700">
|
|
181
|
+
{{ t("integration.vpn.reference.missing", { connection: current.connection, route: current.route }) }}
|
|
182
|
+
</p>
|
|
183
|
+
<p v-else-if="connection && connection.status !== 'active'" class="text-sm text-amber-700">
|
|
184
|
+
{{ t("integration.vpn.reference.inactive", { connection: connectionLabel(connection), status: connectionStatusLabel(String(connection.status)) }) }}
|
|
185
|
+
</p>
|
|
186
|
+
<p v-if="isHttpsTarget(chosenRoute)" class="text-sm text-muted-foreground">
|
|
187
|
+
{{ t("integration.vpn.httpsNote") }}
|
|
188
|
+
</p>
|
|
189
|
+
</template>
|
|
190
|
+
</template>
|
|
191
|
+
|
|
192
|
+
<p v-if="error" class="text-sm text-rose-700">{{ error }}</p>
|
|
193
|
+
</div>
|
|
194
|
+
</template>
|
|
@@ -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;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Credential } from '../types/integrations.js';
|
|
1
|
+
import type { Credential, CredentialNetwork } from '../types/integrations.js';
|
|
2
2
|
import { IntegrationsApiError } from '../utils/apiError.js';
|
|
3
3
|
/**
|
|
4
4
|
* Typed, per-tenant credential instances, backed by the Integrations service
|
|
@@ -6,15 +6,19 @@ import { IntegrationsApiError } from '../utils/apiError.js';
|
|
|
6
6
|
* carries `public_config` (non-secret fields) + `status` only.
|
|
7
7
|
*
|
|
8
8
|
* GET /credentials[?type=slug] → list
|
|
9
|
-
* POST /credentials → create { credential_type_slug, name, config }
|
|
9
|
+
* POST /credentials → create { credential_type_slug, name, config, network? }
|
|
10
10
|
* GET /credentials/{id} → show (masked)
|
|
11
|
-
* PATCH /credentials/{id} → update { name?, config? } (blank secret = keep,
|
|
12
|
-
* unless the same edit changes any other value
|
|
13
|
-
* then 422 at config.<secret>, PO-765)
|
|
11
|
+
* PATCH /credentials/{id} → update { name?, config?, network? } (blank secret = keep,
|
|
12
|
+
* unless the same edit changes any other value
|
|
13
|
+
* or the network: then 422 at config.<secret>, PO-765)
|
|
14
14
|
* DELETE /credentials/{id} → remove
|
|
15
|
-
* POST /credentials/test → inline test { credential_type_slug, config }
|
|
16
|
-
* POST /credentials/{id}/test → test { config? } → { ok, message, recorded, last_test_at, last_test_ok }
|
|
15
|
+
* POST /credentials/test → inline test { credential_type_slug, config, network? }
|
|
16
|
+
* POST /credentials/{id}/test → test { config?, network? } → { ok, message, recorded, last_test_at, last_test_ok }
|
|
17
17
|
* POST /credentials/{id}/oauth/authorize-url → { authorize_url }
|
|
18
|
+
*
|
|
19
|
+
* `network` is `{ kind: 'vpn', connection, route }` or `null` (public internet), and
|
|
20
|
+
* is sent only for a type that declares what a VPN route replaces (PO-829); left out,
|
|
21
|
+
* the service keeps what is stored.
|
|
18
22
|
*/
|
|
19
23
|
/**
|
|
20
24
|
* `message` is what the credential type said about the test, whichever way it went —
|
|
@@ -65,13 +69,15 @@ export declare function useIntegrationsCredentials(): {
|
|
|
65
69
|
credential_type_slug: string;
|
|
66
70
|
name: string;
|
|
67
71
|
config: Record<string, unknown>;
|
|
72
|
+
network?: CredentialNetwork | null;
|
|
68
73
|
}) => Promise<Credential>;
|
|
69
74
|
update: (id: string, input: {
|
|
70
75
|
name?: string;
|
|
71
76
|
config?: Record<string, unknown>;
|
|
77
|
+
network?: CredentialNetwork | null;
|
|
72
78
|
}) => Promise<Credential>;
|
|
73
79
|
remove: (id: string) => Promise<void>;
|
|
74
|
-
testInline: (credentialTypeSlug: string, config: Record<string, unknown
|
|
75
|
-
test: (id: string, config?: Record<string, unknown
|
|
80
|
+
testInline: (credentialTypeSlug: string, config: Record<string, unknown>, network?: CredentialNetwork | null) => Promise<CredentialTestResult>;
|
|
81
|
+
test: (id: string, config?: Record<string, unknown>, network?: CredentialNetwork | null) => Promise<CredentialTestResult>;
|
|
76
82
|
oauthAuthorizeUrl: (id: string) => Promise<string>;
|
|
77
83
|
};
|
|
@@ -49,19 +49,24 @@ export function useIntegrationsCredentials() {
|
|
|
49
49
|
await api.del(`/credentials/${encodeURIComponent(id)}`);
|
|
50
50
|
credentials.value = credentials.value.filter((c) => c.id !== id);
|
|
51
51
|
}
|
|
52
|
-
async function testInline(credentialTypeSlug, config) {
|
|
52
|
+
async function testInline(credentialTypeSlug, config, network) {
|
|
53
53
|
try {
|
|
54
|
-
const
|
|
54
|
+
const body = { credential_type_slug: credentialTypeSlug, config };
|
|
55
|
+
if (network !== void 0) body.network = network;
|
|
56
|
+
const res = await api.post("/credentials/test", body);
|
|
55
57
|
return res?.ok === false ? res : { ok: true, message: res?.message };
|
|
56
58
|
} catch (e) {
|
|
57
59
|
return { ok: false, error: errorReason(e, t("integration.credentials.error.testFailed")) };
|
|
58
60
|
}
|
|
59
61
|
}
|
|
60
|
-
async function test(id, config) {
|
|
62
|
+
async function test(id, config, network) {
|
|
61
63
|
try {
|
|
64
|
+
const body = {};
|
|
65
|
+
if (config) body.config = config;
|
|
66
|
+
if (network !== void 0) body.network = network;
|
|
62
67
|
const res = await api.post(
|
|
63
68
|
`/credentials/${encodeURIComponent(id)}/test`,
|
|
64
|
-
|
|
69
|
+
Object.keys(body).length ? body : void 0
|
|
65
70
|
);
|
|
66
71
|
if (!config && res?.ok === false && res.recorded === false) return { ...res, untested: true };
|
|
67
72
|
return res?.ok === false ? res : { ok: true, message: res?.message, recorded: res?.recorded };
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { VpnConnection } from '../types/integrations.js';
|
|
2
|
+
/**
|
|
3
|
+
* The tenant's VPN connections and their routes, as the Integrations service
|
|
4
|
+
* reports them (`GET /api/v1/vpn-connections`, PO-825). Each carries the status
|
|
5
|
+
* Console derives for it; only an `active` one carries traffic.
|
|
6
|
+
*
|
|
7
|
+
* Read by the credential forms' Network section, the credential picker's VPN badge
|
|
8
|
+
* and the `vpn-route` node setting — so it is one shared list, loaded once per page
|
|
9
|
+
* and re-read on request. A tenant without any answers an empty list; a Console
|
|
10
|
+
* that cannot be reached answers 502, which lands in `error` rather than reading as
|
|
11
|
+
* "no connections".
|
|
12
|
+
*/
|
|
13
|
+
export declare function useVpnConnections(): {
|
|
14
|
+
connections: any;
|
|
15
|
+
loading: any;
|
|
16
|
+
error: any;
|
|
17
|
+
loaded: any;
|
|
18
|
+
load: (force?: boolean) => Promise<VpnConnection[]>;
|
|
19
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { useIntegrationsApi } from "./integrationsApi.js";
|
|
2
|
+
import { useIntegrationsT } from "./useIntegrationsT.js";
|
|
3
|
+
import { errorReason } from "../utils/apiError.js";
|
|
4
|
+
import { unwrapList } from "../utils/apiEnvelope.js";
|
|
5
|
+
import { useLoadGate } from "./useLoadGate.js";
|
|
6
|
+
export function useVpnConnections() {
|
|
7
|
+
const connections = useState("integrations.vpnConnections", () => []);
|
|
8
|
+
const loading = useState("integrations.vpnConnections.loading", () => false);
|
|
9
|
+
const error = useState("integrations.vpnConnections.error", () => null);
|
|
10
|
+
const loaded = useState("integrations.vpnConnections.loaded", () => false);
|
|
11
|
+
const gate = useLoadGate("integrations.vpnConnections", { loading, loaded });
|
|
12
|
+
const api = useIntegrationsApi();
|
|
13
|
+
const t = useIntegrationsT();
|
|
14
|
+
async function load(force = false) {
|
|
15
|
+
if (!await gate.claim(force)) return connections.value;
|
|
16
|
+
error.value = null;
|
|
17
|
+
try {
|
|
18
|
+
connections.value = unwrapList(await api.get("/vpn-connections"));
|
|
19
|
+
loaded.value = true;
|
|
20
|
+
} catch (e) {
|
|
21
|
+
error.value = errorReason(e, t("integration.vpn.error.load"));
|
|
22
|
+
} finally {
|
|
23
|
+
gate.release();
|
|
24
|
+
}
|
|
25
|
+
return connections.value;
|
|
26
|
+
}
|
|
27
|
+
return { connections, loading, error, loaded, load };
|
|
28
|
+
}
|
|
@@ -574,8 +574,10 @@
|
|
|
574
574
|
"integration.nodeConfig.credential.state.failed": "Letzter Test fehlgeschlagen: {message}",
|
|
575
575
|
"integration.nodeConfig.credential.state.needsReauth": "Diese Zugangsdaten müssen neu verbunden werden.",
|
|
576
576
|
"integration.nodeConfig.credential.trigger.bound": "{label}: {name}, {state}",
|
|
577
|
+
"integration.nodeConfig.credential.trigger.boundVpn": "{label}: {name}, {state}, {vpn}",
|
|
577
578
|
"integration.nodeConfig.credential.trigger.unknown": "{label}: {unknown}",
|
|
578
579
|
"integration.nodeConfig.credential.unknown": "Unbekannte Zugangsdaten — bitte erneut auswählen",
|
|
580
|
+
"integration.nodeConfig.credential.vpnInactive": "Ausführungen mit diesen Zugangsdaten schlagen fehl, bis ihre VPN-Verbindung wieder aktiv ist.",
|
|
579
581
|
"integration.nodeConfig.dynamicField.currentNotResolved": "aktueller Wert nicht in den geladenen Optionen",
|
|
580
582
|
"integration.nodeConfig.dynamicField.empty": "keine Optionen verfügbar",
|
|
581
583
|
"integration.nodeConfig.dynamicField.emptyFiltered": "keine Optionen passen zum Filter",
|
|
@@ -1231,6 +1233,39 @@
|
|
|
1231
1233
|
"integration.templates.trigger.manual": "Bei Bedarf ausführen",
|
|
1232
1234
|
"integration.templates.trigger.webhook": "Webhook",
|
|
1233
1235
|
"integration.triggers.error.loadFailed": "Trigger konnten nicht geladen werden.",
|
|
1236
|
+
"integration.vpn.badge.aria": "{name}: {badge}",
|
|
1237
|
+
"integration.vpn.badge.missing": "VPN-Route fehlt",
|
|
1238
|
+
"integration.vpn.badge.named": "VPN · {connection}",
|
|
1239
|
+
"integration.vpn.badge.withState": "VPN · {connection} ({status})",
|
|
1240
|
+
"integration.vpn.empty.elsewhere": "VPN-Verbindungen werden im Cockpit eingerichtet.",
|
|
1241
|
+
"integration.vpn.empty.link": "Jetzt einrichten",
|
|
1242
|
+
"integration.vpn.empty.text": "Für diesen Mandanten ist noch keine VPN-Verbindung eingerichtet.",
|
|
1243
|
+
"integration.vpn.error.load": "Die VPN-Verbindungen konnten nicht geladen werden.",
|
|
1244
|
+
"integration.vpn.httpsNote": "Das Ziel wird über HTTPS erreicht; sein Zertifikat wird am Gateway nicht geprüft.",
|
|
1245
|
+
"integration.vpn.loading": "VPN-Verbindungen werden geladen…",
|
|
1246
|
+
"integration.vpn.picker.ariaFor": "{label}: {control}",
|
|
1247
|
+
"integration.vpn.picker.connection": "VPN-Verbindung",
|
|
1248
|
+
"integration.vpn.picker.connection.placeholder": "Verbindung auswählen",
|
|
1249
|
+
"integration.vpn.picker.group": "Netzwerk",
|
|
1250
|
+
"integration.vpn.picker.pending.public": "Noch keine Route gewählt. Bis eine gewählt ist, gilt das öffentliche Internet.",
|
|
1251
|
+
"integration.vpn.picker.pending.route": "Noch keine Route gewählt. Bis eine gewählt ist, gilt {route} über {connection}.",
|
|
1252
|
+
"integration.vpn.picker.public": "Öffentliches Internet",
|
|
1253
|
+
"integration.vpn.picker.route": "Route",
|
|
1254
|
+
"integration.vpn.picker.route.empty.http": "Diese Verbindung hat keine HTTP-Routen.",
|
|
1255
|
+
"integration.vpn.picker.route.empty.tcp": "Diese Verbindung hat keine TCP-Routen.",
|
|
1256
|
+
"integration.vpn.picker.route.placeholder": "Route auswählen",
|
|
1257
|
+
"integration.vpn.picker.vpn": "Über VPN-Verbindung",
|
|
1258
|
+
"integration.vpn.reference.inactive": "{connection} ist nicht aktiv ({status}) — Ausführungen darüber schlagen fehl, bis die Verbindung wieder aktiv ist.",
|
|
1259
|
+
"integration.vpn.reference.missing": "Die VPN-Route {connection}/{route} existiert nicht mehr. Wählen Sie eine andere Route oder das öffentliche Internet.",
|
|
1260
|
+
"integration.vpn.section.title": "Netzwerk",
|
|
1261
|
+
"integration.vpn.status.active": "Aktiv",
|
|
1262
|
+
"integration.vpn.status.disabled": "Deaktiviert",
|
|
1263
|
+
"integration.vpn.status.down": "Getrennt",
|
|
1264
|
+
"integration.vpn.status.failed": "Fehlgeschlagen",
|
|
1265
|
+
"integration.vpn.status.provisioning": "Wird eingerichtet",
|
|
1266
|
+
"integration.vpn.status.standby": "Bereitschaft",
|
|
1267
|
+
"integration.vpn.status.unknown": "Unbekannt",
|
|
1268
|
+
"integration.vpn.target": "{target} über {connection}",
|
|
1234
1269
|
"integration.workflowFile.action.export": "Als Datei exportieren",
|
|
1235
1270
|
"integration.workflowFile.action.import": "Aus Datei importieren…",
|
|
1236
1271
|
"integration.workflowFile.count.both": "{nodes}, {triggers}",
|
|
@@ -574,8 +574,10 @@
|
|
|
574
574
|
"integration.nodeConfig.credential.state.failed": "Last test failed: {message}",
|
|
575
575
|
"integration.nodeConfig.credential.state.needsReauth": "This credential needs reconnect.",
|
|
576
576
|
"integration.nodeConfig.credential.trigger.bound": "{label}: {name}, {state}",
|
|
577
|
+
"integration.nodeConfig.credential.trigger.boundVpn": "{label}: {name}, {state}, {vpn}",
|
|
577
578
|
"integration.nodeConfig.credential.trigger.unknown": "{label}: {unknown}",
|
|
578
579
|
"integration.nodeConfig.credential.unknown": "Unknown credential — pick again",
|
|
580
|
+
"integration.nodeConfig.credential.vpnInactive": "Runs that use this credential fail until its VPN connection is active again.",
|
|
579
581
|
"integration.nodeConfig.dynamicField.currentNotResolved": "current value not in resolved options",
|
|
580
582
|
"integration.nodeConfig.dynamicField.empty": "no options available",
|
|
581
583
|
"integration.nodeConfig.dynamicField.emptyFiltered": "no options match the filter",
|
|
@@ -1231,6 +1233,39 @@
|
|
|
1231
1233
|
"integration.templates.trigger.manual": "Run on demand",
|
|
1232
1234
|
"integration.templates.trigger.webhook": "Webhook",
|
|
1233
1235
|
"integration.triggers.error.loadFailed": "Could not load triggers.",
|
|
1236
|
+
"integration.vpn.badge.aria": "{name}: {badge}",
|
|
1237
|
+
"integration.vpn.badge.missing": "VPN route missing",
|
|
1238
|
+
"integration.vpn.badge.named": "VPN · {connection}",
|
|
1239
|
+
"integration.vpn.badge.withState": "VPN · {connection} ({status})",
|
|
1240
|
+
"integration.vpn.empty.elsewhere": "VPN connections are set up in the cockpit.",
|
|
1241
|
+
"integration.vpn.empty.link": "Set one up",
|
|
1242
|
+
"integration.vpn.empty.text": "No VPN connection is set up for this tenant yet.",
|
|
1243
|
+
"integration.vpn.error.load": "The VPN connections could not be loaded.",
|
|
1244
|
+
"integration.vpn.httpsNote": "The target is reached over HTTPS, and its certificate is not verified at the gateway.",
|
|
1245
|
+
"integration.vpn.loading": "Loading VPN connections…",
|
|
1246
|
+
"integration.vpn.picker.ariaFor": "{label}: {control}",
|
|
1247
|
+
"integration.vpn.picker.connection": "VPN connection",
|
|
1248
|
+
"integration.vpn.picker.connection.placeholder": "Pick a connection",
|
|
1249
|
+
"integration.vpn.picker.group": "Network",
|
|
1250
|
+
"integration.vpn.picker.pending.public": "No route chosen yet. Until one is, the public internet is used.",
|
|
1251
|
+
"integration.vpn.picker.pending.route": "No route chosen yet. Until one is, {route} via {connection} is used.",
|
|
1252
|
+
"integration.vpn.picker.public": "Public internet",
|
|
1253
|
+
"integration.vpn.picker.route": "Route",
|
|
1254
|
+
"integration.vpn.picker.route.empty.http": "This connection has no HTTP routes.",
|
|
1255
|
+
"integration.vpn.picker.route.empty.tcp": "This connection has no TCP routes.",
|
|
1256
|
+
"integration.vpn.picker.route.placeholder": "Pick a route",
|
|
1257
|
+
"integration.vpn.picker.vpn": "Through a VPN connection",
|
|
1258
|
+
"integration.vpn.reference.inactive": "{connection} is not active ({status}) — runs through it fail until it is active again.",
|
|
1259
|
+
"integration.vpn.reference.missing": "The VPN route {connection}/{route} no longer exists. Choose another route or the public internet.",
|
|
1260
|
+
"integration.vpn.section.title": "Network",
|
|
1261
|
+
"integration.vpn.status.active": "Active",
|
|
1262
|
+
"integration.vpn.status.disabled": "Disabled",
|
|
1263
|
+
"integration.vpn.status.down": "Down",
|
|
1264
|
+
"integration.vpn.status.failed": "Failed",
|
|
1265
|
+
"integration.vpn.status.provisioning": "Provisioning",
|
|
1266
|
+
"integration.vpn.status.standby": "Standby",
|
|
1267
|
+
"integration.vpn.status.unknown": "Unknown",
|
|
1268
|
+
"integration.vpn.target": "{target} via {connection}",
|
|
1234
1269
|
"integration.workflowFile.action.export": "Export as file",
|
|
1235
1270
|
"integration.workflowFile.action.import": "Import from file…",
|
|
1236
1271
|
"integration.workflowFile.count.both": "{nodes}, {triggers}",
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script setup>
|
|
2
|
-
import { computed, nextTick, onMounted, reactive, ref } from "vue";
|
|
2
|
+
import { computed, nextTick, onMounted, reactive, ref, watch } from "vue";
|
|
3
3
|
import {
|
|
4
4
|
RefreshCircle as Loader2,
|
|
5
5
|
AddCircle as Plus,
|
|
@@ -35,9 +35,11 @@ import { apiRefusal, errorReason } from "../../utils/apiError";
|
|
|
35
35
|
import { CREDENTIAL_STATES, credentialState, credentialStateLabel, providerStepWord } from "../../utils/credentialState";
|
|
36
36
|
import { hasRevenexxSource } from "../../utils/revenexxCredentialSource";
|
|
37
37
|
import { resolveLocalized } from "../../utils/localize";
|
|
38
|
+
import { defaultsFor } from "../../utils/configDefaults";
|
|
38
39
|
import { fromModelValue, toItemValue, toModelValue } from "../../utils/selectOptions";
|
|
39
40
|
import { keepOpenBeside } from "../../utils/dialog";
|
|
40
41
|
import { testOutcomeView } from "../../utils/credentialTest";
|
|
42
|
+
import { credentialNetworkPayload, credentialVpnBadge, replacedFieldTexts } from "../../utils/vpnRoute";
|
|
41
43
|
const t = useIntegrationsT();
|
|
42
44
|
useHead({ title: () => t("integration.credentials.list.head.title") });
|
|
43
45
|
const { credentials, loading, error, load, create, update, remove, testInline, test, oauthAuthorizeUrl } = useIntegrationsCredentials();
|
|
@@ -45,6 +47,13 @@ const { types, load: loadTypes, bySlug } = useCredentialTypes();
|
|
|
45
47
|
onMounted(async () => {
|
|
46
48
|
await Promise.all([load(), loadTypes()]);
|
|
47
49
|
});
|
|
50
|
+
const { connections: vpnConnections, loaded: vpnLoaded, load: loadVpn } = useVpnConnections();
|
|
51
|
+
watch(() => credentials.value.some((c) => c.network), (has) => {
|
|
52
|
+
if (has) void loadVpn();
|
|
53
|
+
}, { immediate: true });
|
|
54
|
+
function vpnBadge(c) {
|
|
55
|
+
return credentialVpnBadge(c.network, vpnConnections.value, vpnLoaded.value);
|
|
56
|
+
}
|
|
48
57
|
function typeFor(slug) {
|
|
49
58
|
return bySlug(slug);
|
|
50
59
|
}
|
|
@@ -108,12 +117,19 @@ const testing = ref(false);
|
|
|
108
117
|
const testResult = ref(null);
|
|
109
118
|
const saveRefusal = ref(null);
|
|
110
119
|
const sourceActive = ref(false);
|
|
120
|
+
const network = ref(null);
|
|
121
|
+
const networkComplete = ref(true);
|
|
122
|
+
const replaced = computed(() => replacedFieldTexts(pickedType.value, network.value, vpnConnections.value));
|
|
123
|
+
function networkPayload() {
|
|
124
|
+
return pickedType.value?.network ? credentialNetworkPayload(network.value) : void 0;
|
|
125
|
+
}
|
|
111
126
|
const isEdit = computed(() => editing.value !== null);
|
|
112
127
|
function fieldsOf(t2) {
|
|
113
128
|
return t2?.fields ?? [];
|
|
114
129
|
}
|
|
115
130
|
const drawnKeys = computed(() => [
|
|
116
131
|
"name",
|
|
132
|
+
...pickedType.value?.network ? ["network"] : [],
|
|
117
133
|
...sourceActive.value ? [] : fieldsOf(pickedType.value).map((f) => `config.${f.key}`)
|
|
118
134
|
]);
|
|
119
135
|
const refusal = computed(() => apiRefusal(saveRefusal.value, drawnKeys.value));
|
|
@@ -131,20 +147,30 @@ async function focusFirstRefusedField() {
|
|
|
131
147
|
await nextTick();
|
|
132
148
|
const key = drawnKeys.value.find((k) => k in fieldErrors.value);
|
|
133
149
|
if (!key) return;
|
|
134
|
-
const id = key === "name" ? "cred-name" : `cred-field-${key.slice("config.".length)}`;
|
|
150
|
+
const id = key === "name" ? "cred-name" : key === "network" ? "cred-network-public" : `cred-field-${key.slice("config.".length)}`;
|
|
135
151
|
document.getElementById(id)?.focus();
|
|
136
152
|
}
|
|
137
153
|
function clearRefusal() {
|
|
138
154
|
saveRefusal.value = null;
|
|
139
155
|
}
|
|
156
|
+
function pickCreateType(picked) {
|
|
157
|
+
pickedType.value = picked;
|
|
158
|
+
form.name = resolveLocalized(picked.name);
|
|
159
|
+
form.config = defaultsFor(fieldsOf(picked));
|
|
160
|
+
network.value = null;
|
|
161
|
+
testResult.value = null;
|
|
162
|
+
sourceActive.value = false;
|
|
163
|
+
clearRefusal();
|
|
164
|
+
}
|
|
140
165
|
function openCreate(t2) {
|
|
141
166
|
editing.value = null;
|
|
142
167
|
pickedType.value = t2 ?? null;
|
|
143
168
|
form.name = "";
|
|
144
|
-
form.config =
|
|
169
|
+
form.config = defaultsFor(fieldsOf(pickedType.value));
|
|
145
170
|
testResult.value = null;
|
|
146
171
|
clearRefusal();
|
|
147
172
|
sourceActive.value = false;
|
|
173
|
+
network.value = null;
|
|
148
174
|
dialogOpen.value = true;
|
|
149
175
|
}
|
|
150
176
|
const detailOpen = ref(false);
|
|
@@ -180,7 +206,7 @@ async function runTest() {
|
|
|
180
206
|
testResult.value = null;
|
|
181
207
|
clearRefusal();
|
|
182
208
|
try {
|
|
183
|
-
testResult.value = isEdit.value && editing.value ? await test(editing.value.id) : await testInline(pickedType.value.slug, buildConfigPayload());
|
|
209
|
+
testResult.value = isEdit.value && editing.value ? await test(editing.value.id) : await testInline(pickedType.value.slug, buildConfigPayload(), networkPayload());
|
|
184
210
|
} finally {
|
|
185
211
|
testing.value = false;
|
|
186
212
|
}
|
|
@@ -191,14 +217,15 @@ async function submit() {
|
|
|
191
217
|
clearRefusal();
|
|
192
218
|
try {
|
|
193
219
|
if (isEdit.value && editing.value) {
|
|
194
|
-
await update(editing.value.id, { name: form.name.trim(), config: buildConfigPayload() });
|
|
220
|
+
await update(editing.value.id, { name: form.name.trim(), config: buildConfigPayload(), network: networkPayload() });
|
|
195
221
|
dialogOpen.value = false;
|
|
196
222
|
toast.success(t("integration.credentials.toast.updated"));
|
|
197
223
|
} else {
|
|
198
224
|
const created = await create({
|
|
199
225
|
credential_type_slug: pickedType.value.slug,
|
|
200
226
|
name: form.name.trim(),
|
|
201
|
-
config: buildConfigPayload()
|
|
227
|
+
config: buildConfigPayload(),
|
|
228
|
+
network: networkPayload()
|
|
202
229
|
});
|
|
203
230
|
dialogOpen.value = false;
|
|
204
231
|
toast.success(t("integration.credentials.toast.created"));
|
|
@@ -285,7 +312,18 @@ async function confirmDelete() {
|
|
|
285
312
|
</template>
|
|
286
313
|
|
|
287
314
|
<template #cell-name="{ row: c }">
|
|
288
|
-
<
|
|
315
|
+
<div class="flex min-w-0 items-center gap-1.5">
|
|
316
|
+
<span class="text-sm truncate">{{ c.name }}</span>
|
|
317
|
+
<!-- Reached through a VPN route: which connection, and — where the
|
|
318
|
+
reference no longer resolves — that it is broken, before a run says
|
|
319
|
+
so (specs/credential-network.md AC-7). -->
|
|
320
|
+
<span
|
|
321
|
+
v-if="vpnBadge(c)"
|
|
322
|
+
class="shrink-0 rounded-full border px-1.5 py-px text-[10px] font-medium"
|
|
323
|
+
:class="vpnBadge(c).tone"
|
|
324
|
+
:aria-label="t('integration.vpn.badge.aria', { name: c.name, badge: vpnBadge(c).text })"
|
|
325
|
+
>{{ vpnBadge(c).text }}</span>
|
|
326
|
+
</div>
|
|
289
327
|
</template>
|
|
290
328
|
|
|
291
329
|
<template #cell-status="{ row: c }">
|
|
@@ -353,11 +391,7 @@ async function confirmDelete() {
|
|
|
353
391
|
<IntegrationsCredentialTypePicker
|
|
354
392
|
v-if="!pickedType"
|
|
355
393
|
:types="types"
|
|
356
|
-
@pick="
|
|
357
|
-
pickedType = picked;
|
|
358
|
-
form.name = resolveLocalized(picked.name);
|
|
359
|
-
clearRefusal();
|
|
360
|
-
}"
|
|
394
|
+
@pick="pickCreateType"
|
|
361
395
|
/>
|
|
362
396
|
|
|
363
397
|
<!-- Step 2: dynamic form -->
|
|
@@ -390,15 +424,33 @@ async function confirmDelete() {
|
|
|
390
424
|
@apply="(c2) => Object.assign(form.config, c2)"
|
|
391
425
|
/>
|
|
392
426
|
|
|
427
|
+
<!-- Public internet or a VPN route — for a type that says which of its
|
|
428
|
+
fields a route stands in for (specs/credential-network.md). -->
|
|
429
|
+
<IntegrationsCredentialNetworkSection
|
|
430
|
+
v-model="network"
|
|
431
|
+
:type="pickedType"
|
|
432
|
+
id-prefix="cred-network"
|
|
433
|
+
:error="fieldErrors.network"
|
|
434
|
+
@update:complete="(c2) => networkComplete = c2"
|
|
435
|
+
/>
|
|
436
|
+
|
|
393
437
|
<!-- Each label names its own control (`for`/`id`): without it a screen
|
|
394
438
|
reader reads the field as unlabelled, and the label text cannot be
|
|
395
439
|
used to reach it at all. -->
|
|
396
440
|
<div v-for="f in fieldsOf(pickedType)" v-show="!sourceActive" :key="f.key" class="space-y-1.5">
|
|
397
441
|
<Label :for="`cred-field-${f.key}`">{{ resolveLocalized(f.label) }}{{ f.required ? " *" : "" }}</Label>
|
|
398
442
|
|
|
443
|
+
<!-- stood in for by the chosen VPN route: what it reaches, not editable -->
|
|
444
|
+
<Input
|
|
445
|
+
v-if="replaced[f.key]"
|
|
446
|
+
:id="`cred-field-${f.key}`"
|
|
447
|
+
:model-value="replaced[f.key]"
|
|
448
|
+
readonly
|
|
449
|
+
class="bg-muted/40"
|
|
450
|
+
/>
|
|
399
451
|
<!-- secret -->
|
|
400
452
|
<Input
|
|
401
|
-
v-if="f.type === 'secret'"
|
|
453
|
+
v-else-if="f.type === 'secret'"
|
|
402
454
|
:id="`cred-field-${f.key}`"
|
|
403
455
|
:model-value="form.config[f.key] ?? ''"
|
|
404
456
|
type="password"
|
|
@@ -492,12 +544,12 @@ async function confirmDelete() {
|
|
|
492
544
|
</Button>
|
|
493
545
|
<span v-else />
|
|
494
546
|
<div class="flex items-center gap-2">
|
|
495
|
-
<Button variant="outline" :disabled="saving || testing" @click="runTest">
|
|
547
|
+
<Button variant="outline" :disabled="saving || testing || !networkComplete" @click="runTest">
|
|
496
548
|
<Loader2 v-if="testing" :size="14" class="mr-1 animate-spin" />
|
|
497
549
|
<TestIcon v-else :size="14" class="mr-1" />
|
|
498
550
|
{{ t("integration.credentials.shared.action.testConnection") }}
|
|
499
551
|
</Button>
|
|
500
|
-
<Button :disabled="saving || !form.name.trim()" @click="submit">
|
|
552
|
+
<Button :disabled="saving || !form.name.trim() || !networkComplete" @click="submit">
|
|
501
553
|
<Loader2 v-if="saving" :size="14" class="mr-1 animate-spin" />
|
|
502
554
|
{{ t("integration.common.action.save") }}
|
|
503
555
|
</Button>
|