@cat-factory/app 0.314.0 → 0.315.2
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/layout/NotificationsInbox.vue +62 -129
- package/app/components/settings/ApiTokensPanel.vue +86 -7
- package/app/composables/usePipelineErrorToast.ts +4 -0
- package/app/stores/publicApiKeys.spec.ts +28 -0
- package/app/stores/publicApiKeys.ts +17 -3
- package/app/utils/notificationRoutes.spec.ts +81 -0
- package/app/utils/notificationRoutes.ts +179 -0
- package/i18n/locales/de.json +24 -5
- package/i18n/locales/en.json +24 -5
- package/i18n/locales/es.json +24 -5
- package/i18n/locales/fr.json +24 -5
- package/i18n/locales/he.json +24 -5
- package/i18n/locales/it.json +24 -5
- package/i18n/locales/ja.json +24 -5
- package/i18n/locales/pl.json +24 -5
- package/i18n/locales/tr.json +24 -5
- package/i18n/locales/uk.json +24 -5
- package/package.json +2 -2
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
import type { Notification } from '~/types/domain'
|
|
3
3
|
import type { ReviewEffort } from '~/types/merge'
|
|
4
4
|
import SectionLabel from '~/components/common/SectionLabel.vue'
|
|
5
|
+
import {
|
|
6
|
+
notificationTarget,
|
|
7
|
+
notificationVerbKey,
|
|
8
|
+
type NotificationRouteDeps,
|
|
9
|
+
} from '~/utils/notificationRoutes'
|
|
5
10
|
|
|
6
11
|
// The board's notification inbox: a bell with an open-count badge that opens a
|
|
7
12
|
// panel of human-actionable items (a PR awaiting a merge decision, a completed
|
|
@@ -133,6 +138,45 @@ function actionLabel(n: Notification): string {
|
|
|
133
138
|
return te(key) ? t(key) : t('layout.notifications.action.markRead')
|
|
134
139
|
}
|
|
135
140
|
|
|
141
|
+
// The store reads and openers every route uses (`utils/notificationRoutes.ts`).
|
|
142
|
+
const routeDeps: NotificationRouteDeps = {
|
|
143
|
+
getInstance: (id) => execution.getInstance(id),
|
|
144
|
+
select: (id) => ui.select(id),
|
|
145
|
+
openStepDetail: (id, index) => ui.openStepDetail(id, index),
|
|
146
|
+
openDecision: (id, decisionId) => ui.openDecision(id, decisionId),
|
|
147
|
+
openFollowUps: (id) => ui.openFollowUps(id),
|
|
148
|
+
openForkDecision: (id) => ui.openForkDecision(id),
|
|
149
|
+
openPrReview: (id) => ui.openPrReview(id),
|
|
150
|
+
openBugFishing: (id) => ui.openBugFishing(id),
|
|
151
|
+
openRequirementReview: (id) => ui.openRequirementReview(id),
|
|
152
|
+
openClarityReview: (id) => ui.openClarityReview(id),
|
|
153
|
+
openInitiativeTracker: (id) => ui.openInitiativeTracker(id),
|
|
154
|
+
openOperatorDashboard: () => ui.openOperatorDashboard(),
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The primary verb for a card whose act button only marks it read, from the same resolution its
|
|
159
|
+
* click takes. `te()`-guarded like the act label: a locale missing the key gets no button rather
|
|
160
|
+
* than a raw key path.
|
|
161
|
+
*/
|
|
162
|
+
function goToLabel(n: Notification): string | null {
|
|
163
|
+
const key = notificationVerbKey(n, routeDeps)
|
|
164
|
+
return key && te(key) ? t(key) : null
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Each open card's go-to label, computed once per change rather than once per binding. */
|
|
168
|
+
const goToLabels = computed(
|
|
169
|
+
() => new Map(notifications.open.map((n) => [n.id, goToLabel(n)] as const)),
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
// The popover is closed when a card takes the person somewhere, so it does not stay open under
|
|
173
|
+
// the window it opened and reappear when that window closes.
|
|
174
|
+
const open = ref(false)
|
|
175
|
+
function goTo(n: Notification) {
|
|
176
|
+
open.value = false
|
|
177
|
+
reveal(n)
|
|
178
|
+
}
|
|
179
|
+
|
|
136
180
|
/** A notification the escalation sweep has flagged as overdue (waited past the threshold). */
|
|
137
181
|
function isUrgent(n: Notification): boolean {
|
|
138
182
|
return n.severity === 'urgent'
|
|
@@ -217,120 +261,9 @@ async function dismiss(n: Notification) {
|
|
|
217
261
|
}
|
|
218
262
|
}
|
|
219
263
|
|
|
220
|
-
/**
|
|
221
|
-
* Clicking a notification's title takes the user where they can act on it. A
|
|
222
|
-
* `requirement_review` / `clarity_review` summons them straight back into the matching review
|
|
223
|
-
* window (the async incorporate/re-review raised new findings or hit the cap); every other
|
|
224
|
-
* type just focuses the related block on the board.
|
|
225
|
-
*/
|
|
264
|
+
/** Clicking a notification's title, or its verb, takes the user where they can act on it. */
|
|
226
265
|
function reveal(n: Notification) {
|
|
227
|
-
|
|
228
|
-
// dashboard where the live aggregate numbers behind the alert live.
|
|
229
|
-
if (n.type === 'platform_health') return ui.openOperatorDashboard()
|
|
230
|
-
if (!n.blockId) return
|
|
231
|
-
if (n.type === 'requirement_review') ui.openRequirementReview(n.blockId)
|
|
232
|
-
else if (n.type === 'clarity_review') ui.openClarityReview(n.blockId)
|
|
233
|
-
else if (n.type === 'decision_required') revealDecision(n)
|
|
234
|
-
else if (n.type === 'human_test_ready') revealHumanTest(n)
|
|
235
|
-
else if (n.type === 'visual_confirmation_ready') revealVisualConfirm(n)
|
|
236
|
-
else if (n.type === 'human_review') revealHumanReview(n)
|
|
237
|
-
else if (n.type === 'followup_pending') revealFollowUps(n)
|
|
238
|
-
else if (n.type === 'fork_decision_pending') revealForkDecision(n)
|
|
239
|
-
else if (n.type === 'judge_review') revealJudge(n)
|
|
240
|
-
else if (n.type === 'pr_review_ready') revealPrReview(n)
|
|
241
|
-
else if (n.type === 'bug_fishing_triage') revealBugFishing(n)
|
|
242
|
-
else if (n.type === 'initiative') ui.openInitiativeTracker(n.blockId)
|
|
243
|
-
else ui.select(n.blockId)
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
/**
|
|
247
|
-
* Open the gate window for a parked `human-review` gate: find the run's human-review step and
|
|
248
|
-
* open it through the universal step dispatch (its archetype declares the `gate` result view,
|
|
249
|
-
* where the human can request a freeform fix). Falls back to focusing the block.
|
|
250
|
-
*/
|
|
251
|
-
function revealHumanReview(n: Notification) {
|
|
252
|
-
const instance = n.executionId ? execution.getInstance(n.executionId) : undefined
|
|
253
|
-
const idx = instance?.steps.findIndex((s) => s.agentKind === 'human-review') ?? -1
|
|
254
|
-
if (instance && idx >= 0) ui.openStepDetail(instance.id, idx)
|
|
255
|
-
else if (n.blockId) ui.select(n.blockId)
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
/**
|
|
259
|
-
* Open the Follow-up companion window for a run whose Coder parked on undecided items.
|
|
260
|
-
* Falls back to focusing the block when the run isn't loaded.
|
|
261
|
-
*/
|
|
262
|
-
function revealFollowUps(n: Notification) {
|
|
263
|
-
if (n.executionId && execution.getInstance(n.executionId)) ui.openFollowUps(n.executionId)
|
|
264
|
-
else if (n.blockId) ui.select(n.blockId)
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
/**
|
|
268
|
-
* Open the implementation-fork decision window for a run parked awaiting a fork choice.
|
|
269
|
-
* Falls back to focusing the block when the run isn't loaded.
|
|
270
|
-
*/
|
|
271
|
-
function revealForkDecision(n: Notification) {
|
|
272
|
-
if (n.executionId && execution.getInstance(n.executionId)) ui.openForkDecision(n.executionId)
|
|
273
|
-
else if (n.blockId) ui.select(n.blockId)
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
/**
|
|
277
|
-
* Open the judge window for a run parked on a rubric verdict: find the step carrying the parked
|
|
278
|
-
* verdict and open it through the universal step dispatch (a registered judge declares the
|
|
279
|
-
* `judge` result view). Falls back to focusing the block when the run isn't loaded.
|
|
280
|
-
*/
|
|
281
|
-
function revealJudge(n: Notification) {
|
|
282
|
-
const instance = n.executionId ? execution.getInstance(n.executionId) : undefined
|
|
283
|
-
const idx = instance?.steps.findIndex((s) => s.judge?.status === 'awaiting_decision') ?? -1
|
|
284
|
-
if (instance && idx >= 0) ui.openStepDetail(instance.id, idx)
|
|
285
|
-
else if (n.blockId) ui.select(n.blockId)
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
/**
|
|
289
|
-
* Open the PR deep-review window for a run parked awaiting a finding selection.
|
|
290
|
-
* Falls back to focusing the block when the run isn't loaded.
|
|
291
|
-
*/
|
|
292
|
-
function revealPrReview(n: Notification) {
|
|
293
|
-
if (n.executionId && execution.getInstance(n.executionId)) ui.openPrReview(n.executionId)
|
|
294
|
-
else if (n.blockId) ui.select(n.blockId)
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
/**
|
|
298
|
-
* Open the bug-fishing expedition window for a run whose angles have all settled. Falls back to
|
|
299
|
-
* focusing the block when the run is not loaded, exactly like its PR-review sibling.
|
|
300
|
-
*/
|
|
301
|
-
function revealBugFishing(n: Notification) {
|
|
302
|
-
if (n.executionId && execution.getInstance(n.executionId)) ui.openBugFishing(n.executionId)
|
|
303
|
-
else if (n.blockId) ui.select(n.blockId)
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
/**
|
|
307
|
-
* Open the human-testing window for a parked `human-test` gate: find the run's parked
|
|
308
|
-
* human-test step and open it through the universal step dispatch (its archetype declares
|
|
309
|
-
* the `human-test` result view). Falls back to focusing the block.
|
|
310
|
-
*/
|
|
311
|
-
function revealHumanTest(n: Notification) {
|
|
312
|
-
const instance = n.executionId ? execution.getInstance(n.executionId) : undefined
|
|
313
|
-
const idx =
|
|
314
|
-
instance?.steps.findIndex(
|
|
315
|
-
(s) => s.agentKind === 'human-test' && s.state === 'waiting_decision',
|
|
316
|
-
) ?? -1
|
|
317
|
-
if (instance && idx >= 0) ui.openStepDetail(instance.id, idx)
|
|
318
|
-
else if (n.blockId) ui.select(n.blockId)
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
/**
|
|
322
|
-
* Open the visual-confirmation window for a parked `visual-confirmation` gate: find the run's
|
|
323
|
-
* parked step and open it through the universal step dispatch (its archetype declares the
|
|
324
|
-
* `visual-confirm` result view). Falls back to focusing the block.
|
|
325
|
-
*/
|
|
326
|
-
function revealVisualConfirm(n: Notification) {
|
|
327
|
-
const instance = n.executionId ? execution.getInstance(n.executionId) : undefined
|
|
328
|
-
const idx =
|
|
329
|
-
instance?.steps.findIndex(
|
|
330
|
-
(s) => s.agentKind === 'visual-confirmation' && s.state === 'waiting_decision',
|
|
331
|
-
) ?? -1
|
|
332
|
-
if (instance && idx >= 0) ui.openStepDetail(instance.id, idx)
|
|
333
|
-
else if (n.blockId) ui.select(n.blockId)
|
|
266
|
+
notificationTarget(n, routeDeps)?.open()
|
|
334
267
|
}
|
|
335
268
|
|
|
336
269
|
/**
|
|
@@ -370,23 +303,10 @@ function revealFailingRun(run: { executionId: string; blockId: string | null })
|
|
|
370
303
|
if (execution.getInstance(run.executionId)) ui.openObservability(run.executionId)
|
|
371
304
|
else if (run.blockId) ui.select(run.blockId)
|
|
372
305
|
}
|
|
373
|
-
|
|
374
|
-
/**
|
|
375
|
-
* Open the decision surface for a parked iteration-cap run: find the run's step that is
|
|
376
|
-
* waiting on a human and open it through the universal step dispatch — which routes a
|
|
377
|
-
* `requirements-review` step to the review window and a companion step to its detail
|
|
378
|
-
* panel (where the iteration-cap prompt lives). Falls back to focusing the block.
|
|
379
|
-
*/
|
|
380
|
-
function revealDecision(n: Notification) {
|
|
381
|
-
const instance = n.executionId ? execution.getInstance(n.executionId) : undefined
|
|
382
|
-
const idx = instance?.steps.findIndex((s) => s.state === 'waiting_decision') ?? -1
|
|
383
|
-
if (instance && idx >= 0) ui.openStepDetail(instance.id, idx)
|
|
384
|
-
else if (n.blockId) ui.select(n.blockId)
|
|
385
|
-
}
|
|
386
306
|
</script>
|
|
387
307
|
|
|
388
308
|
<template>
|
|
389
|
-
<UPopover v-if="notifications.count" :content="{ align: 'end' }">
|
|
309
|
+
<UPopover v-if="notifications.count" v-model:open="open" :content="{ align: 'end' }">
|
|
390
310
|
<UButton
|
|
391
311
|
data-testid="notifications-bell"
|
|
392
312
|
:color="hasUrgent ? 'error' : 'warning'"
|
|
@@ -426,7 +346,7 @@ function revealDecision(n: Notification) {
|
|
|
426
346
|
variant="link"
|
|
427
347
|
class="block min-w-0 flex-1 truncate p-0 text-start text-sm font-medium text-default hover:underline"
|
|
428
348
|
:title="n.title"
|
|
429
|
-
@click="
|
|
349
|
+
@click="goTo(n)"
|
|
430
350
|
>
|
|
431
351
|
{{ n.title }}
|
|
432
352
|
</UButton>
|
|
@@ -502,11 +422,24 @@ function revealDecision(n: Notification) {
|
|
|
502
422
|
@update:model-value="effortByCard[n.id] = $event"
|
|
503
423
|
/>
|
|
504
424
|
<div class="mt-2 flex items-center gap-1.5">
|
|
425
|
+
<!-- The action, where marking read is not one: opens what the title opens. -->
|
|
426
|
+
<!-- Soft, not solid: the inbox lists many cards, and a solid button on each would put
|
|
427
|
+
many primaries in one view. -->
|
|
505
428
|
<UButton
|
|
506
|
-
|
|
429
|
+
v-if="goToLabels.get(n.id)"
|
|
430
|
+
data-testid="notification-go-to"
|
|
507
431
|
:color="accent(n)"
|
|
508
432
|
variant="soft"
|
|
509
433
|
size="xs"
|
|
434
|
+
@click="goTo(n)"
|
|
435
|
+
>
|
|
436
|
+
{{ goToLabels.get(n.id) }}
|
|
437
|
+
</UButton>
|
|
438
|
+
<UButton
|
|
439
|
+
data-testid="notification-act"
|
|
440
|
+
:color="goToLabels.get(n.id) ? 'neutral' : accent(n)"
|
|
441
|
+
:variant="goToLabels.get(n.id) ? 'outline' : 'soft'"
|
|
442
|
+
size="xs"
|
|
510
443
|
:loading="busy === n.id"
|
|
511
444
|
:disabled="!access.canExecuteRuns.value"
|
|
512
445
|
:title="access.canExecuteRuns.value ? undefined : t('access.noRunExecute')"
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
// API access tokens
|
|
3
|
-
// `/api/v1` surface (`Authorization: Bearer cf_live_…`). Keys are
|
|
4
|
-
// so the raw secret is shown EXACTLY ONCE, on create; the list thereafter
|
|
5
|
-
// only (label + created / last-used). To rotate a token, revoke it and mint a new
|
|
6
|
-
// Opened from the Integrations hub.
|
|
2
|
+
// API access tokens: the account's inbound public-API keys that reach this workspace, which
|
|
3
|
+
// external systems present to the `/api/v1` surface (`Authorization: Bearer cf_live_…`). Keys are
|
|
4
|
+
// hashed one-way server-side, so the raw secret is shown EXACTLY ONCE, on create; the list thereafter
|
|
5
|
+
// renders metadata only (label + created / last-used). To rotate a token, revoke it and mint a new
|
|
6
|
+
// one. Opened from the Integrations hub.
|
|
7
7
|
import { computed, ref, watch } from 'vue'
|
|
8
|
+
import { showOverrideField } from '~/utils/uiMode'
|
|
8
9
|
import type { PublicApiKey, PublicApiScope } from '~/types/publicApiKeys'
|
|
9
10
|
import IntegrationBackTitle from '~/components/layout/IntegrationBackTitle.vue'
|
|
10
11
|
import CopyButton from '~/components/common/CopyButton.vue'
|
|
@@ -36,6 +37,50 @@ const scopeItems = computed(() => SCOPES.map((value) => ({ value, label: scopeLa
|
|
|
36
37
|
const ui = useUiStore()
|
|
37
38
|
const auth = useAuthStore()
|
|
38
39
|
const store = usePublicApiKeysStore()
|
|
40
|
+
const accounts = useAccountsStore()
|
|
41
|
+
const workspace = useWorkspaceStore()
|
|
42
|
+
const uiMode = useUiModeStore()
|
|
43
|
+
|
|
44
|
+
// A key's reach. The default is this board alone, which is what every key was before keys could
|
|
45
|
+
// span workspaces; the wider choices are an override only an account admin can make, so the field
|
|
46
|
+
// is hidden for everyone else rather than offered and refused.
|
|
47
|
+
type TokenReach = 'this' | 'all' | 'selected'
|
|
48
|
+
const reach = ref<TokenReach>('this')
|
|
49
|
+
const selectedWorkspaceIds = ref<string[]>([])
|
|
50
|
+
const canWidenReach = computed(
|
|
51
|
+
() =>
|
|
52
|
+
accounts.activeAccount?.roles?.includes('admin') === true &&
|
|
53
|
+
showOverrideField(uiMode.isAdvanced),
|
|
54
|
+
)
|
|
55
|
+
const reachItems = computed(() => [
|
|
56
|
+
{ value: 'this' as const, label: t('settings.apiTokens.add.reachThis') },
|
|
57
|
+
{ value: 'all' as const, label: t('settings.apiTokens.add.reachAll') },
|
|
58
|
+
{ value: 'selected' as const, label: t('settings.apiTokens.add.reachSelected') },
|
|
59
|
+
])
|
|
60
|
+
const workspaceItems = computed(() =>
|
|
61
|
+
workspace.accountWorkspaces.map((w) => ({ value: w.id, label: w.name })),
|
|
62
|
+
)
|
|
63
|
+
/** The `workspaceIds` to mint with: omitted for this board, `null` for all, or the picked list. */
|
|
64
|
+
function requestedReach(): string[] | null | undefined {
|
|
65
|
+
if (!canWidenReach.value || reach.value === 'this') return undefined
|
|
66
|
+
if (reach.value === 'all') return null
|
|
67
|
+
return selectedWorkspaceIds.value
|
|
68
|
+
}
|
|
69
|
+
// Gated on `canWidenReach` like `requestedReach`: a hidden picker left on 'selected' must not
|
|
70
|
+
// disable the create button.
|
|
71
|
+
const reachIncomplete = computed(
|
|
72
|
+
() =>
|
|
73
|
+
canWidenReach.value && reach.value === 'selected' && selectedWorkspaceIds.value.length === 0,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
/** The reach badge for a listed key; none for a key reaching this one board. */
|
|
77
|
+
function reachLabel(key: PublicApiKey): string | null {
|
|
78
|
+
if (key.workspaceIds === null) return t('settings.apiTokens.list.reachAll')
|
|
79
|
+
if (key.workspaceIds.length > 1) {
|
|
80
|
+
return t('settings.apiTokens.list.reachCount', { count: key.workspaceIds.length })
|
|
81
|
+
}
|
|
82
|
+
return null
|
|
83
|
+
}
|
|
39
84
|
|
|
40
85
|
/**
|
|
41
86
|
* The minter to attribute a key to. When the minter is the signed-in user we show a localized
|
|
@@ -61,7 +106,7 @@ function minterLabel(key: PublicApiKey): string | null {
|
|
|
61
106
|
/**
|
|
62
107
|
* The badge for a key bound to a person's subscription, WHOSE person named.
|
|
63
108
|
*
|
|
64
|
-
*
|
|
109
|
+
* This list shows every key reaching the board, so a colleague's bound key is right here in
|
|
65
110
|
* everyone's panel: a fixed "your subscription" tells every other member that a token they never
|
|
66
111
|
* minted reaches theirs, which is alarming and false. The same comparison {@link minterLabel} makes
|
|
67
112
|
* is available (a binding is always to the minter), so the honest badge names the owner, and falls
|
|
@@ -147,11 +192,14 @@ async function createToken() {
|
|
|
147
192
|
trimmed,
|
|
148
193
|
scope.value,
|
|
149
194
|
canBindSelf.value && identity.value === 'self',
|
|
195
|
+
requestedReach(),
|
|
150
196
|
)
|
|
151
197
|
newSecret.value = created.secret
|
|
152
198
|
label.value = ''
|
|
153
199
|
scope.value = 'write'
|
|
154
200
|
identity.value = 'system'
|
|
201
|
+
reach.value = 'this'
|
|
202
|
+
selectedWorkspaceIds.value = []
|
|
155
203
|
actionToast.success('settings.apiTokens.toast.created')
|
|
156
204
|
} catch (e) {
|
|
157
205
|
present(e, 'settings.apiTokens.toast.createFailed')
|
|
@@ -245,6 +293,15 @@ async function revokeToken(key: PublicApiKey) {
|
|
|
245
293
|
<!-- Always shown, never mode-gated: an existing key can already carry the
|
|
246
294
|
binding, and this is the only place its holder can see that a token they
|
|
247
295
|
are about to hand out reaches a personal subscription. -->
|
|
296
|
+
<UBadge
|
|
297
|
+
v-if="reachLabel(key)"
|
|
298
|
+
color="info"
|
|
299
|
+
variant="subtle"
|
|
300
|
+
size="sm"
|
|
301
|
+
:data-testid="`api-token-reach-${key.id}`"
|
|
302
|
+
>
|
|
303
|
+
{{ reachLabel(key) }}
|
|
304
|
+
</UBadge>
|
|
248
305
|
<UBadge
|
|
249
306
|
v-if="boundLabel(key)"
|
|
250
307
|
color="warning"
|
|
@@ -326,9 +383,31 @@ async function revokeToken(key: PublicApiKey) {
|
|
|
326
383
|
data-testid="api-token-identity"
|
|
327
384
|
/>
|
|
328
385
|
</UFormField>
|
|
386
|
+
<UFormField
|
|
387
|
+
v-if="canWidenReach"
|
|
388
|
+
:label="t('settings.apiTokens.add.reach')"
|
|
389
|
+
:help="t('settings.apiTokens.add.reachHelp')"
|
|
390
|
+
>
|
|
391
|
+
<USelect
|
|
392
|
+
v-model="reach"
|
|
393
|
+
:items="reachItems"
|
|
394
|
+
class="w-full"
|
|
395
|
+
data-testid="api-token-reach"
|
|
396
|
+
/>
|
|
397
|
+
<USelectMenu
|
|
398
|
+
v-if="reach === 'selected'"
|
|
399
|
+
v-model="selectedWorkspaceIds"
|
|
400
|
+
:items="workspaceItems"
|
|
401
|
+
value-key="value"
|
|
402
|
+
multiple
|
|
403
|
+
:placeholder="t('settings.apiTokens.add.reachPick')"
|
|
404
|
+
class="mt-2 w-full"
|
|
405
|
+
data-testid="api-token-reach-workspaces"
|
|
406
|
+
/>
|
|
407
|
+
</UFormField>
|
|
329
408
|
<UButton
|
|
330
409
|
:loading="busy"
|
|
331
|
-
:disabled="!label.trim()"
|
|
410
|
+
:disabled="!label.trim() || reachIncomplete"
|
|
332
411
|
data-testid="api-token-create"
|
|
333
412
|
@click="createToken"
|
|
334
413
|
>
|
|
@@ -183,6 +183,10 @@ const CONFLICT_INFO: Record<Exclude<ConflictReason, BespokeConflictReason>, Conf
|
|
|
183
183
|
titleKey: 'errors.conflict.title.session_stale',
|
|
184
184
|
descriptionKey: 'errors.conflict.description.session_stale',
|
|
185
185
|
},
|
|
186
|
+
cursor_expired: {
|
|
187
|
+
titleKey: 'errors.conflict.title.cursor_expired',
|
|
188
|
+
descriptionKey: 'errors.conflict.description.cursor_expired',
|
|
189
|
+
},
|
|
186
190
|
task_limit_reached: {
|
|
187
191
|
titleKey: 'errors.conflict.title.task_limit_reached',
|
|
188
192
|
descriptionKey: 'errors.conflict.description.task_limit_reached',
|
|
@@ -9,6 +9,7 @@ function key(over: Partial<PublicApiKey> = {}): PublicApiKey {
|
|
|
9
9
|
id: 'pak_1',
|
|
10
10
|
accountId: 'acc1',
|
|
11
11
|
workspaceId: 'ws1',
|
|
12
|
+
workspaceIds: ['ws1'],
|
|
12
13
|
label: 'CI',
|
|
13
14
|
scope: 'write',
|
|
14
15
|
createdByUserId: null,
|
|
@@ -110,6 +111,33 @@ describe('publicApiKeys store', () => {
|
|
|
110
111
|
expect(body).toHaveBeenCalledWith({ label: 'mine', scope: 'write', actsAsSelf: true })
|
|
111
112
|
})
|
|
112
113
|
|
|
114
|
+
it('sends a reach only when one was chosen, so the default stays this workspace alone', async () => {
|
|
115
|
+
// An omitted `workspaceIds` and a `null` one mean opposite things on the wire (this board vs
|
|
116
|
+
// every board in the account), so the store must never turn "not chosen" into a value.
|
|
117
|
+
const body = vi.fn((_body: unknown) => Promise.resolve({ key: key(), secret: 's' }))
|
|
118
|
+
vi.stubGlobal('useApi', () => ({ createPublicApiKey: (_ws: string, b: unknown) => body(b) }))
|
|
119
|
+
|
|
120
|
+
const store = usePublicApiKeysStore()
|
|
121
|
+
await store.create('ci', 'read')
|
|
122
|
+
expect(body).toHaveBeenLastCalledWith({ label: 'ci', scope: 'read', actsAsSelf: false })
|
|
123
|
+
|
|
124
|
+
await store.create('all', 'read', false, null)
|
|
125
|
+
expect(body).toHaveBeenLastCalledWith({
|
|
126
|
+
label: 'all',
|
|
127
|
+
scope: 'read',
|
|
128
|
+
actsAsSelf: false,
|
|
129
|
+
workspaceIds: null,
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
await store.create('two', 'read', false, ['ws1', 'ws2'])
|
|
133
|
+
expect(body).toHaveBeenLastCalledWith({
|
|
134
|
+
label: 'two',
|
|
135
|
+
scope: 'read',
|
|
136
|
+
actsAsSelf: false,
|
|
137
|
+
workspaceIds: ['ws1', 'ws2'],
|
|
138
|
+
})
|
|
139
|
+
})
|
|
140
|
+
|
|
113
141
|
it('revoke drops the key from the list', async () => {
|
|
114
142
|
vi.stubGlobal('useApi', () => ({
|
|
115
143
|
listPublicApiKeys: () => Promise.resolve({ keys: [key({ id: 'a' }), key({ id: 'b' })] }),
|
|
@@ -58,17 +58,31 @@ export const usePublicApiKeysStore = defineStore('publicApiKeys', () => {
|
|
|
58
58
|
* `actsAsSelf` binds the key to the signed-in user's PERSONAL subscriptions, so a headless run
|
|
59
59
|
* it starts can unlock them with the password sent on that call. Passed through rather than
|
|
60
60
|
* defaulted here: the server writes the id off the session, so this is only ever a yes/no.
|
|
61
|
+
*
|
|
62
|
+
* `workspaceIds` is the key's reach: omitted for this workspace alone, `null` for every workspace
|
|
63
|
+
* in the account, or a list. Anything past this workspace needs an account admin.
|
|
61
64
|
*/
|
|
62
65
|
async function create(
|
|
63
66
|
label: string,
|
|
64
67
|
scope: PublicApiScope,
|
|
65
68
|
actsAsSelf = false,
|
|
69
|
+
workspaceIds?: string[] | null,
|
|
66
70
|
): Promise<CreatedPublicApiKey> {
|
|
67
71
|
const ws = useWorkspaceStore()
|
|
68
|
-
const
|
|
72
|
+
const workspaceId = ws.requireId()
|
|
73
|
+
const created = await api.createPublicApiKey(workspaceId, {
|
|
74
|
+
label,
|
|
75
|
+
scope,
|
|
76
|
+
actsAsSelf,
|
|
77
|
+
...(workspaceIds === undefined ? {} : { workspaceIds }),
|
|
78
|
+
})
|
|
69
79
|
// Prepend: the backend lists newest-first, so the freshly minted key belongs at the
|
|
70
|
-
// top — matching the order a subsequent `load()` would produce.
|
|
71
|
-
|
|
80
|
+
// top — matching the order a subsequent `load()` would produce. A key picked to reach only
|
|
81
|
+
// other workspaces is not in this board's list, and revoking it from here would be refused as
|
|
82
|
+
// a no-op, so it is not shown.
|
|
83
|
+
const reaches =
|
|
84
|
+
created.key.workspaceIds === null || created.key.workspaceIds.includes(workspaceId)
|
|
85
|
+
if (reaches) keys.value = [created.key, ...keys.value]
|
|
72
86
|
available.value = true
|
|
73
87
|
return created
|
|
74
88
|
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import type { ExecutionInstance, Notification } from '~/types/domain'
|
|
3
|
+
import {
|
|
4
|
+
NOTIFICATION_ROUTES,
|
|
5
|
+
OPEN_TASK_VERB_KEY,
|
|
6
|
+
notificationTarget,
|
|
7
|
+
notificationVerbKey,
|
|
8
|
+
type NotificationRouteDeps,
|
|
9
|
+
} from '~/utils/notificationRoutes'
|
|
10
|
+
import { missingI18nKeys } from '../../test/i18nKeys'
|
|
11
|
+
|
|
12
|
+
// The card's verb and the place its click lands come from ONE resolution, so a button named for
|
|
13
|
+
// answering never just selects the task. These pin that, and that every verb names a real key.
|
|
14
|
+
|
|
15
|
+
function deps(instances: ExecutionInstance[] = []): NotificationRouteDeps {
|
|
16
|
+
return {
|
|
17
|
+
getInstance: (id) => instances.find((i) => i.id === id),
|
|
18
|
+
select: vi.fn(),
|
|
19
|
+
openStepDetail: vi.fn(),
|
|
20
|
+
openDecision: vi.fn(),
|
|
21
|
+
openFollowUps: vi.fn(),
|
|
22
|
+
openForkDecision: vi.fn(),
|
|
23
|
+
openPrReview: vi.fn(),
|
|
24
|
+
openBugFishing: vi.fn(),
|
|
25
|
+
openRequirementReview: vi.fn(),
|
|
26
|
+
openClarityReview: vi.fn(),
|
|
27
|
+
openInitiativeTracker: vi.fn(),
|
|
28
|
+
openOperatorDashboard: vi.fn(),
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const card = (type: Notification['type'], over: Partial<Notification> = {}) =>
|
|
33
|
+
({ id: 'n1', type, blockId: 'blk', executionId: 'run', ...over }) as Notification
|
|
34
|
+
|
|
35
|
+
const run = (steps: unknown[]) => ({ id: 'run', blockId: 'blk', steps }) as ExecutionInstance
|
|
36
|
+
|
|
37
|
+
describe('notification routes', () => {
|
|
38
|
+
it('names a real catalog key for every verb', () => {
|
|
39
|
+
const keys = Object.values(NOTIFICATION_ROUTES)
|
|
40
|
+
.map((r) => r.verbKey)
|
|
41
|
+
.filter((k): k is string => k !== null)
|
|
42
|
+
expect(missingI18nKeys([...keys, OPEN_TASK_VERB_KEY])).toEqual([])
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('answers a parked agent question in the decision window', () => {
|
|
46
|
+
const d = deps([run([{ state: 'waiting_decision', decision: { id: 'dec', chosen: null } }])])
|
|
47
|
+
const n = card('decision_required')
|
|
48
|
+
expect(notificationVerbKey(n, d)).toBe('layout.notifications.goTo.answer')
|
|
49
|
+
notificationTarget(n, d)!.open()
|
|
50
|
+
expect(d.openDecision).toHaveBeenCalledWith('run', 'dec')
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('opens the step for any other park, such as an approval gate', () => {
|
|
54
|
+
const d = deps([run([{ state: 'done' }, { state: 'waiting_decision' }])])
|
|
55
|
+
notificationTarget(card('decision_required'), d)!.open()
|
|
56
|
+
expect(d.openStepDetail).toHaveBeenCalledWith('run', 1)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('says "Open" when only the task can be opened, and opens the task', () => {
|
|
60
|
+
// The run is not loaded, or it is loaded and not parked (a Ralph loop that gave up).
|
|
61
|
+
for (const d of [deps(), deps([run([{ state: 'failed' }])])]) {
|
|
62
|
+
const n = card('decision_required')
|
|
63
|
+
expect(notificationVerbKey(n, d)).toBe(OPEN_TASK_VERB_KEY)
|
|
64
|
+
notificationTarget(n, d)!.open()
|
|
65
|
+
expect(d.select).toHaveBeenCalledWith('blk')
|
|
66
|
+
}
|
|
67
|
+
expect(notificationVerbKey(card('fork_decision_pending'), deps())).toBe(OPEN_TASK_VERB_KEY)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('gives no verb to a card whose act button is the action, or that has nowhere to go', () => {
|
|
71
|
+
expect(notificationVerbKey(card('merge_review'), deps())).toBeNull()
|
|
72
|
+
expect(notificationVerbKey(card('budget_paused', { blockId: null }), deps())).toBeNull()
|
|
73
|
+
expect(notificationTarget(card('budget_paused', { blockId: null }), deps())).toBeNull()
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('sends a deployment-scoped health alert to the operator dashboard, with no block', () => {
|
|
77
|
+
const d = deps()
|
|
78
|
+
notificationTarget(card('platform_health', { blockId: null }), d)!.open()
|
|
79
|
+
expect(d.openOperatorDashboard).toHaveBeenCalled()
|
|
80
|
+
})
|
|
81
|
+
})
|