@cat-factory/app 0.128.0 → 0.130.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.
Files changed (44) hide show
  1. package/app/components/board/AgentFailureCard.vue +3 -1
  2. package/app/components/board/AgentStopButton.vue +3 -0
  3. package/app/components/board/BoardCanvas.vue +16 -3
  4. package/app/components/board/RecurringPipelineModal.vue +7 -1
  5. package/app/components/board/nodes/BlockNode.vue +47 -41
  6. package/app/components/brainstorm/BrainstormWindow.vue +25 -5
  7. package/app/components/clarity/ClarityReviewWindow.vue +17 -4
  8. package/app/components/docs/DocInterviewWindow.vue +5 -1
  9. package/app/components/followUp/FollowUpWindow.vue +15 -1
  10. package/app/components/forkDecision/ForkDecisionWindow.vue +7 -2
  11. package/app/components/gates/GateResultView.vue +7 -1
  12. package/app/components/humanTest/HumanTestWindow.vue +11 -5
  13. package/app/components/layout/BoardSwitcher.vue +21 -12
  14. package/app/components/layout/BoardToolbar.vue +8 -4
  15. package/app/components/layout/CommandBar.vue +137 -123
  16. package/app/components/layout/NotificationsInbox.vue +5 -1
  17. package/app/components/layout/SideBar.vue +157 -115
  18. package/app/components/layout/SpendWarningBanner.vue +3 -0
  19. package/app/components/panels/InspectorPanel.vue +23 -10
  20. package/app/components/panels/inspector/TaskExecution.vue +13 -4
  21. package/app/components/prReview/PrReviewWindow.vue +7 -3
  22. package/app/components/requirements/RequirementsReviewWindow.vue +33 -5
  23. package/app/components/settings/ApiTokensPanel.vue +18 -0
  24. package/app/components/visualConfirm/VisualConfirmationWindow.vue +7 -3
  25. package/app/composables/useBlockDeletion.ts +7 -0
  26. package/app/composables/useBlockDrag.ts +5 -0
  27. package/app/composables/useFrameResize.ts +4 -0
  28. package/app/composables/useWorkspaceAccess.spec.ts +102 -0
  29. package/app/composables/useWorkspaceAccess.ts +84 -0
  30. package/app/stores/publicApiKeys.spec.ts +1 -0
  31. package/app/stores/workspace.spec.ts +19 -0
  32. package/app/stores/workspace.ts +27 -7
  33. package/app/types/domain.ts +6 -0
  34. package/i18n/locales/de.json +7 -0
  35. package/i18n/locales/en.json +10 -0
  36. package/i18n/locales/es.json +7 -0
  37. package/i18n/locales/fr.json +7 -0
  38. package/i18n/locales/he.json +7 -0
  39. package/i18n/locales/it.json +7 -0
  40. package/i18n/locales/ja.json +7 -0
  41. package/i18n/locales/pl.json +7 -0
  42. package/i18n/locales/tr.json +7 -0
  43. package/i18n/locales/uk.json +7 -0
  44. package/package.json +2 -2
@@ -23,11 +23,30 @@ const accounts = useAccountsStore()
23
23
  const auth = useAuthStore()
24
24
  const providerConnections = useProviderConnectionsStore()
25
25
  const ui = useUiStore()
26
+ const access = useWorkspaceAccess()
26
27
 
27
28
  // The operator dashboard (deployment-level run health) is sensitive cross-workspace data,
28
29
  // so it's shown only to an admin of the active account (matching the backend admin gate).
29
30
  const isAccountAdmin = computed(() => accounts.activeAccount?.roles?.includes('admin') ?? false)
30
31
 
