@revenexx/studio-integrations 1.4.0 → 1.4.1
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 +31 -0
- package/dist/module.json +1 -1
- package/dist/runtime/components/Integrations/CredentialDetailDialog.vue +61 -29
- package/dist/runtime/pages/integrations/credentials.vue +40 -18
- package/dist/runtime/utils/apiError.d.ts +35 -8
- package/dist/runtime/utils/apiError.js +12 -12
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,36 @@
|
|
|
1
1
|
# @revenexx/studio-integrations
|
|
2
2
|
|
|
3
|
+
## 1.4.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 7bb1913: A refused credential save states each reason once: at its field, or below the form
|
|
8
|
+
when no field can show it (PO-477).
|
|
9
|
+
|
|
10
|
+
**What a field can carry is marked there and nowhere else.** Until now the service's
|
|
11
|
+
lead reason was also repeated in a red box above the Save button, so the one sentence
|
|
12
|
+
the operator had already read was the one thing on the surface they had to read twice
|
|
13
|
+
— and it crowded out the reasons that had no other place to appear.
|
|
14
|
+
|
|
15
|
+
**What no field can carry is what the box is for now.** The service keys a refusal by
|
|
16
|
+
validation rule, and several of those shapes name nothing either dialog draws an input
|
|
17
|
+
for: the access data taken as a whole, a field the credential type never handed out, a
|
|
18
|
+
field hidden while a managed revenexx resource is being reused, or a refusal that is
|
|
19
|
+
not field-keyed at all, such as a conflict or a gateway error. Any of them was silent
|
|
20
|
+
unless the service happened to lead with it. All of them are now stated below the form,
|
|
21
|
+
every message of them, still without the service's count of everything else it found.
|
|
22
|
+
|
|
23
|
+
**The refusal also puts the operator in the first marked field.** The box was what
|
|
24
|
+
guaranteed a refusal could not be rendered below the fold of a form that scrolls; the
|
|
25
|
+
focus is what guarantees it now, and it leaves the cursor where the correction has to
|
|
26
|
+
be typed.
|
|
27
|
+
|
|
28
|
+
Both dialogs read the refusal through one seam, so the create-and-edit dialog on
|
|
29
|
+
Credentials and the credential detail dialog answer the same way.
|
|
30
|
+
|
|
31
|
+
Promised behaviour: `specs/credential-create.md` AC-8 and AC-11,
|
|
32
|
+
`specs/credential-detail.md` AC-6.
|
|
33
|
+
|
|
3
34
|
## 1.4.0
|
|
4
35
|
|
|
5
36
|
### Minor Changes
|
package/dist/module.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script setup>
|
|
2
|
-
import { computed, reactive, ref, watch } from "vue";
|
|
2
|
+
import { computed, nextTick, reactive, ref, watch } from "vue";
|
|
3
3
|
import {
|
|
4
4
|
CheckCircle as CheckCircle2,
|
|
5
5
|
CloseCircle as XCircle,
|
|
@@ -30,7 +30,7 @@ import {
|
|
|
30
30
|
SelectValue
|
|
31
31
|
} from "@revenexx/studio";
|
|
32
32
|
import { toast } from "../../utils/notify";
|
|
33
|
-
import {
|
|
33
|
+
import { apiRefusal, errorReason } from "../../utils/apiError";
|
|
34
34
|
import { credentialState, relativeTime } from "../../utils/credentialState";
|
|
35
35
|
import { hasRevenexxSource } from "../../utils/revenexxCredentialSource";
|
|
36
36
|
import { resolveLocalized } from "../../utils/localize";
|
|
@@ -53,14 +53,31 @@ const saving = ref(false);
|
|
|
53
53
|
const deleting = ref(false);
|
|
54
54
|
const testing = ref(false);
|
|
55
55
|
const liveTest = ref(null);
|
|
56
|
-
const
|
|
57
|
-
const
|
|
56
|
+
const saveRefusal = ref(null);
|
|
57
|
+
const drawnKeys = computed(() => [
|
|
58
|
+
"name",
|
|
59
|
+
...sourceActive.value ? [] : fieldsOf(props.type).map((f) => `config.${f.key}`)
|
|
60
|
+
]);
|
|
61
|
+
const refusal = computed(() => apiRefusal(saveRefusal.value, drawnKeys.value));
|
|
62
|
+
const fieldErrors = computed(() => refusal.value.atFields);
|
|
63
|
+
const summaryReasons = computed(() => {
|
|
64
|
+
if (!saveRefusal.value) return [];
|
|
65
|
+
if (refusal.value.unshown.length) return refusal.value.unshown.map((u) => u.message);
|
|
66
|
+
if (Object.keys(refusal.value.atFields).length) return [];
|
|
67
|
+
return [errorReason(saveRefusal.value, t("integration.credentials.error.save"))];
|
|
68
|
+
});
|
|
58
69
|
function configFieldAria(key) {
|
|
59
70
|
return fieldErrors.value[`config.${key}`] ? { "aria-invalid": "true", "aria-describedby": `detail-field-${key}-error` } : {};
|
|
60
71
|
}
|
|
72
|
+
async function focusFirstRefusedField() {
|
|
73
|
+
await nextTick();
|
|
74
|
+
const key = drawnKeys.value.find((k) => k in fieldErrors.value);
|
|
75
|
+
if (!key) return;
|
|
76
|
+
const id = key === "name" ? "detail-cred-name" : `detail-field-${key.slice("config.".length)}`;
|
|
77
|
+
document.getElementById(id)?.focus();
|
|
78
|
+
}
|
|
61
79
|
function clearRefusal() {
|
|
62
|
-
|
|
63
|
-
fieldErrors.value = {};
|
|
80
|
+
saveRefusal.value = null;
|
|
64
81
|
}
|
|
65
82
|
const connecting = ref(false);
|
|
66
83
|
const typeName = computed(() => resolveLocalized(props.type?.name) || props.credential?.credential_type_slug || "");
|
|
@@ -103,6 +120,7 @@ async function runTest() {
|
|
|
103
120
|
if (!props.credential) return;
|
|
104
121
|
testing.value = true;
|
|
105
122
|
liveTest.value = null;
|
|
123
|
+
clearRefusal();
|
|
106
124
|
try {
|
|
107
125
|
liveTest.value = await test(props.credential.id);
|
|
108
126
|
await load(true);
|
|
@@ -121,9 +139,9 @@ async function save() {
|
|
|
121
139
|
emit("update:open", false);
|
|
122
140
|
toast.success(t("integration.credentials.toast.updated"));
|
|
123
141
|
} catch (e) {
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
toast.error(
|
|
142
|
+
saveRefusal.value = e;
|
|
143
|
+
void focusFirstRefusedField();
|
|
144
|
+
toast.error(errorReason(e, t("integration.credentials.error.save")));
|
|
127
145
|
} finally {
|
|
128
146
|
saving.value = false;
|
|
129
147
|
}
|
|
@@ -182,8 +200,11 @@ async function connect() {
|
|
|
182
200
|
<!-- Standard cockpit tab bar (violet underline) -->
|
|
183
201
|
<TabBar :model-value="tab" :tabs="tabs" @update:model-value="(k) => tab = k" />
|
|
184
202
|
|
|
185
|
-
<!-- Tab 1: Credentials
|
|
186
|
-
|
|
203
|
+
<!-- Tab 1: Credentials. A `template` rather than a wrapping element: the
|
|
204
|
+
scrolling pane and the block below it are siblings, as they were when the
|
|
205
|
+
pane was the whole tab, so nothing about the spacing around them moves. -->
|
|
206
|
+
<template v-if="tab === 'credentials'">
|
|
207
|
+
<div class="space-y-5 max-h-[62vh] overflow-y-auto pr-1 pt-4">
|
|
187
208
|
<!-- Connection state — the headline answer: are these credentials correct? -->
|
|
188
209
|
<div class="rounded-lg border p-3" :class="state.toneClass">
|
|
189
210
|
<div class="flex items-center gap-2">
|
|
@@ -243,7 +264,10 @@ async function connect() {
|
|
|
243
264
|
<IntegrationsCredentialRevenexxSource
|
|
244
265
|
v-if="hasRevenexxSource(type?.slug)"
|
|
245
266
|
:type="type"
|
|
246
|
-
@update:active="(v) =>
|
|
267
|
+
@update:active="(v) => {
|
|
268
|
+
sourceActive = v;
|
|
269
|
+
clearRefusal();
|
|
270
|
+
}"
|
|
247
271
|
@apply="(c) => Object.assign(form.config, c)"
|
|
248
272
|
/>
|
|
249
273
|
|
|
@@ -313,27 +337,35 @@ async function connect() {
|
|
|
313
337
|
{{ liveTest.ok ? liveTest.message ?? t("integration.credentials.shared.test.ok") : liveTest.error ?? liveTest.message ?? t("integration.credentials.shared.test.failed") }}
|
|
314
338
|
</div>
|
|
315
339
|
|
|
316
|
-
|
|
317
|
-
the field where the service keyed it. -->
|
|
318
|
-
<div v-if="saveError" role="alert" class="rounded-md bg-rose-500/10 px-3 py-2 text-sm text-rose-700">
|
|
319
|
-
{{ saveError }}
|
|
320
|
-
</div>
|
|
340
|
+
</div>
|
|
321
341
|
|
|
322
|
-
<div class="flex items-center gap-2 pt-1">
|
|
323
|
-
<div class="flex-1" />
|
|
324
|
-
<Button variant="outline" :disabled="testing || saving" @click="runTest">
|
|
325
|
-
<Loader2 v-if="testing" :size="14" class="mr-1 animate-spin" />
|
|
326
|
-
<TestIcon v-else :size="14" class="mr-1" />
|
|
327
|
-
{{ t("integration.credentials.shared.action.testConnection") }}
|
|
328
|
-
</Button>
|
|
329
|
-
<Button :disabled="saving || !form.name.trim()" @click="save">
|
|
330
|
-
<Loader2 v-if="saving" :size="14" class="mr-1 animate-spin" />
|
|
331
|
-
{{ t("integration.common.action.save") }}
|
|
332
|
-
</Button>
|
|
333
|
-
</div>
|
|
334
342
|
</div>
|
|
335
343
|
|
|
344
|
+
<!-- Outside the scrolling pane on purpose, the way the create dialog has it:
|
|
345
|
+
a refusal puts the operator in the first marked field, which scrolls that
|
|
346
|
+
pane back to the top — and a summary rendered at the end of the form would
|
|
347
|
+
be carried out of sight by the very focus meant to make the refusal
|
|
348
|
+
visible. What is marked at a field is not repeated here; what has no field
|
|
349
|
+
to be marked at would otherwise be silent. -->
|
|
350
|
+
<div v-if="summaryReasons.length" role="alert" class="mt-3 space-y-1 rounded-md bg-rose-500/10 px-3 py-2 text-sm text-rose-700">
|
|
351
|
+
<!-- Keyed by position: two undrawn keys can be refused with the same
|
|
352
|
+
sentence, and a duplicate key is how one of them goes unrendered. -->
|
|
353
|
+
<p v-for="(reason, i) in summaryReasons" :key="i">{{ reason }}</p>
|
|
354
|
+
</div>
|
|
355
|
+
|
|
356
|
+
<div class="flex items-center gap-2 pt-3">
|
|
357
|
+
<div class="flex-1" />
|
|
358
|
+
<Button variant="outline" :disabled="testing || saving" @click="runTest">
|
|
359
|
+
<Loader2 v-if="testing" :size="14" class="mr-1 animate-spin" />
|
|
360
|
+
<TestIcon v-else :size="14" class="mr-1" />
|
|
361
|
+
{{ t("integration.credentials.shared.action.testConnection") }}
|
|
362
|
+
</Button>
|
|
363
|
+
<Button :disabled="saving || !form.name.trim()" @click="save">
|
|
364
|
+
<Loader2 v-if="saving" :size="14" class="mr-1 animate-spin" />
|
|
365
|
+
{{ t("integration.common.action.save") }}
|
|
366
|
+
</Button>
|
|
336
367
|
</div>
|
|
368
|
+
</template>
|
|
337
369
|
|
|
338
370
|
<!-- Tab 2: Details -->
|
|
339
371
|
<div v-else class="space-y-5 max-h-[62vh] overflow-y-auto pr-1 pt-4">
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script setup>
|
|
2
|
-
import { computed, onMounted, reactive, ref } from "vue";
|
|
2
|
+
import { computed, nextTick, onMounted, reactive, ref } from "vue";
|
|
3
3
|
import {
|
|
4
4
|
RefreshCircle as Loader2,
|
|
5
5
|
AddCircle as Plus,
|
|
@@ -31,7 +31,7 @@ import {
|
|
|
31
31
|
SelectValue
|
|
32
32
|
} from "@revenexx/studio";
|
|
33
33
|
import { toast } from "../../utils/notify";
|
|
34
|
-
import {
|
|
34
|
+
import { apiRefusal, errorReason } from "../../utils/apiError";
|
|
35
35
|
import { CREDENTIAL_STATES, credentialState, credentialStateLabel } from "../../utils/credentialState";
|
|
36
36
|
import { hasRevenexxSource } from "../../utils/revenexxCredentialSource";
|
|
37
37
|
import { resolveLocalized } from "../../utils/localize";
|
|
@@ -92,19 +92,36 @@ const form = reactive({ name: "", config: {} });
|
|
|
92
92
|
const saving = ref(false);
|
|
93
93
|
const testing = ref(false);
|
|
94
94
|
const testResult = ref(null);
|
|
95
|
-
const
|
|
96
|
-
const fieldErrors = ref({});
|
|
95
|
+
const saveRefusal = ref(null);
|
|
97
96
|
const sourceActive = ref(false);
|
|
98
97
|
const isEdit = computed(() => editing.value !== null);
|
|
99
98
|
function fieldsOf(t2) {
|
|
100
99
|
return t2?.fields ?? [];
|
|
101
100
|
}
|
|
101
|
+
const drawnKeys = computed(() => [
|
|
102
|
+
"name",
|
|
103
|
+
...sourceActive.value ? [] : fieldsOf(pickedType.value).map((f) => `config.${f.key}`)
|
|
104
|
+
]);
|
|
105
|
+
const refusal = computed(() => apiRefusal(saveRefusal.value, drawnKeys.value));
|
|
106
|
+
const fieldErrors = computed(() => refusal.value.atFields);
|
|
107
|
+
const summaryReasons = computed(() => {
|
|
108
|
+
if (!saveRefusal.value) return [];
|
|
109
|
+
if (refusal.value.unshown.length) return refusal.value.unshown.map((u) => u.message);
|
|
110
|
+
if (Object.keys(refusal.value.atFields).length) return [];
|
|
111
|
+
return [errorReason(saveRefusal.value, t("integration.credentials.error.save"))];
|
|
112
|
+
});
|
|
102
113
|
function configFieldAria(key) {
|
|
103
114
|
return fieldErrors.value[`config.${key}`] ? { "aria-invalid": "true", "aria-describedby": `cred-field-${key}-error` } : {};
|
|
104
115
|
}
|
|
116
|
+
async function focusFirstRefusedField() {
|
|
117
|
+
await nextTick();
|
|
118
|
+
const key = drawnKeys.value.find((k) => k in fieldErrors.value);
|
|
119
|
+
if (!key) return;
|
|
120
|
+
const id = key === "name" ? "cred-name" : `cred-field-${key.slice("config.".length)}`;
|
|
121
|
+
document.getElementById(id)?.focus();
|
|
122
|
+
}
|
|
105
123
|
function clearRefusal() {
|
|
106
|
-
|
|
107
|
-
fieldErrors.value = {};
|
|
124
|
+
saveRefusal.value = null;
|
|
108
125
|
}
|
|
109
126
|
function openCreate(t2) {
|
|
110
127
|
editing.value = null;
|
|
@@ -176,9 +193,9 @@ async function submit() {
|
|
|
176
193
|
}
|
|
177
194
|
}
|
|
178
195
|
} catch (e) {
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
toast.error(
|
|
196
|
+
saveRefusal.value = e;
|
|
197
|
+
void focusFirstRefusedField();
|
|
198
|
+
toast.error(errorReason(e, t("integration.credentials.error.save")));
|
|
182
199
|
} finally {
|
|
183
200
|
saving.value = false;
|
|
184
201
|
}
|
|
@@ -351,7 +368,10 @@ async function confirmDelete() {
|
|
|
351
368
|
<IntegrationsCredentialRevenexxSource
|
|
352
369
|
v-if="hasRevenexxSource(pickedType?.slug)"
|
|
353
370
|
:type="pickedType"
|
|
354
|
-
@update:active="(v) =>
|
|
371
|
+
@update:active="(v) => {
|
|
372
|
+
sourceActive = v;
|
|
373
|
+
clearRefusal();
|
|
374
|
+
}"
|
|
355
375
|
@apply="(c2) => Object.assign(form.config, c2)"
|
|
356
376
|
/>
|
|
357
377
|
|
|
@@ -438,14 +458,16 @@ async function confirmDelete() {
|
|
|
438
458
|
|
|
439
459
|
</div>
|
|
440
460
|
|
|
441
|
-
<!-- The save was refused
|
|
442
|
-
button sits in the footer below, so a summary at
|
|
443
|
-
scrolls — five fields already do — would be a
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
gateway error —
|
|
447
|
-
<div v-if="
|
|
448
|
-
|
|
461
|
+
<!-- The save was refused for something no field shows. Outside the scrolling
|
|
462
|
+
form on purpose: the Save button sits in the footer below, so a summary at
|
|
463
|
+
the end of a form that scrolls — five fields already do — would be a
|
|
464
|
+
refusal rendered under the fold. What is marked at a field is not repeated
|
|
465
|
+
here; what has no field to be marked at — a service-side field, a conflict,
|
|
466
|
+
a gateway error — would otherwise be silent. -->
|
|
467
|
+
<div v-if="summaryReasons.length && pickedType" role="alert" class="space-y-1 rounded-md bg-rose-500/10 px-3 py-2 text-sm text-rose-700">
|
|
468
|
+
<!-- Keyed by position: two undrawn keys can be refused with the same
|
|
469
|
+
sentence, and a duplicate key is how one of them goes unrendered. -->
|
|
470
|
+
<p v-for="(reason, i) in summaryReasons" :key="i">{{ reason }}</p>
|
|
449
471
|
</div>
|
|
450
472
|
|
|
451
473
|
<DialogFooter v-if="pickedType" class="sm:justify-between">
|
|
@@ -35,21 +35,48 @@ export declare function apiValidationIssues(err: unknown): {
|
|
|
35
35
|
path: string;
|
|
36
36
|
message: string;
|
|
37
37
|
}[];
|
|
38
|
+
/** One message the service keyed to a field, as it keyed it. */
|
|
39
|
+
export interface RefusalMessage {
|
|
40
|
+
key: string;
|
|
41
|
+
message: string;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* A refusal split by what the form on screen can show of it.
|
|
45
|
+
*
|
|
46
|
+
* `atFields` is what goes under an input, keyed the way the form draws it; `unshown`
|
|
47
|
+
* is everything left over — the reasons that have no input to stand at, in the order
|
|
48
|
+
* the service listed them.
|
|
49
|
+
*/
|
|
50
|
+
export interface Refusal {
|
|
51
|
+
atFields: Record<string, string>;
|
|
52
|
+
unshown: RefusalMessage[];
|
|
53
|
+
}
|
|
38
54
|
/**
|
|
39
|
-
*
|
|
55
|
+
* A rejection split into the messages a form can show at a field and the messages it
|
|
56
|
+
* cannot, given the keys that form actually draws.
|
|
40
57
|
*
|
|
41
58
|
* The counterpart to `apiValidationIssues` above, for the ordinary case that has
|
|
42
59
|
* nothing to do with the workflow blob: a form field the service refused, named the
|
|
43
60
|
* way the form names it. Laravel answers a `422` with `errors` keyed by field and a
|
|
44
|
-
* list of messages per key; the first message is the one a person reads,
|
|
45
|
-
* the one kept.
|
|
61
|
+
* list of messages per key; at a field the first message is the one a person reads,
|
|
62
|
+
* so that is the one kept.
|
|
63
|
+
*
|
|
64
|
+
* **`drawn` is the form's own list, in the order the form draws it** — the name, then
|
|
65
|
+
* the fields of the chosen type, and nothing while those are hidden behind another
|
|
66
|
+
* source. A key absent from it has no input to be shown at, whether because no form
|
|
67
|
+
* draws that field (`config` as a whole, a field the type never declared, a key that
|
|
68
|
+
* is not a field at all) or because this form is currently drawing something else.
|
|
69
|
+
* Both cases go to `unshown`, which is what the surface states below the form: a
|
|
70
|
+
* refusal nobody can see is the same as no answer at all (PO-275, PO-477).
|
|
71
|
+
*
|
|
72
|
+
* A container that is only at fault because one of its members is — `config` beside
|
|
73
|
+
* `config.authKey`, which the service reports as two — is dropped wherever the member
|
|
74
|
+
* is reported too: it says the same thing twice, and the member carries it either way.
|
|
46
75
|
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
* else about a field they then have to find (PO-275). Keys that address the blob are
|
|
50
|
-
* left out: those belong to `apiValidationIssues`, which resolves them to nodes.
|
|
76
|
+
* Keys that address the blob are left out: those belong to `apiValidationIssues`,
|
|
77
|
+
* which resolves them to nodes. No credential surface can receive one.
|
|
51
78
|
*/
|
|
52
|
-
export declare function
|
|
79
|
+
export declare function apiRefusal(err: unknown, drawn: readonly string[]): Refusal;
|
|
53
80
|
/**
|
|
54
81
|
* The reason a rejection gives, without the count of everything else it found.
|
|
55
82
|
*
|
|
@@ -18,20 +18,20 @@ export function apiValidationIssues(err) {
|
|
|
18
18
|
}
|
|
19
19
|
return issues;
|
|
20
20
|
}
|
|
21
|
-
export function
|
|
22
|
-
|
|
21
|
+
export function apiRefusal(err, drawn) {
|
|
22
|
+
const refusal = { atFields: {}, unshown: [] };
|
|
23
|
+
if (!(err instanceof IntegrationsApiError) || err.status !== 422) return refusal;
|
|
23
24
|
const errors = err.body?.errors;
|
|
24
|
-
if (!errors || typeof errors !== "object") return
|
|
25
|
-
const
|
|
26
|
-
for (const
|
|
27
|
-
if (
|
|
28
|
-
const
|
|
29
|
-
if (
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
if (Object.keys(out).some((other) => other.startsWith(`${key}.`))) delete out[key];
|
|
25
|
+
if (!errors || typeof errors !== "object") return refusal;
|
|
26
|
+
const keys = Object.keys(errors).filter((key) => key !== "blob" && !key.startsWith("blob."));
|
|
27
|
+
for (const key of keys) {
|
|
28
|
+
if (keys.some((other) => other.startsWith(`${key}.`))) continue;
|
|
29
|
+
const messages = (Array.isArray(errors[key]) ? errors[key] : [errors[key]]).filter((message) => message !== void 0 && message !== null).map(String);
|
|
30
|
+
if (!messages.length) continue;
|
|
31
|
+
if (drawn.includes(key)) refusal.atFields[key] = messages[0];
|
|
32
|
+
else refusal.unshown.push(...messages.map((message) => ({ key, message })));
|
|
33
33
|
}
|
|
34
|
-
return
|
|
34
|
+
return refusal;
|
|
35
35
|
}
|
|
36
36
|
export function errorReason(err, fallback) {
|
|
37
37
|
const raw = err?.message?.trim();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@revenexx/studio-integrations",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Integration Studio (core: workflows, node editor, runs, schedules, credentials, secrets, audit) as a standalone, publishable Nuxt module.",
|
|
6
6
|
"repository": {
|