@cat-factory/app 0.110.3 → 0.111.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/app/components/board/AddTaskModal.vue +30 -2
- package/app/components/board/RecurringPipelineModal.vue +29 -2
- package/app/components/bootstrap/BootstrapModal.vue +18 -1
- package/app/components/panels/DecisionModal.vue +33 -5
- package/app/components/panels/InspectorPanel.vue +4 -0
- package/app/components/panels/inspector/ServiceTestSecrets.vue +264 -0
- package/app/composables/api/testSecrets.ts +36 -0
- package/app/composables/useApi.ts +2 -0
- package/app/composables/useUnsavedGuard.spec.ts +104 -0
- package/app/composables/useUnsavedGuard.ts +66 -0
- package/app/stores/testSecrets.ts +89 -0
- package/app/types/testSecrets.ts +15 -0
- package/i18n/locales/de.json +30 -2
- package/i18n/locales/en.json +30 -2
- package/i18n/locales/es.json +30 -2
- package/i18n/locales/fr.json +30 -2
- package/i18n/locales/he.json +30 -2
- package/i18n/locales/it.json +30 -2
- package/i18n/locales/ja.json +30 -2
- package/i18n/locales/pl.json +30 -2
- package/i18n/locales/tr.json +30 -2
- package/i18n/locales/uk.json +30 -2
- package/package.json +1 -1
|
@@ -40,7 +40,7 @@ const { linkPending } = useContextLinking()
|
|
|
40
40
|
const open = computed({
|
|
41
41
|
get: () => ui.addTaskContainerId !== null,
|
|
42
42
|
set: (v: boolean) => {
|
|
43
|
-
if (!v)
|
|
43
|
+
if (!v) void requestClose()
|
|
44
44
|
},
|
|
45
45
|
})
|
|
46
46
|
|
|
@@ -405,6 +405,34 @@ watch(open, (isOpen) => {
|
|
|
405
405
|
resolvePendingIssueBodies().catch(() => {})
|
|
406
406
|
})
|
|
407
407
|
|
|
408
|
+
// UX-18: prompt before discarding typed input on Escape / backdrop / Cancel. Registered
|
|
409
|
+
// after the reset watcher so the baseline is the seeded form (a prefill is the clean
|
|
410
|
+
// starting point, not a spurious edit). The snapshot covers the user-owned fields — the
|
|
411
|
+
// cheap task-type / technical toggles are excluded, and only the *stable* context keys are
|
|
412
|
+
// compared so the async issue-body resolution never reads as a change.
|
|
413
|
+
const { requestClose } = useUnsavedGuard({
|
|
414
|
+
open,
|
|
415
|
+
close: () => ui.closeAddTask(),
|
|
416
|
+
saving: () => saving.value,
|
|
417
|
+
snapshot: () => ({
|
|
418
|
+
title: title.value.trim(),
|
|
419
|
+
description: description.value.trim(),
|
|
420
|
+
severity: severity.value,
|
|
421
|
+
stepsToReproduce: stepsToReproduce.value.trim(),
|
|
422
|
+
timeboxHours: timeboxHours.value ?? null,
|
|
423
|
+
docKind: docKind.value,
|
|
424
|
+
docAudience: docAudience.value.trim(),
|
|
425
|
+
docTargetPath: docTargetPath.value.trim(),
|
|
426
|
+
docOutlineHints: docOutlineHints.value.trim(),
|
|
427
|
+
docKindFieldValues: { ...docKindFieldValues },
|
|
428
|
+
riskPolicyId: riskPolicyId.value,
|
|
429
|
+
modelPresetId: modelPresetId.value,
|
|
430
|
+
pipelineId: pipelineId.value,
|
|
431
|
+
agentConfig: { ...agentConfigValues.value },
|
|
432
|
+
context: pendingContext.value.map(contextKey),
|
|
433
|
+
}),
|
|
434
|
+
})
|
|
435
|
+
|
|
408
436
|
// A recurring task only needs a target frame (its details are filled in the schedule
|
|
409
437
|
// modal); every other type needs a title.
|
|
410
438
|
const canAdd = computed(() =>
|
|
@@ -921,7 +949,7 @@ async function add() {
|
|
|
921
949
|
|
|
922
950
|
<template #footer>
|
|
923
951
|
<div class="flex w-full justify-end gap-2">
|
|
924
|
-
<UButton color="neutral" variant="ghost" @click="
|
|
952
|
+
<UButton color="neutral" variant="ghost" @click="requestClose()">{{
|
|
925
953
|
t('common.cancel')
|
|
926
954
|
}}</UButton>
|
|
927
955
|
<UButton
|
|
@@ -21,7 +21,7 @@ const { t } = useI18n()
|
|
|
21
21
|
const open = computed({
|
|
22
22
|
get: () => ui.addRecurringFrameId !== null,
|
|
23
23
|
set: (v: boolean) => {
|
|
24
|
-
if (!v)
|
|
24
|
+
if (!v) void requestClose()
|
|
25
25
|
},
|
|
26
26
|
})
|
|
27
27
|
|
|
@@ -131,6 +131,33 @@ watch(open, (isOpen) => {
|
|
|
131
131
|
void tasks.probe()
|
|
132
132
|
})
|
|
133
133
|
|
|
134
|
+
// UX-18: prompt before discarding typed input on Escape / backdrop / Cancel. Registered
|
|
135
|
+
// after the reset watcher so the baseline is the seeded form (the default pipeline + the
|
|
136
|
+
// workspace tracker settings are the clean starting point, not a spurious edit).
|
|
137
|
+
const { requestClose } = useUnsavedGuard({
|
|
138
|
+
open,
|
|
139
|
+
close: () => ui.closeAddRecurring(),
|
|
140
|
+
saving: () => saving.value,
|
|
141
|
+
snapshot: () => ({
|
|
142
|
+
name: name.value.trim(),
|
|
143
|
+
description: description.value.trim(),
|
|
144
|
+
pipelineId: pipelineId.value,
|
|
145
|
+
onDemand: onDemand.value,
|
|
146
|
+
recurrence: recurrence.value,
|
|
147
|
+
trackerKind: trackerKind.value,
|
|
148
|
+
jiraProjectKey: jiraProjectKey.value.trim(),
|
|
149
|
+
linearTeamId: linearTeamId.value.trim(),
|
|
150
|
+
intakeSource: intakeSource.value,
|
|
151
|
+
intakeJiraProjectKey: intakeJiraProjectKey.value.trim(),
|
|
152
|
+
intakeLinearTeamId: intakeLinearTeamId.value.trim(),
|
|
153
|
+
intakeGithubRepo: intakeGithubRepo.value.trim(),
|
|
154
|
+
intakeTitleFragment: intakeTitleFragment.value.trim(),
|
|
155
|
+
intakeLabels: intakeLabels.value.trim(),
|
|
156
|
+
intakeIssueType: intakeIssueType.value.trim(),
|
|
157
|
+
intakeInProgressLabel: intakeInProgressLabel.value.trim(),
|
|
158
|
+
}),
|
|
159
|
+
})
|
|
160
|
+
|
|
134
161
|
// The board field required for the picked source must be filled before a bug-intake schedule saves.
|
|
135
162
|
const intakeReady = computed(() => {
|
|
136
163
|
if (!isBugIntake.value) return true
|
|
@@ -423,7 +450,7 @@ async function add() {
|
|
|
423
450
|
|
|
424
451
|
<template #footer>
|
|
425
452
|
<div class="flex w-full justify-end gap-2">
|
|
426
|
-
<UButton color="neutral" variant="ghost" @click="
|
|
453
|
+
<UButton color="neutral" variant="ghost" @click="requestClose()">{{
|
|
427
454
|
t('common.cancel')
|
|
428
455
|
}}</UButton>
|
|
429
456
|
<UButton
|
|
@@ -22,7 +22,7 @@ const { confirmAction, toastDone } = useConfirmAction()
|
|
|
22
22
|
const open = computed({
|
|
23
23
|
get: () => ui.bootstrapOpen,
|
|
24
24
|
set: (v: boolean) => {
|
|
25
|
-
if (!v)
|
|
25
|
+
if (!v) void requestClose()
|
|
26
26
|
},
|
|
27
27
|
})
|
|
28
28
|
|
|
@@ -84,6 +84,23 @@ const typeItems = useFrameRepoTypeItems()
|
|
|
84
84
|
|
|
85
85
|
const usingReference = computed(() => mode.value === 'reference')
|
|
86
86
|
|
|
87
|
+
// UX-18: prompt before discarding a half-filled launch form on Escape / backdrop / the X.
|
|
88
|
+
// The modal keeps its fields across opens (no reset watcher), so the baseline is whatever
|
|
89
|
+
// the form held when it opened — a close only prompts once the user has typed something
|
|
90
|
+
// new. Only the typed launch fields are guarded; the reference-architecture sub-form has
|
|
91
|
+
// its own Cancel/Save. Declared here (below the launch-form refs) because the guard reads
|
|
92
|
+
// its initial baseline synchronously.
|
|
93
|
+
const { requestClose } = useUnsavedGuard({
|
|
94
|
+
open,
|
|
95
|
+
close: () => ui.closeBootstrap(),
|
|
96
|
+
saving: () => launching.value,
|
|
97
|
+
snapshot: () => ({
|
|
98
|
+
repoName: repoName.value.trim(),
|
|
99
|
+
description: description.value.trim(),
|
|
100
|
+
instructions: instructions.value.trim(),
|
|
101
|
+
}),
|
|
102
|
+
})
|
|
103
|
+
|
|
87
104
|
// Mirror of the backend `slugField` rule (@cat-factory/contracts bootstrap
|
|
88
105
|
// schema): the new repo name is a SINGLE GitHub name segment — no "owner/"
|
|
89
106
|
// prefix — so reject a bad value inline before we hit the API. Kept in sync with
|
|
@@ -4,6 +4,7 @@ import { agentKindMeta } from '~/utils/catalog'
|
|
|
4
4
|
const execution = useExecutionStore()
|
|
5
5
|
const board = useBoardStore()
|
|
6
6
|
const ui = useUiStore()
|
|
7
|
+
const toast = useToast()
|
|
7
8
|
const { t } = useI18n()
|
|
8
9
|
|
|
9
10
|
const ctx = computed(() => ui.decisionContext)
|
|
@@ -16,17 +17,42 @@ const decision = computed(() => step.value?.decision ?? null)
|
|
|
16
17
|
const block = computed(() => (instance.value ? board.getBlock(instance.value.blockId) : undefined))
|
|
17
18
|
const agent = computed(() => (step.value ? agentKindMeta(step.value.agentKind) : null))
|
|
18
19
|
|
|
20
|
+
// UX-25: which option is being resolved (null = idle). Guards against a fire-and-forget
|
|
21
|
+
// double-submit — the resolve is awaited, all options disable while it runs, and a failed
|
|
22
|
+
// resolve keeps the modal open with an error toast instead of closing silently.
|
|
23
|
+
const resolvingOption = ref<string | null>(null)
|
|
24
|
+
|
|
19
25
|
const open = computed({
|
|
20
26
|
get: () => !!ctx.value && !!decision.value,
|
|
21
27
|
set: (v: boolean) => {
|
|
22
|
-
|
|
28
|
+
// While a resolve is in flight the options are disabled, so the dismiss affordances
|
|
29
|
+
// (Escape / backdrop) are locked too — the awaited resolve settles the modal itself.
|
|
30
|
+
if (!v && !resolvingOption.value) ui.closeDecision()
|
|
23
31
|
},
|
|
24
32
|
})
|
|
25
33
|
|
|
26
|
-
function choose(option: string) {
|
|
27
|
-
if (!ctx.value) return
|
|
28
|
-
|
|
29
|
-
|
|
34
|
+
async function choose(option: string) {
|
|
35
|
+
if (!ctx.value || resolvingOption.value) return
|
|
36
|
+
resolvingOption.value = option
|
|
37
|
+
try {
|
|
38
|
+
// `resolveDecision` returns false when a required-credential prompt is cancelled — keep
|
|
39
|
+
// the modal open in that case so the choice isn't silently dropped.
|
|
40
|
+
const resolved = await execution.resolveDecision(
|
|
41
|
+
ctx.value.instanceId,
|
|
42
|
+
ctx.value.decisionId,
|
|
43
|
+
option,
|
|
44
|
+
)
|
|
45
|
+
if (resolved) ui.closeDecision()
|
|
46
|
+
} catch (e) {
|
|
47
|
+
toast.add({
|
|
48
|
+
title: t('panels.decision.resolveFailed'),
|
|
49
|
+
description: e instanceof Error ? e.message : String(e),
|
|
50
|
+
icon: 'i-lucide-triangle-alert',
|
|
51
|
+
color: 'error',
|
|
52
|
+
})
|
|
53
|
+
} finally {
|
|
54
|
+
resolvingOption.value = null
|
|
55
|
+
}
|
|
30
56
|
}
|
|
31
57
|
</script>
|
|
32
58
|
|
|
@@ -65,6 +91,8 @@ function choose(option: string) {
|
|
|
65
91
|
block
|
|
66
92
|
data-testid="decision-option"
|
|
67
93
|
class="justify-start"
|
|
94
|
+
:loading="resolvingOption === opt"
|
|
95
|
+
:disabled="resolvingOption !== null && resolvingOption !== opt"
|
|
68
96
|
@click="choose(opt)"
|
|
69
97
|
>
|
|
70
98
|
{{ opt }}
|
|
@@ -8,6 +8,7 @@ import TaskAgentConfig from '~/components/panels/inspector/TaskAgentConfig.vue'
|
|
|
8
8
|
import ServiceTestConfig from '~/components/panels/inspector/ServiceTestConfig.vue'
|
|
9
9
|
import ServiceFragments from '~/components/panels/inspector/ServiceFragments.vue'
|
|
10
10
|
import ServiceReleaseHealthConfig from '~/components/panels/inspector/ServiceReleaseHealthConfig.vue'
|
|
11
|
+
import ServiceTestSecrets from '~/components/panels/inspector/ServiceTestSecrets.vue'
|
|
11
12
|
import FrontendConfig from '~/components/panels/inspector/FrontendConfig.vue'
|
|
12
13
|
import ServiceConnections from '~/components/panels/inspector/ServiceConnections.vue'
|
|
13
14
|
import ContainerSummary from '~/components/panels/inspector/ContainerSummary.vue'
|
|
@@ -496,6 +497,9 @@ const showOriginalDescription = ref(false)
|
|
|
496
497
|
<!-- service (frame): test infra + provisioning configuration -->
|
|
497
498
|
<ServiceTestConfig v-if="isFrame" :key="`test-config-${block.id}`" :block="block" />
|
|
498
499
|
|
|
500
|
+
<!-- service (frame): SENSITIVE test credentials (sealed, injected out of band) -->
|
|
501
|
+
<ServiceTestSecrets v-if="isFrame" :key="`test-secrets-${block.id}`" :block="block" />
|
|
502
|
+
|
|
499
503
|
<!-- service (frame): best-practice fragments for code-aware agents -->
|
|
500
504
|
<ServiceFragments v-if="isFrame" :key="`fragments-${block.id}`" :block="block" />
|
|
501
505
|
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, reactive, ref, watch } from 'vue'
|
|
3
|
+
import type { Block } from '~/types/domain'
|
|
4
|
+
import InspectorSection from '~/components/panels/inspector/InspectorSection.vue'
|
|
5
|
+
import SecretInput from '~/components/common/SecretInput.vue'
|
|
6
|
+
|
|
7
|
+
// Per-service (frame) SENSITIVE test credentials: a genuinely secret token a Tester needs
|
|
8
|
+
// to exercise a third-party integration (e.g. a Stripe API key). Unlike the non-sensitive
|
|
9
|
+
// pools, these are SEALED at rest and injected into the Tester container OUT OF BAND — never
|
|
10
|
+
// rendered into a prompt or the telemetry snapshot. Keyed by THIS frame's block id.
|
|
11
|
+
//
|
|
12
|
+
// The backend stores the WHOLE set and values are write-only (never read back), so this is a
|
|
13
|
+
// full-set replace editor: saving persists exactly the rows below and drops anything omitted.
|
|
14
|
+
// The list prefills from the configured keys/descriptions; every value must be (re-)entered,
|
|
15
|
+
// and Save stays disabled until each row has one — so an existing secret can never be blanked
|
|
16
|
+
// by accident. Hidden entirely when the backend store is unconfigured (no ENCRYPTION_KEY).
|
|
17
|
+
const props = defineProps<{ block: Block }>()
|
|
18
|
+
|
|
19
|
+
const store = useTestSecretsStore()
|
|
20
|
+
const toast = useToast()
|
|
21
|
+
const { t } = useI18n()
|
|
22
|
+
const { confirmAction, toastDone } = useConfirmAction()
|
|
23
|
+
|
|
24
|
+
const busy = ref(false)
|
|
25
|
+
|
|
26
|
+
interface DraftRow {
|
|
27
|
+
key: string
|
|
28
|
+
description: string
|
|
29
|
+
value: string
|
|
30
|
+
}
|
|
31
|
+
const draft = reactive<{ rows: DraftRow[] }>({ rows: [] })
|
|
32
|
+
|
|
33
|
+
const configured = computed(() => store.entriesForBlock(props.block.id))
|
|
34
|
+
const available = computed(() => store.available !== false)
|
|
35
|
+
|
|
36
|
+
const blankRow = (): DraftRow => ({ key: '', description: '', value: '' })
|
|
37
|
+
|
|
38
|
+
// Load this frame's configured refs once, then (re)hydrate the editor from them. Runs again
|
|
39
|
+
// after a save/clear (the store refs change) so the just-typed secret values don't linger in
|
|
40
|
+
// the form — the persisted set is re-shown with empty value fields to re-enter.
|
|
41
|
+
onMounted(() => {
|
|
42
|
+
store.ensureLoaded(props.block.id).catch(() => {})
|
|
43
|
+
})
|
|
44
|
+
watch(
|
|
45
|
+
configured,
|
|
46
|
+
(entries) => {
|
|
47
|
+
draft.rows = entries.length
|
|
48
|
+
? entries.map((e) => ({ key: e.key, description: e.description, value: '' }))
|
|
49
|
+
: [blankRow()]
|
|
50
|
+
},
|
|
51
|
+
{ immediate: true },
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
// A valid POSIX env-var name (mirrors the contract's testSecretKeySchema regex + max length).
|
|
55
|
+
// The reserved/toolchain-name rejection lives server-side and surfaces as a save error — we
|
|
56
|
+
// don't duplicate the harness's reserved-name list here.
|
|
57
|
+
const KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/
|
|
58
|
+
const KEY_MAX = 128
|
|
59
|
+
function keyValid(key: string): boolean {
|
|
60
|
+
const k = key.trim()
|
|
61
|
+
return KEY_RE.test(k) && k.length <= KEY_MAX
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Keys that appear more than once (trimmed) — flagged inline and block saving.
|
|
65
|
+
const duplicateKeys = computed(() => {
|
|
66
|
+
const seen = new Map<string, number>()
|
|
67
|
+
for (const r of draft.rows) {
|
|
68
|
+
const k = r.key.trim()
|
|
69
|
+
if (k) seen.set(k, (seen.get(k) ?? 0) + 1)
|
|
70
|
+
}
|
|
71
|
+
return new Set([...seen.entries()].filter(([, n]) => n > 1).map(([k]) => k))
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
function rowComplete(r: DraftRow): boolean {
|
|
75
|
+
return keyValid(r.key) && r.value.trim().length > 0
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const canSave = computed(
|
|
79
|
+
() =>
|
|
80
|
+
!busy.value &&
|
|
81
|
+
draft.rows.length > 0 &&
|
|
82
|
+
draft.rows.every(rowComplete) &&
|
|
83
|
+
duplicateKeys.value.size === 0,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
function addRow() {
|
|
87
|
+
draft.rows.push(blankRow())
|
|
88
|
+
}
|
|
89
|
+
function removeRow(index: number) {
|
|
90
|
+
draft.rows.splice(index, 1)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function notifyError(title: string, e: unknown) {
|
|
94
|
+
toast.add({
|
|
95
|
+
title,
|
|
96
|
+
description: e instanceof Error ? e.message : String(e),
|
|
97
|
+
icon: 'i-lucide-triangle-alert',
|
|
98
|
+
color: 'error',
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function save() {
|
|
103
|
+
busy.value = true
|
|
104
|
+
try {
|
|
105
|
+
await store.save(props.block.id, {
|
|
106
|
+
entries: draft.rows.map((r) => ({
|
|
107
|
+
key: r.key.trim(),
|
|
108
|
+
description: r.description.trim(),
|
|
109
|
+
value: r.value,
|
|
110
|
+
})),
|
|
111
|
+
})
|
|
112
|
+
toast.add({
|
|
113
|
+
title: t('inspector.testSecrets.savedToast'),
|
|
114
|
+
icon: 'i-lucide-check',
|
|
115
|
+
color: 'success',
|
|
116
|
+
})
|
|
117
|
+
} catch (e) {
|
|
118
|
+
notifyError(t('inspector.testSecrets.saveFailed'), e)
|
|
119
|
+
} finally {
|
|
120
|
+
busy.value = false
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function clearAll() {
|
|
125
|
+
const noun = t('inspector.testSecrets.configNoun')
|
|
126
|
+
if (!(await confirmAction('clear', noun))) return
|
|
127
|
+
busy.value = true
|
|
128
|
+
try {
|
|
129
|
+
await store.clear(props.block.id)
|
|
130
|
+
toastDone('clear', noun)
|
|
131
|
+
} catch (e) {
|
|
132
|
+
notifyError(t('inspector.testSecrets.clearFailed'), e)
|
|
133
|
+
} finally {
|
|
134
|
+
busy.value = false
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
</script>
|
|
138
|
+
|
|
139
|
+
<template>
|
|
140
|
+
<InspectorSection
|
|
141
|
+
v-if="available"
|
|
142
|
+
:title="t('inspector.testSecrets.title')"
|
|
143
|
+
:hint="t('inspector.testSecrets.sectionHint')"
|
|
144
|
+
:count="configured.length"
|
|
145
|
+
warning
|
|
146
|
+
default-open
|
|
147
|
+
data-testid="service-test-secrets"
|
|
148
|
+
>
|
|
149
|
+
<template #actions>
|
|
150
|
+
<UButton
|
|
151
|
+
v-if="configured.length"
|
|
152
|
+
color="error"
|
|
153
|
+
variant="ghost"
|
|
154
|
+
size="xs"
|
|
155
|
+
icon="i-lucide-trash-2"
|
|
156
|
+
:loading="busy"
|
|
157
|
+
data-testid="test-secrets-clear"
|
|
158
|
+
@click="clearAll"
|
|
159
|
+
>
|
|
160
|
+
{{ t('inspector.testSecrets.clear') }}
|
|
161
|
+
</UButton>
|
|
162
|
+
</template>
|
|
163
|
+
|
|
164
|
+
<!-- These are REAL secrets: an unmistakable sensitivity + replace-all warning. -->
|
|
165
|
+
<div
|
|
166
|
+
class="flex items-start gap-2 rounded-md border border-amber-500/40 bg-amber-500/10 px-2.5 py-2 text-[11px] leading-snug text-amber-200"
|
|
167
|
+
>
|
|
168
|
+
<UIcon name="i-lucide-shield-alert" class="mt-0.5 h-4 w-4 shrink-0 text-amber-400" />
|
|
169
|
+
<span>{{ t('inspector.testSecrets.warning') }}</span>
|
|
170
|
+
</div>
|
|
171
|
+
|
|
172
|
+
<p class="text-[11px] leading-snug text-slate-500">
|
|
173
|
+
{{ t('inspector.testSecrets.replaceNote') }}
|
|
174
|
+
</p>
|
|
175
|
+
|
|
176
|
+
<div class="space-y-3">
|
|
177
|
+
<div
|
|
178
|
+
v-for="(row, index) in draft.rows"
|
|
179
|
+
:key="index"
|
|
180
|
+
class="space-y-2 rounded-md border border-slate-800 p-2.5"
|
|
181
|
+
:data-testid="`test-secret-row-${index}`"
|
|
182
|
+
>
|
|
183
|
+
<div class="flex items-start gap-2">
|
|
184
|
+
<UFormField
|
|
185
|
+
:label="t('inspector.testSecrets.key')"
|
|
186
|
+
:error="
|
|
187
|
+
row.key.trim() && !keyValid(row.key)
|
|
188
|
+
? t('inspector.testSecrets.keyInvalid')
|
|
189
|
+
: undefined
|
|
190
|
+
"
|
|
191
|
+
class="flex-1"
|
|
192
|
+
>
|
|
193
|
+
<UInput
|
|
194
|
+
v-model="row.key"
|
|
195
|
+
placeholder="STRIPE_API_KEY"
|
|
196
|
+
size="sm"
|
|
197
|
+
class="w-full font-mono"
|
|
198
|
+
:data-testid="`test-secret-key-${index}`"
|
|
199
|
+
/>
|
|
200
|
+
</UFormField>
|
|
201
|
+
<UButton
|
|
202
|
+
color="error"
|
|
203
|
+
variant="ghost"
|
|
204
|
+
size="xs"
|
|
205
|
+
icon="i-lucide-x"
|
|
206
|
+
class="mt-5 shrink-0"
|
|
207
|
+
:aria-label="t('inspector.testSecrets.removeRow')"
|
|
208
|
+
:data-testid="`test-secret-remove-${index}`"
|
|
209
|
+
@click="removeRow(index)"
|
|
210
|
+
/>
|
|
211
|
+
</div>
|
|
212
|
+
|
|
213
|
+
<UFormField :label="t('inspector.testSecrets.description')">
|
|
214
|
+
<UInput
|
|
215
|
+
v-model="row.description"
|
|
216
|
+
:placeholder="t('inspector.testSecrets.descriptionPlaceholder')"
|
|
217
|
+
size="sm"
|
|
218
|
+
class="w-full"
|
|
219
|
+
:data-testid="`test-secret-description-${index}`"
|
|
220
|
+
/>
|
|
221
|
+
</UFormField>
|
|
222
|
+
|
|
223
|
+
<UFormField :label="t('inspector.testSecrets.value')">
|
|
224
|
+
<SecretInput
|
|
225
|
+
v-model="row.value"
|
|
226
|
+
:placeholder="t('inspector.testSecrets.valuePlaceholder')"
|
|
227
|
+
size="sm"
|
|
228
|
+
class="w-full"
|
|
229
|
+
:data-testid="`test-secret-value-${index}`"
|
|
230
|
+
/>
|
|
231
|
+
</UFormField>
|
|
232
|
+
</div>
|
|
233
|
+
|
|
234
|
+
<p v-if="duplicateKeys.size" class="text-[11px] text-error-400">
|
|
235
|
+
{{ t('inspector.testSecrets.duplicateKey') }}
|
|
236
|
+
</p>
|
|
237
|
+
|
|
238
|
+
<div class="flex items-center justify-between gap-2">
|
|
239
|
+
<UButton
|
|
240
|
+
color="neutral"
|
|
241
|
+
variant="soft"
|
|
242
|
+
size="xs"
|
|
243
|
+
icon="i-lucide-plus"
|
|
244
|
+
data-testid="test-secret-add"
|
|
245
|
+
@click="addRow"
|
|
246
|
+
>
|
|
247
|
+
{{ t('inspector.testSecrets.addRow') }}
|
|
248
|
+
</UButton>
|
|
249
|
+
<UButton
|
|
250
|
+
color="primary"
|
|
251
|
+
variant="soft"
|
|
252
|
+
size="xs"
|
|
253
|
+
icon="i-lucide-save"
|
|
254
|
+
:loading="busy"
|
|
255
|
+
:disabled="!canSave"
|
|
256
|
+
data-testid="test-secrets-save"
|
|
257
|
+
@click="save"
|
|
258
|
+
>
|
|
259
|
+
{{ t('inspector.testSecrets.save') }}
|
|
260
|
+
</UButton>
|
|
261
|
+
</div>
|
|
262
|
+
</div>
|
|
263
|
+
</InspectorSection>
|
|
264
|
+
</template>
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import {
|
|
2
|
+
deleteServiceTestSecretsContract,
|
|
3
|
+
getServiceTestSecretsContract,
|
|
4
|
+
setServiceTestSecretsContract,
|
|
5
|
+
} from '@cat-factory/contracts'
|
|
6
|
+
import type { UpsertServiceTestSecretsInput } from '~/types/testSecrets'
|
|
7
|
+
import type { ApiContext } from './context'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Sensitive per-service test secrets (SEALED, write-only). The GET view returns only the
|
|
11
|
+
* configured keys + descriptions; the PUT replaces the whole set (values write-only) and
|
|
12
|
+
* an empty set clears it. Keyed by the service-frame block. See TestSecretsController.
|
|
13
|
+
*/
|
|
14
|
+
export function testSecretsApi({ send, ws }: ApiContext) {
|
|
15
|
+
return {
|
|
16
|
+
getServiceTestSecrets: (workspaceId: string, blockId: string) =>
|
|
17
|
+
send(getServiceTestSecretsContract, { pathPrefix: ws(workspaceId), pathParams: { blockId } }),
|
|
18
|
+
|
|
19
|
+
setServiceTestSecrets: (
|
|
20
|
+
workspaceId: string,
|
|
21
|
+
blockId: string,
|
|
22
|
+
body: UpsertServiceTestSecretsInput,
|
|
23
|
+
) =>
|
|
24
|
+
send(setServiceTestSecretsContract, {
|
|
25
|
+
pathPrefix: ws(workspaceId),
|
|
26
|
+
pathParams: { blockId },
|
|
27
|
+
body,
|
|
28
|
+
}),
|
|
29
|
+
|
|
30
|
+
deleteServiceTestSecrets: (workspaceId: string, blockId: string) =>
|
|
31
|
+
send(deleteServiceTestSecretsContract, {
|
|
32
|
+
pathPrefix: ws(workspaceId),
|
|
33
|
+
pathParams: { blockId },
|
|
34
|
+
}),
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -35,6 +35,7 @@ import { reviewsApi } from './api/reviews'
|
|
|
35
35
|
import { slackApi } from './api/slack'
|
|
36
36
|
import { specApi } from './api/spec'
|
|
37
37
|
import { tasksApi } from './api/tasks'
|
|
38
|
+
import { testSecretsApi } from './api/testSecrets'
|
|
38
39
|
import { userSecretsApi } from './api/userSecrets'
|
|
39
40
|
import { userSettingsApi } from './api/userSettings'
|
|
40
41
|
import { workspacesApi } from './api/workspaces'
|
|
@@ -123,6 +124,7 @@ export function useApi() {
|
|
|
123
124
|
...docInterviewApi(ctx),
|
|
124
125
|
...provisioningLogsApi(ctx),
|
|
125
126
|
...releaseHealthApi(ctx),
|
|
127
|
+
...testSecretsApi(ctx),
|
|
126
128
|
...packageRegistriesApi(ctx),
|
|
127
129
|
...previewApi(ctx),
|
|
128
130
|
...environmentsApi(ctx),
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { nextTick, ref } from 'vue'
|
|
3
|
+
import { useUnsavedGuard } from '~/composables/useUnsavedGuard'
|
|
4
|
+
|
|
5
|
+
// Control the shared confirm dialog: `confirm()` resolves to the user's choice (discard
|
|
6
|
+
// vs keep). `useI18n` is already stubbed to a passthrough in test/setup.ts.
|
|
7
|
+
function stubConfirm(result: boolean) {
|
|
8
|
+
const confirm = vi.fn().mockResolvedValue(result)
|
|
9
|
+
vi.stubGlobal('useConfirm', () => ({ confirm }))
|
|
10
|
+
return confirm
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe('useUnsavedGuard', () => {
|
|
14
|
+
it('closes immediately without prompting when the form is unchanged', async () => {
|
|
15
|
+
const confirm = stubConfirm(true)
|
|
16
|
+
const close = vi.fn()
|
|
17
|
+
const value = ref('hello')
|
|
18
|
+
const { requestClose } = useUnsavedGuard({
|
|
19
|
+
open: ref(true),
|
|
20
|
+
close,
|
|
21
|
+
snapshot: () => ({ value: value.value }),
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
await requestClose()
|
|
25
|
+
|
|
26
|
+
expect(confirm).not.toHaveBeenCalled()
|
|
27
|
+
expect(close).toHaveBeenCalledOnce()
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('prompts and closes when dirty and the user confirms discard', async () => {
|
|
31
|
+
const confirm = stubConfirm(true)
|
|
32
|
+
const close = vi.fn()
|
|
33
|
+
const value = ref('hello')
|
|
34
|
+
const { requestClose, isDirty } = useUnsavedGuard({
|
|
35
|
+
open: ref(true),
|
|
36
|
+
close,
|
|
37
|
+
snapshot: () => ({ value: value.value }),
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
value.value = 'changed'
|
|
41
|
+
expect(isDirty()).toBe(true)
|
|
42
|
+
await requestClose()
|
|
43
|
+
|
|
44
|
+
expect(confirm).toHaveBeenCalledOnce()
|
|
45
|
+
expect(close).toHaveBeenCalledOnce()
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('prompts and keeps the modal open when the user cancels discard', async () => {
|
|
49
|
+
const confirm = stubConfirm(false)
|
|
50
|
+
const close = vi.fn()
|
|
51
|
+
const value = ref('hello')
|
|
52
|
+
const { requestClose } = useUnsavedGuard({
|
|
53
|
+
open: ref(true),
|
|
54
|
+
close,
|
|
55
|
+
snapshot: () => ({ value: value.value }),
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
value.value = 'changed'
|
|
59
|
+
await requestClose()
|
|
60
|
+
|
|
61
|
+
expect(confirm).toHaveBeenCalledOnce()
|
|
62
|
+
expect(close).not.toHaveBeenCalled()
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('never prompts or closes while a submit is in flight', async () => {
|
|
66
|
+
const confirm = stubConfirm(true)
|
|
67
|
+
const close = vi.fn()
|
|
68
|
+
const value = ref('hello')
|
|
69
|
+
const { requestClose } = useUnsavedGuard({
|
|
70
|
+
open: ref(true),
|
|
71
|
+
close,
|
|
72
|
+
saving: () => true,
|
|
73
|
+
snapshot: () => ({ value: value.value }),
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
value.value = 'changed'
|
|
77
|
+
await requestClose()
|
|
78
|
+
|
|
79
|
+
expect(confirm).not.toHaveBeenCalled()
|
|
80
|
+
expect(close).not.toHaveBeenCalled()
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('re-baselines the seeded form each time the modal opens', async () => {
|
|
84
|
+
stubConfirm(true)
|
|
85
|
+
const open = ref(false)
|
|
86
|
+
const value = ref('')
|
|
87
|
+
const { isDirty } = useUnsavedGuard({
|
|
88
|
+
open,
|
|
89
|
+
close: vi.fn(),
|
|
90
|
+
snapshot: () => ({ value: value.value }),
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
// A reset watcher seeds a prefill; the guard re-snapshots on open, so the seeded value
|
|
94
|
+
// is the clean baseline rather than a spurious edit.
|
|
95
|
+
value.value = 'seeded prefill'
|
|
96
|
+
open.value = true
|
|
97
|
+
await nextTick()
|
|
98
|
+
expect(isDirty()).toBe(false)
|
|
99
|
+
|
|
100
|
+
// A genuine edit after the modal is open is dirty.
|
|
101
|
+
value.value = 'user typed more'
|
|
102
|
+
expect(isDirty()).toBe(true)
|
|
103
|
+
})
|
|
104
|
+
})
|