32
+ // Workspace-RBAC nav gating: each management destination is shown only when the caller
33
+ // holds the permission its writes require (the SPA mirror of the admin-tier controller
34
+ // middleware — a member/viewer who'd only get a 403 never sees the entry). Board authoring
35
+ // (create/repos) needs `board.write`; connections/infra/sandbox/bootstrap need
36
+ // `integrations.manage`; workspace + model configuration and the fragment library need
37
+ // `settings.manage`. Kaizen and account settings are reads / account-scoped and stay.
38
+ // Absent access (dev-open) ⇒ every `can*` is true, so nothing is hidden.
39
+ const showCreate = computed(() => access.canWriteBoard.value)
40
+ const showAddFromRepo = computed(() => github.available && access.canWriteBoard.value)
41
+ const showBootstrap = computed(() => access.canManageIntegrations.value)
42
+ const showRepositories = computed(() => showAddFromRepo.value || showBootstrap.value)
43
+ const showIntegrationsHub = computed(() => access.canManageIntegrations.value)
44
+ const showSandbox = computed(() => access.canManageIntegrations.value)
45
+ // The Configuration section carries workspace/model settings (`settings.manage`) AND the
46
+ // account-scoped account-settings / operator-dashboard entries, so it shows when the caller
47
+ // can manage settings OR accounts are enabled (the account entries have their own gates).
48
+ const showConfiguration = computed(() => access.canManageSettings.value || accounts.enabled)
49
+
31
50
  // The Infrastructure menu (agent-container execution + test environments) shows whenever the
32
51
  // deployment reports its infrastructure capability — every facade populates `auth.infrastructure`
33
52
  // (it drives the execution-backend selector), so there is always an execution + test-env backend
