@cat-factory/app 0.119.1 → 0.121.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 +78 -12
- package/app/components/layout/IntegrationsHub.vue +28 -16
- package/app/components/settings/ApiTokensPanel.vue +205 -0
- package/app/composables/api/publicApiKeys.ts +26 -0
- package/app/composables/useApi.ts +2 -0
- package/app/pages/index.vue +4 -0
- package/app/stores/publicApiKeys.spec.ts +105 -0
- package/app/stores/publicApiKeys.ts +72 -0
- package/app/stores/ui.ts +13 -0
- package/app/types/publicApiKeys.ts +10 -0
- package/app/utils/catalog.spec.ts +1 -0
- package/app/utils/catalog.ts +13 -0
- package/i18n/locales/de.json +48 -0
- package/i18n/locales/en.json +48 -0
- package/i18n/locales/es.json +48 -0
- package/i18n/locales/fr.json +48 -0
- package/i18n/locales/he.json +48 -0
- package/i18n/locales/it.json +48 -0
- package/i18n/locales/ja.json +48 -0
- package/i18n/locales/pl.json +48 -0
- package/i18n/locales/tr.json +48 -0
- package/i18n/locales/uk.json +48 -0
- package/package.json +2 -2
|
@@ -102,6 +102,13 @@ const isRecurring = computed(() => taskType.value === 'recurring')
|
|
|
102
102
|
const severity = ref<'low' | 'medium' | 'high' | 'critical' | ''>('')
|
|
103
103
|
const stepsToReproduce = ref('')
|
|
104
104
|
const timeboxHours = ref<number | undefined>(undefined)
|
|
105
|
+
// Spike research criteria — folded into the spike agent's prompt (see the backend `spike` kind).
|
|
106
|
+
const spikeResearchQuestion = ref('')
|
|
107
|
+
const spikeSuccessCriteria = ref('')
|
|
108
|
+
const spikeOptionsToCompare = ref('')
|
|
109
|
+
// Optional in-repo path the findings document is committed to (else `docs/research/<slug>.md`);
|
|
110
|
+
// shares the `taskTypeFields.targetPath` field + its safe-`.md`-path validation with `document`.
|
|
111
|
+
const spikeTargetPath = ref('')
|
|
105
112
|
// `DOC_KINDS` (and the `DocKind` type) are owned by the contracts package — re-exported via
|
|
106
113
|
// `~/types/domain` — so the picker and the create payload can't drift from the backend list.
|
|
107
114
|
const docKind = ref<DocKind | ''>('')
|
|
@@ -171,13 +178,21 @@ function buildTypeFields(): TaskTypeFields | undefined {
|
|
|
171
178
|
return Object.keys(f).length ? f : undefined
|
|
172
179
|
}
|
|
173
180
|
if (taskType.value === 'spike') {
|
|
181
|
+
const f: TaskTypeFields = {}
|
|
174
182
|
// `v-model.number` on a cleared number input yields '' (not undefined), which would
|
|
175
183
|
// serialise as a non-number and 400 the create — so require a finite number here.
|
|
176
|
-
|
|
184
|
+
if (
|
|
185
|
+
typeof timeboxHours.value === 'number' &&
|
|
177
186
|
Number.isFinite(timeboxHours.value) &&
|
|
178
187
|
timeboxHours.value >= 0
|
|
179
|
-
|
|
180
|
-
|
|
188
|
+
) {
|
|
189
|
+
f.timeboxHours = timeboxHours.value
|
|
190
|
+
}
|
|
191
|
+
if (spikeResearchQuestion.value.trim()) f.researchQuestion = spikeResearchQuestion.value.trim()
|
|
192
|
+
if (spikeSuccessCriteria.value.trim()) f.successCriteria = spikeSuccessCriteria.value.trim()
|
|
193
|
+
if (spikeOptionsToCompare.value.trim()) f.optionsToCompare = spikeOptionsToCompare.value.trim()
|
|
194
|
+
if (spikeTargetPath.value.trim()) f.targetPath = spikeTargetPath.value.trim()
|
|
195
|
+
return Object.keys(f).length ? f : undefined
|
|
181
196
|
}
|
|
182
197
|
if (taskType.value === 'document') {
|
|
183
198
|
const f: TaskTypeFields = {}
|
|
@@ -406,6 +421,10 @@ watch(open, (isOpen) => {
|
|
|
406
421
|
severity.value = ''
|
|
407
422
|
stepsToReproduce.value = ''
|
|
408
423
|
timeboxHours.value = undefined
|
|
424
|
+
spikeResearchQuestion.value = ''
|
|
425
|
+
spikeSuccessCriteria.value = ''
|
|
426
|
+
spikeOptionsToCompare.value = ''
|
|
427
|
+
spikeTargetPath.value = ''
|
|
409
428
|
docKind.value = ''
|
|
410
429
|
docAudience.value = ''
|
|
411
430
|
docTargetPath.value = ''
|
|
@@ -451,6 +470,10 @@ const { requestClose } = useUnsavedGuard({
|
|
|
451
470
|
severity: severity.value,
|
|
452
471
|
stepsToReproduce: stepsToReproduce.value.trim(),
|
|
453
472
|
timeboxHours: timeboxHours.value ?? null,
|
|
473
|
+
spikeResearchQuestion: spikeResearchQuestion.value.trim(),
|
|
474
|
+
spikeSuccessCriteria: spikeSuccessCriteria.value.trim(),
|
|
475
|
+
spikeOptionsToCompare: spikeOptionsToCompare.value.trim(),
|
|
476
|
+
spikeTargetPath: spikeTargetPath.value.trim(),
|
|
454
477
|
docKind: docKind.value,
|
|
455
478
|
docAudience: docAudience.value.trim(),
|
|
456
479
|
docTargetPath: docTargetPath.value.trim(),
|
|
@@ -671,15 +694,58 @@ async function add() {
|
|
|
671
694
|
</UFormField>
|
|
672
695
|
</div>
|
|
673
696
|
|
|
674
|
-
<
|
|
675
|
-
<
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
697
|
+
<div v-else-if="taskType === 'spike'" class="space-y-3">
|
|
698
|
+
<UFormField :label="t('board.addTask.timebox')">
|
|
699
|
+
<UInput
|
|
700
|
+
v-model.number="timeboxHours"
|
|
701
|
+
type="number"
|
|
702
|
+
min="0"
|
|
703
|
+
:placeholder="t('board.addTask.timeboxPlaceholder')"
|
|
704
|
+
class="w-full"
|
|
705
|
+
/>
|
|
706
|
+
</UFormField>
|
|
707
|
+
<UFormField
|
|
708
|
+
:label="t('board.addTask.spikeFields.researchQuestion.label')"
|
|
709
|
+
:hint="t('board.addTask.optional')"
|
|
710
|
+
>
|
|
711
|
+
<UInput
|
|
712
|
+
v-model="spikeResearchQuestion"
|
|
713
|
+
:placeholder="t('board.addTask.spikeFields.researchQuestion.placeholder')"
|
|
714
|
+
class="w-full"
|
|
715
|
+
/>
|
|
716
|
+
</UFormField>
|
|
717
|
+
<UFormField
|
|
718
|
+
:label="t('board.addTask.spikeFields.successCriteria.label')"
|
|
719
|
+
:hint="t('board.addTask.optional')"
|
|
720
|
+
>
|
|
721
|
+
<UTextarea
|
|
722
|
+
v-model="spikeSuccessCriteria"
|
|
723
|
+
:rows="2"
|
|
724
|
+
autoresize
|
|
725
|
+
:placeholder="t('board.addTask.spikeFields.successCriteria.placeholder')"
|
|
726
|
+
class="w-full"
|
|
727
|
+
/>
|
|
728
|
+
</UFormField>
|
|
729
|
+
<UFormField
|
|
730
|
+
:label="t('board.addTask.spikeFields.optionsToCompare.label')"
|
|
731
|
+
:hint="t('board.addTask.optional')"
|
|
732
|
+
>
|
|
733
|
+
<UTextarea
|
|
734
|
+
v-model="spikeOptionsToCompare"
|
|
735
|
+
:rows="2"
|
|
736
|
+
autoresize
|
|
737
|
+
:placeholder="t('board.addTask.spikeFields.optionsToCompare.placeholder')"
|
|
738
|
+
class="w-full"
|
|
739
|
+
/>
|
|
740
|
+
</UFormField>
|
|
741
|
+
<UFormField :label="t('board.addTask.targetPath')" :hint="t('board.addTask.optional')">
|
|
742
|
+
<UInput
|
|
743
|
+
v-model="spikeTargetPath"
|
|
744
|
+
:placeholder="t('board.addTask.targetPathPlaceholder')"
|
|
745
|
+
class="w-full"
|
|
746
|
+
/>
|
|
747
|
+
</UFormField>
|
|
748
|
+
</div>
|
|
683
749
|
|
|
684
750
|
<div v-else-if="taskType === 'document'" class="space-y-3">
|
|
685
751
|
<UFormField :label="t('board.addTask.documentKind')">
|
|
@@ -21,6 +21,7 @@ const tasks = useTasksStore()
|
|
|
21
21
|
const tracker = useTrackerStore()
|
|
22
22
|
const releaseHealth = useReleaseHealthStore()
|
|
23
23
|
const packageRegistries = usePackageRegistriesStore()
|
|
24
|
+
const publicApiKeys = usePublicApiKeysStore()
|
|
24
25
|
const userSecrets = useUserSecretsStore()
|
|
25
26
|
const apiKeys = useApiKeysStore()
|
|
26
27
|
const workspace = useWorkspaceStore()
|
|
@@ -51,6 +52,7 @@ watch(
|
|
|
51
52
|
query.value = ''
|
|
52
53
|
void releaseHealth.ensureLoaded().catch(() => {})
|
|
53
54
|
void packageRegistries.ensureLoaded().catch(() => {})
|
|
55
|
+
void publicApiKeys.ensureLoaded().catch(() => {})
|
|
54
56
|
void userSecrets.load().catch(() => {})
|
|
55
57
|
// Drives the OpenRouter row's "Key connected" badge.
|
|
56
58
|
if (workspace.workspaceId) void apiKeys.load(workspace.workspaceId).catch(() => {})
|
|
@@ -269,26 +271,36 @@ const groups = computed<IntegrationGroup[]>(() => {
|
|
|
269
271
|
})
|
|
270
272
|
}
|
|
271
273
|
|
|
272
|
-
// --- Development (private package registries)
|
|
273
|
-
//
|
|
274
|
-
// (`available === true`), so an unconfigured backend doesn't show a dead row.
|
|
274
|
+
// --- Development (private package registries + API access tokens) -----------
|
|
275
|
+
// Each row is gated like observability: hidden until a probe confirms its module is
|
|
276
|
+
// wired (`available === true`), so an unconfigured backend doesn't show a dead row.
|
|
277
|
+
const development: IntegrationItem[] = []
|
|
275
278
|
if (packageRegistries.available) {
|
|
276
279
|
const hasEntries = packageRegistries.entries.length > 0
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
280
|
+
development.push({
|
|
281
|
+
key: 'package-registries',
|
|
282
|
+
icon: 'i-lucide-package',
|
|
283
|
+
label: t('layout.integrationsHub.items.packageRegistries.label'),
|
|
284
|
+
description: t('layout.integrationsHub.items.packageRegistries.description'),
|
|
285
|
+
status: hasEntries ? t('layout.integrationsHub.status.connected') : undefined,
|
|
286
|
+
connected: hasEntries,
|
|
287
|
+
onClick: () => go(ui.openPackageRegistries),
|
|
288
|
+
})
|
|
289
|
+
}
|
|
290
|
+
if (publicApiKeys.available) {
|
|
291
|
+
const hasKeys = publicApiKeys.keys.length > 0
|
|
292
|
+
development.push({
|
|
293
|
+
key: 'api-tokens',
|
|
294
|
+
icon: 'i-lucide-key-round',
|
|
295
|
+
label: t('layout.integrationsHub.items.apiTokens.label'),
|
|
296
|
+
description: t('layout.integrationsHub.items.apiTokens.description'),
|
|
297
|
+
status: hasKeys ? t('layout.integrationsHub.status.connected') : undefined,
|
|
298
|
+
connected: hasKeys,
|
|
299
|
+
onClick: () => go(ui.openApiTokens),
|
|
290
300
|
})
|
|
291
301
|
}
|
|
302
|
+
if (development.length)
|
|
303
|
+
out.push({ title: t('layout.integrationsHub.groups.development'), items: development })
|
|
292
304
|
|
|
293
305
|
// NOTE: Infrastructure (agent-container execution + Tester environments + the local-mode
|
|
294
306
|
// warm pool/checkout) is no longer listed here — it moved to its OWN top-level navbar menu
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// API access tokens — the workspace's inbound public-API keys external systems present to the
|
|
3
|
+
// `/api/v1` surface (`Authorization: Bearer cf_live_…`). Keys are hashed one-way server-side,
|
|
4
|
+
// so the raw secret is shown EXACTLY ONCE, on create; the list thereafter renders metadata
|
|
5
|
+
// only (label + created / last-used). To rotate a token, revoke it and mint a new one.
|
|
6
|
+
// Opened from the Integrations hub.
|
|
7
|
+
import { computed, ref, watch } from 'vue'
|
|
8
|
+
import type { PublicApiKey } from '~/types/publicApiKeys'
|
|
9
|
+
import IntegrationBackTitle from '~/components/layout/IntegrationBackTitle.vue'
|
|
10
|
+
import CopyButton from '~/components/common/CopyButton.vue'
|
|
11
|
+
|
|
12
|
+
const { t, d } = useI18n()
|
|
13
|
+
const ui = useUiStore()
|
|
14
|
+
const store = usePublicApiKeysStore()
|
|
15
|
+
const toast = useToast()
|
|
16
|
+
const { confirmAction, toastDone } = useConfirmAction()
|
|
17
|
+
|
|
18
|
+
const open = computed({
|
|
19
|
+
get: () => ui.apiTokensOpen,
|
|
20
|
+
set: (v: boolean) => (v ? ui.openApiTokens() : ui.closeApiTokens()),
|
|
21
|
+
})
|
|
22
|
+
const back = useIntegrationBack(open)
|
|
23
|
+
|
|
24
|
+
const label = ref('')
|
|
25
|
+
const busy = ref(false)
|
|
26
|
+
// The full raw secret from the most recent create — surfaced once, then dismissed. Never
|
|
27
|
+
// re-fetchable, so it lives only in this transient ref (not the store).
|
|
28
|
+
const newSecret = ref<string | null>(null)
|
|
29
|
+
|
|
30
|
+
function notifyError(title: string, e: unknown) {
|
|
31
|
+
toast.add({
|
|
32
|
+
title,
|
|
33
|
+
description: e instanceof Error ? e.message : String(e),
|
|
34
|
+
icon: 'i-lucide-triangle-alert',
|
|
35
|
+
color: 'error',
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
watch(
|
|
40
|
+
open,
|
|
41
|
+
async (isOpen) => {
|
|
42
|
+
if (!isOpen) {
|
|
43
|
+
// Never leave a revealed secret hanging around once the panel closes.
|
|
44
|
+
newSecret.value = null
|
|
45
|
+
return
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
await store.ensureLoaded()
|
|
49
|
+
} catch (e) {
|
|
50
|
+
notifyError(t('settings.apiTokens.toast.loadFailed'), e)
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
{ immediate: true },
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
async function createToken() {
|
|
57
|
+
const trimmed = label.value.trim()
|
|
58
|
+
if (!trimmed) return
|
|
59
|
+
busy.value = true
|
|
60
|
+
try {
|
|
61
|
+
const created = await store.create(trimmed)
|
|
62
|
+
newSecret.value = created.secret
|
|
63
|
+
label.value = ''
|
|
64
|
+
toast.add({
|
|
65
|
+
title: t('settings.apiTokens.toast.created'),
|
|
66
|
+
icon: 'i-lucide-check',
|
|
67
|
+
color: 'success',
|
|
68
|
+
})
|
|
69
|
+
} catch (e) {
|
|
70
|
+
notifyError(t('settings.apiTokens.toast.createFailed'), e)
|
|
71
|
+
} finally {
|
|
72
|
+
busy.value = false
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function dismissSecret() {
|
|
77
|
+
newSecret.value = null
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function revokeToken(key: PublicApiKey) {
|
|
81
|
+
if (!(await confirmAction('revoke', key.label))) return
|
|
82
|
+
busy.value = true
|
|
83
|
+
try {
|
|
84
|
+
await store.revoke(key.id)
|
|
85
|
+
toastDone('revoke', key.label)
|
|
86
|
+
} catch (e) {
|
|
87
|
+
notifyError(t('settings.apiTokens.toast.revokeFailed'), e)
|
|
88
|
+
} finally {
|
|
89
|
+
busy.value = false
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
</script>
|
|
93
|
+
|
|
94
|
+
<template>
|
|
95
|
+
<UModal v-model:open="open" :title="t('settings.apiTokens.title')" :ui="{ content: 'max-w-lg' }">
|
|
96
|
+
<template #title>
|
|
97
|
+
<IntegrationBackTitle :title="t('settings.apiTokens.title')" @back="back" />
|
|
98
|
+
</template>
|
|
99
|
+
<template #body>
|
|
100
|
+
<div class="space-y-4" data-testid="api-tokens-panel">
|
|
101
|
+
<p class="text-sm text-slate-400">
|
|
102
|
+
{{ t('settings.apiTokens.intro') }}
|
|
103
|
+
</p>
|
|
104
|
+
|
|
105
|
+
<!-- One-time secret reveal: shown once after create, dismissed by the user. The full
|
|
106
|
+
key is never recoverable, so it must be copied now. -->
|
|
107
|
+
<section
|
|
108
|
+
v-if="newSecret"
|
|
109
|
+
class="space-y-2 rounded-lg border border-primary-500/40 bg-primary-500/10 p-3"
|
|
110
|
+
data-testid="api-token-secret"
|
|
111
|
+
>
|
|
112
|
+
<div class="flex items-center gap-2 text-sm font-medium text-primary-200">
|
|
113
|
+
<UIcon name="i-lucide-key-round" class="h-4 w-4 shrink-0" />
|
|
114
|
+
<span>{{ t('settings.apiTokens.secret.heading') }}</span>
|
|
115
|
+
</div>
|
|
116
|
+
<p class="text-xs text-slate-300">{{ t('settings.apiTokens.secret.warning') }}</p>
|
|
117
|
+
<div
|
|
118
|
+
class="flex items-center gap-2 rounded-md border border-slate-700 bg-slate-950/60 px-3 py-2"
|
|
119
|
+
>
|
|
120
|
+
<code class="min-w-0 flex-1 truncate font-mono text-xs text-slate-100">{{
|
|
121
|
+
newSecret
|
|
122
|
+
}}</code>
|
|
123
|
+
<CopyButton :text="newSecret" :label="t('settings.apiTokens.secret.copy')" size="sm" />
|
|
124
|
+
</div>
|
|
125
|
+
<div class="flex justify-end">
|
|
126
|
+
<UButton
|
|
127
|
+
color="neutral"
|
|
128
|
+
variant="ghost"
|
|
129
|
+
size="xs"
|
|
130
|
+
data-testid="api-token-secret-dismiss"
|
|
131
|
+
@click="dismissSecret"
|
|
132
|
+
>
|
|
133
|
+
{{ t('settings.apiTokens.secret.done') }}
|
|
134
|
+
</UButton>
|
|
135
|
+
</div>
|
|
136
|
+
</section>
|
|
137
|
+
|
|
138
|
+
<section v-if="store.keys.length" class="space-y-2 rounded-lg border border-slate-700 p-3">
|
|
139
|
+
<h3 class="text-sm font-semibold">
|
|
140
|
+
{{ t('settings.apiTokens.list.heading') }}
|
|
141
|
+
</h3>
|
|
142
|
+
<div
|
|
143
|
+
v-for="key in store.keys"
|
|
144
|
+
:key="key.id"
|
|
145
|
+
class="flex items-center justify-between gap-2 rounded-md border border-slate-800 px-3 py-2"
|
|
146
|
+
>
|
|
147
|
+
<div class="min-w-0 space-y-0.5">
|
|
148
|
+
<div class="truncate text-sm font-medium">{{ key.label }}</div>
|
|
149
|
+
<div class="text-[11px] text-slate-500">
|
|
150
|
+
{{
|
|
151
|
+
t('settings.apiTokens.list.created', {
|
|
152
|
+
date: d(new Date(key.createdAt), 'short'),
|
|
153
|
+
})
|
|
154
|
+
}}
|
|
155
|
+
<span aria-hidden="true"> · </span>
|
|
156
|
+
<template v-if="key.lastUsedAt">{{
|
|
157
|
+
t('settings.apiTokens.list.lastUsed', {
|
|
158
|
+
date: d(new Date(key.lastUsedAt), 'short'),
|
|
159
|
+
})
|
|
160
|
+
}}</template>
|
|
161
|
+
<template v-else>{{ t('settings.apiTokens.list.neverUsed') }}</template>
|
|
162
|
+
</div>
|
|
163
|
+
</div>
|
|
164
|
+
<UButton
|
|
165
|
+
color="error"
|
|
166
|
+
variant="ghost"
|
|
167
|
+
icon="i-lucide-ban"
|
|
168
|
+
size="sm"
|
|
169
|
+
:loading="busy"
|
|
170
|
+
:data-testid="`api-token-revoke-${key.id}`"
|
|
171
|
+
:aria-label="t('settings.apiTokens.list.revoke')"
|
|
172
|
+
@click="revokeToken(key)"
|
|
173
|
+
/>
|
|
174
|
+
</div>
|
|
175
|
+
</section>
|
|
176
|
+
|
|
177
|
+
<section class="space-y-3 rounded-lg border border-slate-700 p-3">
|
|
178
|
+
<h3 class="text-sm font-semibold">
|
|
179
|
+
{{ t('settings.apiTokens.add.heading') }}
|
|
180
|
+
</h3>
|
|
181
|
+
<UFormField
|
|
182
|
+
:label="t('settings.apiTokens.add.label')"
|
|
183
|
+
:help="t('settings.apiTokens.add.labelHelp')"
|
|
184
|
+
>
|
|
185
|
+
<UInput
|
|
186
|
+
v-model="label"
|
|
187
|
+
:placeholder="t('settings.apiTokens.add.labelPlaceholder')"
|
|
188
|
+
class="w-full"
|
|
189
|
+
data-testid="api-token-label"
|
|
190
|
+
@keyup.enter="createToken"
|
|
191
|
+
/>
|
|
192
|
+
</UFormField>
|
|
193
|
+
<UButton
|
|
194
|
+
:loading="busy"
|
|
195
|
+
:disabled="!label.trim()"
|
|
196
|
+
data-testid="api-token-create"
|
|
197
|
+
@click="createToken"
|
|
198
|
+
>
|
|
199
|
+
{{ t('settings.apiTokens.add.create') }}
|
|
200
|
+
</UButton>
|
|
201
|
+
</section>
|
|
202
|
+
</div>
|
|
203
|
+
</template>
|
|
204
|
+
</UModal>
|
|
205
|
+
</template>
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createPublicApiKeyContract,
|
|
3
|
+
listPublicApiKeysContract,
|
|
4
|
+
revokePublicApiKeyContract,
|
|
5
|
+
} from '@cat-factory/contracts'
|
|
6
|
+
import type { CreatePublicApiKeyInput } from '~/types/publicApiKeys'
|
|
7
|
+
import type { ApiContext } from './context'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Inbound public-API keys ("API access tokens") a workspace mints for external systems to
|
|
11
|
+
* call the `/api/v1` surface. Management routes are session-authed under
|
|
12
|
+
* `/workspaces/:workspaceId`; the raw secret comes back only on create. See
|
|
13
|
+
* PublicApiKeyController.
|
|
14
|
+
*/
|
|
15
|
+
export function publicApiKeysApi({ send, ws }: ApiContext) {
|
|
16
|
+
return {
|
|
17
|
+
listPublicApiKeys: (workspaceId: string) =>
|
|
18
|
+
send(listPublicApiKeysContract, { pathPrefix: ws(workspaceId) }),
|
|
19
|
+
|
|
20
|
+
createPublicApiKey: (workspaceId: string, body: CreatePublicApiKeyInput) =>
|
|
21
|
+
send(createPublicApiKeyContract, { pathPrefix: ws(workspaceId), body }),
|
|
22
|
+
|
|
23
|
+
revokePublicApiKey: (workspaceId: string, id: string) =>
|
|
24
|
+
send(revokePublicApiKeyContract, { pathPrefix: ws(workspaceId), pathParams: { id } }),
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -25,6 +25,7 @@ import { notificationsApi } from './api/notifications'
|
|
|
25
25
|
import { packageRegistriesApi } from './api/packageRegistries'
|
|
26
26
|
import { preflightsApi } from './api/preflights'
|
|
27
27
|
import { presetsApi } from './api/presets'
|
|
28
|
+
import { publicApiKeysApi } from './api/publicApiKeys'
|
|
28
29
|
import { sharedStacksApi } from './api/sharedStacks'
|
|
29
30
|
import { providerConnectionsApi } from './api/providerConnections'
|
|
30
31
|
import { provisioningLogsApi } from './api/provisioningLogs'
|
|
@@ -121,6 +122,7 @@ export function useApi() {
|
|
|
121
122
|
...notificationsApi(ctx),
|
|
122
123
|
...presetsApi(ctx),
|
|
123
124
|
...preflightsApi(ctx),
|
|
125
|
+
...publicApiKeysApi(ctx),
|
|
124
126
|
...sharedStacksApi(ctx),
|
|
125
127
|
...providerConnectionsApi(ctx),
|
|
126
128
|
...infraHandlersApi(ctx),
|
package/app/pages/index.vue
CHANGED
|
@@ -96,6 +96,9 @@ const ObservabilityConnectionPanel = defineAsyncComponent(
|
|
|
96
96
|
const PackageRegistriesPanel = defineAsyncComponent(
|
|
97
97
|
() => import('~/components/settings/PackageRegistriesPanel.vue'),
|
|
98
98
|
)
|
|
99
|
+
const ApiTokensPanel = defineAsyncComponent(
|
|
100
|
+
() => import('~/components/settings/ApiTokensPanel.vue'),
|
|
101
|
+
)
|
|
99
102
|
const InfrastructureWindow = defineAsyncComponent(
|
|
100
103
|
() => import('~/components/settings/InfrastructureWindow.vue'),
|
|
101
104
|
)
|
|
@@ -402,6 +405,7 @@ watch(
|
|
|
402
405
|
<AccountSettingsPanel v-if="ui.accountSettingsOpen" />
|
|
403
406
|
<ObservabilityConnectionPanel v-if="ui.observabilityConnectionOpen" />
|
|
404
407
|
<PackageRegistriesPanel v-if="ui.packageRegistriesOpen" />
|
|
408
|
+
<ApiTokensPanel v-if="ui.apiTokensOpen" />
|
|
405
409
|
<InfrastructureWindow v-if="ui.infrastructureOpen" />
|
|
406
410
|
<EnvironmentSetupWizard v-if="ui.environmentWizardOpen" />
|
|
407
411
|
<ModelConfigurationPanel v-if="ui.modelConfigOpen" />
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
2
|
+
import { usePublicApiKeysStore } from '~/stores/publicApiKeys'
|
|
3
|
+
import { useWorkspaceStore } from '~/stores/workspace'
|
|
4
|
+
import type { CreatedPublicApiKey, PublicApiKey } from '~/types/publicApiKeys'
|
|
5
|
+
|
|
6
|
+
/** Minimal metadata-view factory — only the fields the store passes through. */
|
|
7
|
+
function key(over: Partial<PublicApiKey> = {}): PublicApiKey {
|
|
8
|
+
return {
|
|
9
|
+
id: 'pak_1',
|
|
10
|
+
accountId: 'acc1',
|
|
11
|
+
workspaceId: 'ws1',
|
|
12
|
+
label: 'CI',
|
|
13
|
+
createdAt: 1,
|
|
14
|
+
lastUsedAt: null,
|
|
15
|
+
revokedAt: null,
|
|
16
|
+
...over,
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('publicApiKeys store', () => {
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
useWorkspaceStore().workspaceId = 'ws1'
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('load stores the key list and marks the feature available', async () => {
|
|
26
|
+
vi.stubGlobal('useApi', () => ({
|
|
27
|
+
listPublicApiKeys: () => Promise.resolve({ keys: [key()] }),
|
|
28
|
+
}))
|
|
29
|
+
|
|
30
|
+
const store = usePublicApiKeysStore()
|
|
31
|
+
await store.load()
|
|
32
|
+
|
|
33
|
+
expect(store.available).toBe(true)
|
|
34
|
+
expect(store.keys).toHaveLength(1)
|
|
35
|
+
expect(store.loading).toBe(false)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('a definitive 503 latches the feature unavailable and clears the list', async () => {
|
|
39
|
+
vi.stubGlobal('useApi', () => ({
|
|
40
|
+
listPublicApiKeys: () => Promise.reject({ statusCode: 503 }),
|
|
41
|
+
}))
|
|
42
|
+
|
|
43
|
+
const store = usePublicApiKeysStore()
|
|
44
|
+
await store.load()
|
|
45
|
+
|
|
46
|
+
expect(store.available).toBe(false)
|
|
47
|
+
expect(store.keys).toEqual([])
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('a transient failure leaves `available` null so ensureLoaded stays retryable', async () => {
|
|
51
|
+
vi.stubGlobal('useApi', () => ({
|
|
52
|
+
listPublicApiKeys: () => Promise.reject({ statusCode: 500 }),
|
|
53
|
+
}))
|
|
54
|
+
|
|
55
|
+
const store = usePublicApiKeysStore()
|
|
56
|
+
await store.load()
|
|
57
|
+
|
|
58
|
+
// Never latched — a network/5xx blip must not hide an otherwise-available panel.
|
|
59
|
+
expect(store.available).toBeNull()
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('ensureLoaded coalesces concurrent callers into one request', async () => {
|
|
63
|
+
const list = vi.fn(() => Promise.resolve({ keys: [key()] }))
|
|
64
|
+
vi.stubGlobal('useApi', () => ({ listPublicApiKeys: list }))
|
|
65
|
+
|
|
66
|
+
const store = usePublicApiKeysStore()
|
|
67
|
+
await Promise.all([store.ensureLoaded(), store.ensureLoaded()])
|
|
68
|
+
// And once probed, it never re-fetches.
|
|
69
|
+
await store.ensureLoaded()
|
|
70
|
+
|
|
71
|
+
expect(list).toHaveBeenCalledTimes(1)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('create prepends the new key (newest-first) and returns the one-time secret', async () => {
|
|
75
|
+
const created: CreatedPublicApiKey = {
|
|
76
|
+
key: key({ id: 'pak_new', label: 'deploy' }),
|
|
77
|
+
secret: 'cf_live_pak_new.abc',
|
|
78
|
+
}
|
|
79
|
+
vi.stubGlobal('useApi', () => ({
|
|
80
|
+
listPublicApiKeys: () => Promise.resolve({ keys: [key({ id: 'pak_old' })] }),
|
|
81
|
+
createPublicApiKey: () => Promise.resolve(created),
|
|
82
|
+
}))
|
|
83
|
+
|
|
84
|
+
const store = usePublicApiKeysStore()
|
|
85
|
+
await store.load()
|
|
86
|
+
const result = await store.create('deploy')
|
|
87
|
+
|
|
88
|
+
expect(result.secret).toBe('cf_live_pak_new.abc')
|
|
89
|
+
expect(store.keys.map((k) => k.id)).toEqual(['pak_new', 'pak_old'])
|
|
90
|
+
expect(store.available).toBe(true)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('revoke drops the key from the list', async () => {
|
|
94
|
+
vi.stubGlobal('useApi', () => ({
|
|
95
|
+
listPublicApiKeys: () => Promise.resolve({ keys: [key({ id: 'a' }), key({ id: 'b' })] }),
|
|
96
|
+
revokePublicApiKey: () => Promise.resolve(),
|
|
97
|
+
}))
|
|
98
|
+
|
|
99
|
+
const store = usePublicApiKeysStore()
|
|
100
|
+
await store.load()
|
|
101
|
+
await store.revoke('a')
|
|
102
|
+
|
|
103
|
+
expect(store.keys.map((k) => k.id)).toEqual(['b'])
|
|
104
|
+
})
|
|
105
|
+
})
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { defineStore } from 'pinia'
|
|
2
|
+
import { ref } from 'vue'
|
|
3
|
+
import type { CreatedPublicApiKey, PublicApiKey } from '~/types/publicApiKeys'
|
|
4
|
+
import { useWorkspaceStore } from '~/stores/workspace'
|
|
5
|
+
import { apiErrorStatus } from '~/composables/api/errors'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The workspace's inbound public-API keys ("API access tokens") external systems present
|
|
9
|
+
* to the `/api/v1` surface. Secrets are one-way hashed server-side and returned only once
|
|
10
|
+
* on create, so the store holds metadata-only views; the raw secret is surfaced by the
|
|
11
|
+
* caller from the `create()` result. Loaded on demand (the tokens panel + the Integrations
|
|
12
|
+
* hub badge), not from the snapshot.
|
|
13
|
+
*/
|
|
14
|
+
export const usePublicApiKeysStore = defineStore('publicApiKeys', () => {
|
|
15
|
+
const api = useApi()
|
|
16
|
+
|
|
17
|
+
const keys = ref<PublicApiKey[]>([])
|
|
18
|
+
const loading = ref(false)
|
|
19
|
+
// Mirrors the backend's opt-in gate (the module 503s when the encryption key is absent):
|
|
20
|
+
// `null` until first probed, then `true`/`false`. The hub hides its tokens entry point
|
|
21
|
+
// when this is false.
|
|
22
|
+
const available = ref<boolean | null>(null)
|
|
23
|
+
let inFlight: Promise<void> | null = null
|
|
24
|
+
|
|
25
|
+
/** Force a refresh of the key list (used after a create/revoke). */
|
|
26
|
+
async function load() {
|
|
27
|
+
const ws = useWorkspaceStore()
|
|
28
|
+
loading.value = true
|
|
29
|
+
try {
|
|
30
|
+
keys.value = (await api.listPublicApiKeys(ws.requireId())).keys
|
|
31
|
+
available.value = true
|
|
32
|
+
} catch (err) {
|
|
33
|
+
if (apiErrorStatus(err) === 503) {
|
|
34
|
+
// A definitive 503 means the feature is unconfigured (no encryption key on the
|
|
35
|
+
// backend): hide the UI entry points and stop probing.
|
|
36
|
+
available.value = false
|
|
37
|
+
keys.value = []
|
|
38
|
+
}
|
|
39
|
+
// Any other failure (transient 5xx / network) is left untouched: it must not hide an
|
|
40
|
+
// already-available panel nor cache a false "unavailable". `available` stays `null`
|
|
41
|
+
// when never probed, so `ensureLoaded` remains retryable on the next open.
|
|
42
|
+
} finally {
|
|
43
|
+
loading.value = false
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Load once and share the result (coalescing concurrent callers); `load()` refreshes. */
|
|
48
|
+
async function ensureLoaded() {
|
|
49
|
+
if (available.value !== null) return
|
|
50
|
+
if (!inFlight) inFlight = load().finally(() => (inFlight = null))
|
|
51
|
+
return inFlight
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Mint a key. Returns the created record PLUS the one-time raw secret (shown once). */
|
|
55
|
+
async function create(label: string): Promise<CreatedPublicApiKey> {
|
|
56
|
+
const ws = useWorkspaceStore()
|
|
57
|
+
const created = await api.createPublicApiKey(ws.requireId(), { label })
|
|
58
|
+
// Prepend: the backend lists newest-first, so the freshly minted key belongs at the
|
|
59
|
+
// top — matching the order a subsequent `load()` would produce.
|
|
60
|
+
keys.value = [created.key, ...keys.value]
|
|
61
|
+
available.value = true
|
|
62
|
+
return created
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function revoke(id: string) {
|
|
66
|
+
const ws = useWorkspaceStore()
|
|
67
|
+
await api.revokePublicApiKey(ws.requireId(), id)
|
|
68
|
+
keys.value = keys.value.filter((k) => k.id !== id)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return { keys, loading, available, load, ensureLoaded, create, revoke }
|
|
72
|
+
})
|