@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.
@@ -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>) => Promise<CredentialTestResult>;
75
- test: (id: string, config?: Record<string, unknown>) => Promise<CredentialTestResult>;
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 res = await api.post("/credentials/test", { credential_type_slug: credentialTypeSlug, config });
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
- config ? { config } : void 0
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
- <span class="text-sm truncate">{{ c.name }}</span>
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="(picked) => {
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>