@@ -36,12 +55,18 @@ const isAccountAdmin = computed(() => accounts.activeAccount?.roles?.includes('a
36
55
  // (somehow) omits the descriptor.
37
56
  const showInfrastructure = computed(
38
57
  () =>
39
- auth.infrastructure != null ||
40
- auth.localMode?.enabled === true ||
41
- providerConnections.isAvailable('runner-pool') ||
42
- providerConnections.isAvailable('environment'),
58
+ // Provisioning/managing infrastructure is `integrations.manage`, so a member/viewer
59
+ // never sees the section (they'd only 403 on the writes inside it).
60
+ access.canManageIntegrations.value &&
61
+ (auth.infrastructure != null ||
62
+ auth.localMode?.enabled === true ||
63
+ providerConnections.isAvailable('runner-pool') ||
64
+ providerConnections.isAvailable('environment')),
43
65
  )
44
66
 
67
+ // The prompt-fragment library is a `settings.manage` authoring surface.
68
+ const showWorkspaceContext = computed(() => library.available && access.canManageSettings.value)
69
+
45
70
  // `isCompact` (< lg) is the breakpoint at which the navbar is an off-canvas drawer;
46
71
  // above it the aside is static and the drawer flag is inert.
47
72
  const { isCompact } = useViewport()
@@ -166,57 +191,65 @@ watch(
166
191
  <UKbd value="⌘K" />
167
192
  </button>
168
193
 
169
- <section>
170
- <h2 class="mb-2 px-1 text-[11px] font-semibold uppercase tracking-wide text-slate-400">
171
- {{ t('nav.create') }}
172
- </h2>
173
- <div class="space-y-1.5">
174
- <UButton
175
- block
176
- color="primary"
177
- variant="soft"
178
- size="sm"
179
- icon="i-lucide-workflow"
180
- class="justify-start"
181
- @click="ui.openBuilder()"
182
- >
183
- {{ t('nav.buildPipeline') }}
184
- </UButton>
185
- </div>
186
- </section>
187
-
188
- <USeparator />
194
+ <template v-if="showCreate">
195
+ <USeparator />
196
+ <section>
197
+ <h2 class="mb-2 px-1 text-[11px] font-semibold uppercase tracking-wide text-slate-400">
198
+ {{ t('nav.create') }}
199
+ </h2>
200
+ <div class="space-y-1.5">
201
+ <UButton
202
+ block
203
+ color="primary"
204
+ variant="soft"
205
+ size="sm"
206
+ icon="i-lucide-workflow"
207
+ class="justify-start"
208
+ data-testid="nav-build-pipeline"
209
+ @click="ui.openBuilder()"
210
+ >
211
+ {{ t('nav.buildPipeline') }}
212
+ </UButton>
213
+ </div>
214
+ </section>
215
+ </template>
189
216
 
190
- <section>
191
- <h2 class="mb-2 px-1 text-[11px] font-semibold uppercase tracking-wide text-slate-400">
192
- {{ t('nav.repositories') }}
193
- </h2>
194
- <div class="space-y-1.5">
195
- <UButton
196
- v-if="github.available"
197
- block
198
- color="primary"
199
- variant="soft"
200
- size="sm"
201
- icon="i-lucide-folder-git-2"
202
- class="justify-start"
203
- @click="ui.openAddService()"
204
- >
205
- {{ t('nav.addFromRepo') }}
206
- </UButton>
207
- <UButton
208
- block
209
- color="primary"
210
- variant="soft"
211
- size="sm"
212
- icon="i-lucide-git-branch-plus"
213
- class="justify-start"
214
- @click="ui.openBootstrap()"
215
- >
216
- {{ t('nav.bootstrapRepo') }}
217
- </UButton>
218
- </div>
219
- </section>
217
+ <template v-if="showRepositories">
218
+ <USeparator />
219
+ <section>
220
+ <h2 class="mb-2 px-1 text-[11px] font-semibold uppercase tracking-wide text-slate-400">
221
+ {{ t('nav.repositories') }}
222
+ </h2>
223
+ <div class="space-y-1.5">
224
+ <UButton
225
+ v-if="showAddFromRepo"
226
+ block
227
+ color="primary"
228
+ variant="soft"
229
+ size="sm"
230
+ icon="i-lucide-folder-git-2"
231
+ class="justify-start"
232
+ data-testid="nav-add-from-repo"
233
+ @click="ui.openAddService()"
234
+ >
235
+ {{ t('nav.addFromRepo') }}
236
+ </UButton>
237
+ <UButton
238
+ v-if="showBootstrap"
239
+ block
240
+ color="primary"
241
+ variant="soft"
242
+ size="sm"
243
+ icon="i-lucide-git-branch-plus"
244
+ class="justify-start"
245
+ data-testid="nav-bootstrap-repo"
246
+ @click="ui.openBootstrap()"
247
+ >
248
+ {{ t('nav.bootstrapRepo') }}
249
+ </UButton>
250
+ </div>
251
+ </section>
252
+ </template>
220
253
 
221
254
  <USeparator />
222
255
 
@@ -229,12 +262,14 @@ watch(
229
262
  this single button — the hub modal lists them grouped (source control,
230
263
  communication, documents, trackers, observability, model providers). -->
231
264
  <UButton
265
+ v-if="showIntegrationsHub"
232
266
  block
233
267
  color="primary"
234
268
  variant="soft"
235
269
  size="sm"
236
270
  icon="i-lucide-blocks"
237
271
  class="justify-start"
272
+ data-testid="nav-integrations"
238
273
  @click="ui.openIntegrations()"
239
274
  >
240
275
  {{ t('nav.integrations') }}
@@ -242,6 +277,7 @@ watch(
242
277
  <!-- The Sandbox: try prompt versions/models against graded fixtures, off to the
243
278
  side of the board. Opens the on-demand testing window. -->
244
279
  <UButton
280
+ v-if="showSandbox"
245
281
  block
246
282
  color="primary"
247
283
  variant="soft"
@@ -252,7 +288,8 @@ watch(
252
288
  >
253
289
  {{ t('nav.sandbox') }}
254
290
  </UButton>
255
- <!-- The Kaizen screen: grading history + verified prompt/agent/model combos. -->
291
+ <!-- The Kaizen screen: grading history + verified prompt/agent/model combos. A
292
+ read surface (grading history), so it stays visible to every resolved role. -->
256
293
  <UButton
257
294
  block
258
295
  color="primary"
@@ -307,7 +344,7 @@ watch(
307
344
  </section>
308
345
  </template>
309
346
 
310
- <template v-if="library.available">
347
+ <template v-if="showWorkspaceContext">
311
348
  <USeparator />
312
349
  <section>
313
350
  <h2 class="mb-2 px-1 text-[11px] font-semibold uppercase tracking-wide text-slate-400">
@@ -327,66 +364,71 @@ watch(
327
364
  </section>
328
365
  </template>
329
366
 
330
- <USeparator />
331
- <section>
332
- <h2 class="mb-2 px-1 text-[11px] font-semibold uppercase tracking-wide text-slate-400">
333
- {{ t('nav.configuration') }}
334
- </h2>
335
- <div class="space-y-1.5">
336
- <!-- Merge thresholds, issue writeback and default service best practices are
337
- now tabs inside Workspace settings. -->
338
- <UButton
339
- block
340
- color="primary"
341
- variant="soft"
342
- size="sm"
343
- icon="i-lucide-sliders-horizontal"
344
- class="justify-start"
345
- @click="ui.openWorkspaceSettings()"
346
- >
347
- {{ t('nav.workspaceSettings') }}
348
- </UButton>
349
- <UButton
350
- block
351
- color="primary"
352
- variant="soft"
353
- size="sm"
354
- icon="i-lucide-cpu"
355
- class="justify-start"
356
- @click="ui.openModelConfig()"
357
- >
358
- {{ t('nav.modelConfiguration') }}
359
- </UButton>
360
- <!-- Account & team: members + roles, invitations, email sender, account API keys.
367
+ <template v-if="showConfiguration">
368
+ <USeparator />
369
+ <section>
370
+ <h2 class="mb-2 px-1 text-[11px] font-semibold uppercase tracking-wide text-slate-400">
371
+ {{ t('nav.configuration') }}
372
+ </h2>
373
+ <div class="space-y-1.5">
374
+ <!-- Merge thresholds, issue writeback and default service best practices are
375
+ now tabs inside Workspace settings. `settings.manage` — hidden for members/viewers. -->
376
+ <UButton
377
+ v-if="access.canManageSettings.value"
378
+ block
379
+ color="primary"
380
+ variant="soft"
381
+ size="sm"
382
+ icon="i-lucide-sliders-horizontal"
383
+ class="justify-start"
384
+ data-testid="nav-workspace-settings"
385
+ @click="ui.openWorkspaceSettings()"
386
+ >
387
+ {{ t('nav.workspaceSettings') }}
388
+ </UButton>
389
+ <UButton
390
+ v-if="access.canManageSettings.value"
391
+ block
392
+ color="primary"
393
+ variant="soft"
394
+ size="sm"
395
+ icon="i-lucide-cpu"
396
+ class="justify-start"
397
+ @click="ui.openModelConfig()"
398
+ >
399
+ {{ t('nav.modelConfiguration') }}
400
+ </UButton>
401
+ <!-- Account & team: members + roles, invitations, email sender, account API keys.
361
402
  Shown once accounts (auth) are enabled. -->
362
- <UButton
363
- v-if="accounts.enabled"
364
- block
365
- color="primary"
366
- variant="soft"
367
- size="sm"
368
- icon="i-lucide-users"
369
- class="justify-start"
370
- @click="ui.openAccountSettings()"
371
- >
372
- {{ t('nav.accountSettings') }}
373
- </UButton>
374
- <!-- Platform observability: deployment-level run health. Admin-only, like the backend gate. -->
375
- <UButton
376
- v-if="accounts.enabled && isAccountAdmin"
377
- block
378
- color="primary"
379
- variant="soft"
380
- size="sm"
381
- icon="i-lucide-gauge"
382
- class="justify-start"
383
- data-testid="nav-operator-dashboard"
384
- @click="ui.openOperatorDashboard()"
385
- >
386
- {{ t('nav.operatorDashboard') }}
387
- </UButton>
388
- </div>
389
- </section>
403
+ <UButton
404
+ v-if="accounts.enabled"
405
+ block
406
+ color="primary"
407
+ variant="soft"
408
+ size="sm"
409
+ icon="i-lucide-users"
410
+ class="justify-start"
411
+ @click="ui.openAccountSettings()"
412
+ >
413
+ {{ t('nav.accountSettings') }}
414
+ </UButton>
415
+ <!-- Platform observability: deployment-level run health. Admin-only, like the backend gate. -->
416
+ <UButton
417
+ v-if="accounts.enabled && isAccountAdmin"
418
+ block
419
+ color="primary"
420
+ variant="soft"
421
+ size="sm"
422
+ icon="i-lucide-gauge"
423
+ class="justify-start"
424
+ data-testid="nav-operator-dashboard"
425
+ @click="ui.openOperatorDashboard()"
426
+ >
427
+ {{ t('nav.operatorDashboard') }}
428
+ </UButton>
429
+ </div>
430
+ </section>
431
+ </template>
390
432
  </div>
391
433
 
392
434
  <div class="mt-auto space-y-2">
@@ -3,6 +3,7 @@ import { computed } from 'vue'
3
3
 
4
4
  const { t, n } = useI18n()
5
5
  const workspace = useWorkspaceStore()
6
+ const access = useWorkspaceAccess()
6
7
 
7
8
  const spend = computed(() => workspace.spend)
8
9
  /** Show the large warning only once the budget has been reached. */
@@ -80,6 +81,8 @@ async function resume() {
80
81
  variant="solid"
81
82
  icon="i-lucide-play"
82
83
  :loading="resuming"
84
+ :disabled="!access.canExecuteRuns.value"
85
+ :title="access.canExecuteRuns.value ? undefined : t('access.noRunExecute')"
83
86
  @click="resume"
84
87
  >
85
88
  {{ t('layout.spendWarningBanner.resume') }}
@@ -35,6 +35,7 @@ const agentRuns = useAgentRunsStore()
35
35
  const github = useGitHubStore()
36
36
  const recurring = useRecurringPipelinesStore()
37
37
  const requirements = useRequirementsStore()
38
+ const access = useWorkspaceAccess()
38
39
  const { t } = useI18n()
39
40
 
40
41
  // When the selected task block backs a recurring pipeline, the inspector shows the
@@ -105,15 +106,20 @@ const runnable = computed(() => (block.value ? board.isRunnable(block.value.id)
105
106
  const unmetDepTitles = computed(() =>
106
107
  block.value && isTask.value ? board.unmetDeps(block.value.id).map((b) => b.title) : [],
107
108
  )
108
- const runBlockedReason = computed(() =>
109
- unmetDepTitles.value.length
109
+ const runBlockedReason = computed(() => {
110
+ // A read-only viewer can inspect a task but never start/re-run it — surface WHY on the
111
+ // locked trigger, exactly like an unmet-dependency lock (the backend rejects it anyway).
112
+ if (!access.canExecuteRuns.value) return t('access.noRunExecute')
113
+ return unmetDepTitles.value.length
110
114
  ? t(
111
115
  'panels.inspector.runBlocked',
112
116
  { count: unmetDepTitles.value.length, names: unmetDepTitles.value.join(', ') },
113
117
  unmetDepTitles.value.length,
114
118
  )
115
- : null,
116
- )
119
+ : null
120
+ })
121
+ // The Run trigger is enabled only when the task is runnable AND the caller may execute runs.
122
+ const canRun = computed(() => runnable.value && access.canExecuteRuns.value)
117
123
 
118
124
  // The delete control names what it removes, so selecting a task and deleting it
119
125
  // reads as "Delete task" rather than ambiguously removing the whole service.
@@ -564,15 +570,16 @@ const showOriginalDescription = ref(false)
564
570
 
565
571
  <!-- actions -->
566
572
  <div class="flex items-center gap-2">
567
- <UDropdownMenu v-if="isTask" :items="runMenu">
573
+ <UDropdownMenu v-if="isTask" :items="runMenu" :disabled="!canRun">
568
574
  <UButton
569
- :color="runnable ? 'primary' : 'neutral'"
575
+ :color="canRun ? 'primary' : 'neutral'"
570
576
  variant="soft"
571
577
  size="sm"
572
- :icon="runnable ? 'i-lucide-play' : 'i-lucide-lock'"
578
+ :icon="canRun ? 'i-lucide-play' : 'i-lucide-lock'"
573
579
  trailing-icon="i-lucide-chevron-down"
574
- :disabled="!runnable"
580
+ :disabled="!canRun"
575
581
  :title="runBlockedReason ?? undefined"
582
+ data-testid="run-start"
576
583
  >
577
584
  {{ instance ? t('panels.inspector.reRun') : t('panels.inspector.run') }}
578
585
  </UButton>
@@ -595,7 +602,12 @@ const showOriginalDescription = ref(false)
595
602
  icon="i-lucide-archive"
596
603
  :class="isServiceFrame ? 'ms-auto' : ''"
597
604
  data-testid="inspector-archive"
598
- :title="t('panels.inspector.archiveService')"
605
+ :disabled="!access.canWriteBoard.value"
606
+ :title="
607
+ access.canWriteBoard.value
608
+ ? t('panels.inspector.archiveService')
609
+ : t('access.noBoardWrite')
610
+ "
599
611
  @click="archive"
600
612
  >
601
613
  {{ t('panels.inspector.archiveService') }}
@@ -607,7 +619,8 @@ const showOriginalDescription = ref(false)
607
619
  icon="i-lucide-trash-2"
608
620
  :class="isServiceFrame ? '' : 'ms-auto'"
609
621
  data-testid="inspector-delete"
610
- :title="deleteLabel"
622
+ :disabled="!access.canWriteBoard.value"
623
+ :title="access.canWriteBoard.value ? deleteLabel : t('access.noBoardWrite')"
611
624
  @click="remove"
612
625
  >
613
626
  {{ deleteLabel }}
@@ -21,6 +21,7 @@ const agentRuns = useAgentRunsStore()
21
21
  const ui = useUiStore()
22
22
  const models = useModelsStore()
23
23
  const reviews = useReviewStage()
24
+ const access = useWorkspaceAccess()
24
25
  const { t, te } = useI18n()
25
26
  const { confirm } = useConfirm()
26
27
 
@@ -232,8 +233,12 @@ async function mergePr() {
232
233
  variant="ghost"
233
234
  size="xs"
234
235
  :loading="stopping"
235
- :disabled="resetting"
236
- :title="t('inspector.execution.stopTooltip')"
236
+ :disabled="resetting || !access.canExecuteRuns.value"
237
+ :title="
238
+ access.canExecuteRuns.value
239
+ ? t('inspector.execution.stopTooltip')
240
+ : t('access.noRunExecute')
241
+ "
237
242
  data-testid="run-stop"
238
243
  @click="stopRun"
239
244
  >
@@ -246,8 +251,12 @@ async function mergePr() {
246
251
  variant="ghost"
247
252
  size="xs"
248
253
  :loading="resetting"
249
- :disabled="stopping"
250
- :title="t('inspector.execution.resetTooltip')"
254
+ :disabled="stopping || !access.canExecuteRuns.value"
255
+ :title="
256
+ access.canExecuteRuns.value
257
+ ? t('inspector.execution.resetTooltip')
258
+ : t('access.noRunExecute')
259
+ "
251
260
  data-testid="run-reset"
252
261
  @click="resetRun"
253
262
  >
@@ -22,6 +22,7 @@ import type {
22
22
  const execution = useExecutionStore()
23
23
  const board = useBoardStore()
24
24
  const prReview = usePrReviewStore()
25
+ const access = useWorkspaceAccess()
25
26
 
26
27
  const { t } = useI18n()
27
28
 
@@ -305,7 +306,8 @@ async function onResolve(action: PrReviewResolution): Promise<void> {
305
306
  <UButton
306
307
  color="neutral"
307
308
  variant="ghost"
308
- :disabled="!canResolve"
309
+ :disabled="!canResolve || !access.canExecuteRuns.value"
310
+ :title="access.canExecuteRuns.value ? undefined : t('access.noRunExecute')"
309
311
  data-testid="pr-review-finish"
310
312
  @click="onResolve('finish')"
311
313
  >
@@ -314,7 +316,8 @@ async function onResolve(action: PrReviewResolution): Promise<void> {
314
316
  <UButton
315
317
  color="neutral"
316
318
  variant="soft"
317
- :disabled="!canResolve || !hasSelection"
319
+ :disabled="!canResolve || !hasSelection || !access.canExecuteRuns.value"
320
+ :title="access.canExecuteRuns.value ? undefined : t('access.noRunExecute')"
318
321
  data-testid="pr-review-post"
319
322
  @click="onResolve('post')"
320
323
  >
@@ -323,7 +326,8 @@ async function onResolve(action: PrReviewResolution): Promise<void> {
323
326
  <UButton
324
327
  color="primary"
325
328
  :loading="prReview.resolving"
326
- :disabled="!canResolve || !hasSelection"
329
+ :disabled="!canResolve || !hasSelection || !access.canExecuteRuns.value"
330
+ :title="access.canExecuteRuns.value ? undefined : t('access.noRunExecute')"
327
331
  data-testid="pr-review-fix"
328
332
  @click="onResolve('fix')"
329
333
  >
@@ -25,6 +25,7 @@ const requirements = useRequirementsStore()
25
25
  const models = useModelsStore()
26
26
  const toast = useToast()
27
27
  const { t } = useI18n()
28
+ const access = useWorkspaceAccess()
28
29
 
29
30
  // Draft replies, keyed by item id, so editing one item doesn't disturb others.
30
31
  const drafts = ref<Record<string, string>>({})
@@ -837,7 +838,12 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
837
838
  variant="soft"
838
839
  size="xs"
839
840
  icon="i-lucide-check"
840
- :disabled="frozen"
841
+ :disabled="frozen || !access.canExecuteRuns.value"
842
+ :title="
843
+ access.canExecuteRuns.value
844
+ ? undefined
845
+ : t('access.noRunExecute')
846
+ "
841
847
  @click="acceptRecommendation(rec)"
842
848
  >
843
849
  {{ t('requirements.accept') }}
@@ -847,7 +853,12 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
847
853
  variant="ghost"
848
854
  size="xs"
849
855
  icon="i-lucide-x"
850
- :disabled="frozen"
856
+ :disabled="frozen || !access.canExecuteRuns.value"
857
+ :title="
858
+ access.canExecuteRuns.value
859
+ ? undefined
860
+ : t('access.noRunExecute')
861
+ "
851
862
  @click="rejectRecommendation(rec)"
852
863
  >
853
864
  {{ t('requirements.reject') }}
@@ -869,7 +880,16 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
869
880
  size="xs"
870
881
  icon="i-lucide-rotate-cw"
871
882
  :loading="recommending"
872
- :disabled="!(reRequestNotes[rec.id] ?? '').trim() || frozen"
883
+ :disabled="
884
+ !(reRequestNotes[rec.id] ?? '').trim() ||
885
+ frozen ||
886
+ !access.canExecuteRuns.value
887
+ "
888
+ :title="
889
+ access.canExecuteRuns.value
890
+ ? undefined
891
+ : t('access.noRunExecute')
892
+ "
873
893
  @click="reRequestRecommendation(rec)"
874
894
  >
875
895
  {{ t('requirements.reRequest') }}
@@ -1025,6 +1045,8 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
1025
1045
  block
1026
1046
  icon="i-lucide-wand-2"
1027
1047
  :loading="recommending"
1048
+ :disabled="!access.canExecuteRuns.value"
1049
+ :title="access.canExecuteRuns.value ? undefined : t('access.noRunExecute')"
1028
1050
  @click="requestRecommendations"
1029
1051
  >
1030
1052
  {{
@@ -1050,6 +1072,8 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
1050
1072
  icon="i-lucide-arrow-right"
1051
1073
  :ui="{ leadingIcon: 'rtl:-scale-x-100', trailingIcon: 'rtl:-scale-x-100' }"
1052
1074
  :loading="acting"
1075
+ :disabled="!access.canExecuteRuns.value"
1076
+ :title="access.canExecuteRuns.value ? undefined : t('access.noRunExecute')"
1053
1077
  @click="proceed"
1054
1078
  >
1055
1079
  {{ t('requirements.actions.proceedNothing') }}
@@ -1061,7 +1085,8 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
1061
1085
  block
1062
1086
  icon="i-lucide-wand-sparkles"
1063
1087
  :loading="reworking"
1064
- :disabled="!canIncorporate"
1088
+ :disabled="!canIncorporate || !access.canExecuteRuns.value"
1089
+ :title="access.canExecuteRuns.value ? undefined : t('access.noRunExecute')"
1065
1090
  @click="incorporate()"
1066
1091
  >
1067
1092
  {{ t('requirements.actions.incorporateAnswers') }}
@@ -1085,6 +1110,8 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
1085
1110
  block
1086
1111
  icon="i-lucide-sparkles"
1087
1112
  :loading="busy"
1113
+ :disabled="!access.canExecuteRuns.value"
1114
+ :title="access.canExecuteRuns.value ? undefined : t('access.noRunExecute')"
1088
1115
  @click="reReview"
1089
1116
  >
1090
1117
  {{
@@ -1123,7 +1150,8 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
1123
1150
  block
1124
1151
  icon="i-lucide-wand-sparkles"
1125
1152
  :loading="reworking"
1126
- :disabled="!redoComment.trim()"
1153
+ :disabled="!redoComment.trim() || !access.canExecuteRuns.value"
1154
+ :title="access.canExecuteRuns.value ? undefined : t('access.noRunExecute')"
1127
1155
  @click="incorporate(redoComment.trim())"
1128
1156
  >
1129
1157
  {{ t('requirements.actions.redoWithDirection') }}
@@ -30,7 +30,21 @@ function scopeLabel(scope: PublicApiScope): string {
30
30
 
31
31
  const scopeItems = computed(() => SCOPES.map((value) => ({ value, label: scopeLabel(value) })))
32
32
  const ui = useUiStore()
33
+ const auth = useAuthStore()
33
34
  const store = usePublicApiKeysStore()
35
+
36
+ /**
37
+ * The minter to attribute a key to. `null` when the key predates the audit column (or was
38
+ * minted with no session), so the row simply omits the segment. When the minter is the
39
+ * signed-in user we show a localized "you"; otherwise the raw `usr_*` id (the list has no
40
+ * user-name lookup — the audit id is the honest, non-misleading thing to show).
41
+ */
42
+ function minterLabel(key: PublicApiKey): string | null {
43
+ if (!key.createdByUserId) return null
44
+ return key.createdByUserId === auth.user?.id
45
+ ? t('settings.apiTokens.list.createdByYou')
46
+ : key.createdByUserId
47
+ }
34
48
  const toast = useToast()
35
49
  const { confirmAction, toastDone } = useConfirmAction()
36
50
 
@@ -191,6 +205,10 @@ async function revokeToken(key: PublicApiKey) {
191
205
  })
192
206
  }}</template>
193
207
  <template v-else>{{ t('settings.apiTokens.list.neverUsed') }}</template>
208
+ <template v-if="minterLabel(key)">
209
+ <span aria-hidden="true"> · </span>
210
+ {{ t('settings.apiTokens.list.createdBy', { user: minterLabel(key) }) }}
211
+ </template>
194
212
  </div>
195
213
  </div>
196
214
  <UButton