@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
@@ -15,6 +15,7 @@ const props = withDefaults(
15
15
 
16
16
  const { t } = useI18n()
17
17
  const agentRuns = useAgentRunsStore()
18
+ const access = useWorkspaceAccess()
18
19
  const ui = useUiStore()
19
20
  const auth = useAuthStore()
20
21
  const board = useBoardStore()
@@ -171,7 +172,8 @@ async function retry() {
171
172
  type="button"
172
173
  class="nodrag flex items-center gap-1 rounded-md bg-rose-900/40 text-rose-200 hover:bg-rose-900/70 disabled:opacity-60"
173
174
  :class="compact ? 'px-2 py-0.5 text-[10px]' : 'px-2 py-1 text-[11px]'"
174
- :disabled="retrying"
175
+ :disabled="retrying || !access.canExecuteRuns.value"
176
+ :title="access.canExecuteRuns.value ? undefined : t('access.noRunExecute')"
175
177
  data-testid="agent-failure-retry"
176
178
  @click.stop="retry"
177
179
  >
@@ -21,6 +21,7 @@ const props = withDefaults(
21
21
 
22
22
  const { t } = useI18n()
23
23
  const agentRuns = useAgentRunsStore()
24
+ const access = useWorkspaceAccess()
24
25
  const toast = useToast()
25
26
  const { confirm } = useConfirm()
26
27
  const stopping = ref(false)
@@ -68,6 +69,8 @@ async function stop() {
68
69
  :size="size"
69
70
  icon="i-lucide-circle-stop"
70
71
  :loading="stopping"
72
+ :disabled="!access.canExecuteRuns.value"
73
+ :title="access.canExecuteRuns.value ? undefined : t('access.noRunExecute')"
71
74
  @click.stop="stop"
72
75
  >
73
76
  {{ displayLabel }}
@@ -20,6 +20,7 @@ const execution = useExecutionStore()
20
20
  const ui = useUiStore()
21
21
  const github = useGitHubStore()
22
22
  const toast = useToast()
23
+ const access = useWorkspaceAccess()
23
24
  const { t } = useI18n()
24
25
 
25
26
  const { onNodeDragStop, onViewportChange, screenToFlowCoordinate } = useVueFlow(BOARD_FLOW_ID)
@@ -240,12 +241,22 @@ async function onDrop(event: DragEvent) {
240
241
  {{ t('board.canvas.emptyBody') }}
241
242
  </p>
242
243
  </div>
243
- <div class="pointer-events-auto flex flex-wrap items-center justify-center gap-2">
244
- <UButton color="primary" icon="i-lucide-git-branch-plus" @click="ui.openBootstrap()">
244
+ <div
245
+ v-if="
246
+ access.canManageIntegrations.value || (github.available && access.canWriteBoard.value)
247
+ "
248
+ class="pointer-events-auto flex flex-wrap items-center justify-center gap-2"
249
+ >
250
+ <UButton
251
+ v-if="access.canManageIntegrations.value"
252
+ color="primary"
253
+ icon="i-lucide-git-branch-plus"
254
+ @click="ui.openBootstrap()"
255
+ >
245
256
  {{ t('nav.bootstrapRepo') }}
246
257
  </UButton>
247
258
  <UButton
248
- v-if="github.available"
259
+ v-if="github.available && access.canWriteBoard.value"
249
260
  color="primary"
250
261
  variant="soft"
251
262
  icon="i-lucide-folder-git-2"
@@ -254,6 +265,8 @@ async function onDrop(event: DragEvent) {
254
265
  {{ t('nav.addFromRepo') }}
255
266
  </UButton>
256
267
  </div>
268
+ <!-- A read-only viewer sees the empty state but no create affordances. -->
269
+ <p v-else class="max-w-sm text-xs text-slate-600">{{ t('access.noBoardWrite') }}</p>
257
270
  </div>
258
271
 
259
272
  <!-- task dependency arrows, overlaid in screen space -->
@@ -16,6 +16,7 @@ const recurring = useRecurringPipelinesStore()
16
16
  const tracker = useTrackerStore()
17
17
  const tasks = useTasksStore()
18
18
  const toast = useToast()
19
+ const access = useWorkspaceAccess()
19
20
  const { t } = useI18n()
20
21
 
21
22
  const open = computed({
@@ -200,7 +201,12 @@ function buildIssueIntake(): IssueIntakeConfig {
200
201
  }
201
202
 
202
203
  const canAdd = computed(
203
- () => name.value.trim().length > 0 && pipelineId.value.length > 0 && intakeReady.value,
204
+ () =>
205
+ name.value.trim().length > 0 &&
206
+ pipelineId.value.length > 0 &&
207
+ intakeReady.value &&
208
+ // Scheduling a recurring pipeline is a `runs.execute` action.
209
+ access.canExecuteRuns.value,
204
210
  )
205
211
 
206
212
  async function add() {
@@ -23,6 +23,7 @@ const tasks = useTasksStore()
23
23
  const agentRuns = useAgentRunsStore()
24
24
  const services = useServicesStore()
25
25
  const reviews = useReviewStage()
26
+ const access = useWorkspaceAccess()
26
27
  const { t } = useI18n()
27
28
  const { lod } = useSemanticZoom()
28
29
  // Coarse-pointer (touch) bumps the frame-header actions from `xs` to `sm` so
@@ -466,45 +467,50 @@ const ITEM_ICON: Record<string, string> = {
466
467
  <UBadge :color="statusMeta.chip as any" variant="subtle" size="sm">{{
467
468
  statusLabel
468
469
  }}</UBadge>
469
- <UButton
470
- class="nodrag"
471
- data-testid="frame-add-task"
472
- :size="isTouch ? 'sm' : 'xs'"
473
- variant="ghost"
474
- color="neutral"
475
- icon="i-lucide-plus"
476
- :title="t('board.frame.addTaskTitle')"
477
- @click.stop="addTask"
478
- />
479
- <UButton
480
- v-if="tasks.anyOffered"
481
- class="nodrag"
482
- :size="isTouch ? 'sm' : 'xs'"
483
- variant="ghost"
484
- color="neutral"
485
- icon="i-lucide-ticket"
486
- :title="t('board.frame.createTaskFromIssueTitle')"
487
- @click.stop="createTaskFromIssue"
488
- />
489
- <UButton
490
- class="nodrag"
491
- :size="isTouch ? 'sm' : 'xs'"
492
- variant="ghost"
493
- color="neutral"
494
- icon="i-lucide-repeat"
495
- :title="t('board.frame.addRecurringTitle')"
496
- @click.stop="addRecurring"
497
- />
498
- <UButton
499
- class="nodrag"
500
- data-testid="frame-add-initiative"
501
- :size="isTouch ? 'sm' : 'xs'"
502
- variant="ghost"
503
- color="neutral"
504
- icon="i-lucide-milestone"
505
- :title="t('board.frame.createInitiativeTitle')"
506
- @click.stop="createInitiative"
507
- />
470
+ <!-- Board-authoring buttons (create task / from issue / recurring / initiative)
471
+ are `board.write` — hidden for a read-only viewer, who keeps the badge +
472
+ collapse toggle (view-only affordances). -->
473
+ <template v-if="access.canWriteBoard.value">
474
+ <UButton
475
+ class="nodrag"
476
+ data-testid="frame-add-task"
477
+ :size="isTouch ? 'sm' : 'xs'"
478
+ variant="ghost"
479
+ color="neutral"
480
+ icon="i-lucide-plus"
481
+ :title="t('board.frame.addTaskTitle')"
482
+ @click.stop="addTask"
483
+ />
484
+ <UButton
485
+ v-if="tasks.anyOffered"
486
+ class="nodrag"
487
+ :size="isTouch ? 'sm' : 'xs'"
488
+ variant="ghost"
489
+ color="neutral"
490
+ icon="i-lucide-ticket"
491
+ :title="t('board.frame.createTaskFromIssueTitle')"
492
+ @click.stop="createTaskFromIssue"
493
+ />
494
+ <UButton
495
+ class="nodrag"
496
+ :size="isTouch ? 'sm' : 'xs'"
497
+ variant="ghost"
498
+ color="neutral"
499
+ icon="i-lucide-repeat"
500
+ :title="t('board.frame.addRecurringTitle')"
501
+ @click.stop="addRecurring"
502
+ />
503
+ <UButton
504
+ class="nodrag"
505
+ data-testid="frame-add-initiative"
506
+ :size="isTouch ? 'sm' : 'xs'"
507
+ variant="ghost"
508
+ color="neutral"
509
+ icon="i-lucide-milestone"
510
+ :title="t('board.frame.createInitiativeTitle')"
511
+ @click.stop="createInitiative"
512
+ />
513
+ </template>
508
514
  <UButton
509
515
  class="nodrag"
510
516
  :size="isTouch ? 'sm' : 'xs'"
@@ -540,9 +546,9 @@ const ITEM_ICON: Record<string, string> = {
540
546
  <InitiativeCard v-for="i in initiativeBlocks" :key="i.id" :block-id="i.id" />
541
547
  <DraggableTask v-for="t in directTasks" :key="t.id" :task-id="t.id" />
542
548
  <button
543
- v-if="!hasTasks"
549
+ v-if="!hasTasks && access.canWriteBoard.value"
544
550
  type="button"
545
- data-testid="frame-add-task"
551
+ data-testid="frame-add-task-empty"
546
552
  class="absolute inset-4 flex items-center justify-center gap-1 rounded-lg border border-dashed border-slate-700 text-[11px] text-slate-500 hover:border-slate-500 hover:text-slate-300"
547
553
  @click.stop="addTask"
548
554
  >
@@ -25,6 +25,7 @@ const brainstorm = useBrainstormStore()
25
25
  const ui = useUiStore()
26
26
  const toast = useToast()
27
27
  const { t } = useI18n()
28
+ const access = useWorkspaceAccess()
28
29
 
29
30
  const drafts = ref<Record<string, string>>({})
30
31
  const redoComment = ref('')
@@ -427,7 +428,14 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
427
428
  variant="soft"
428
429
  size="xs"
429
430
  icon="i-lucide-corner-down-left"
430
- :disabled="!(drafts[item.id] ?? '').trim() || frozen"
431
+ :disabled="
432
+ !(drafts[item.id] ?? '').trim() ||
433
+ frozen ||
434
+ !access.canExecuteRuns.value
435
+ "
436
+ :title="
437
+ access.canExecuteRuns.value ? undefined : t('access.noRunExecute')
438
+ "
431
439
  @click="submitReply(item)"
432
440
  >
433
441
  {{ t('brainstorm.saveChoice') }}
@@ -437,7 +445,10 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
437
445
  variant="ghost"
438
446
  size="xs"
439
447
  icon="i-lucide-x"
440
- :disabled="frozen"
448
+ :disabled="frozen || !access.canExecuteRuns.value"
449
+ :title="
450
+ access.canExecuteRuns.value ? undefined : t('access.noRunExecute')
451
+ "
441
452
  @click="setStatus(item, 'dismissed')"
442
453
  >
443
454
  {{ t('brainstorm.dismiss') }}
@@ -452,7 +463,10 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
452
463
  variant="ghost"
453
464
  size="xs"
454
465
  icon="i-lucide-rotate-ccw"
455
- :disabled="frozen"
466
+ :disabled="frozen || !access.canExecuteRuns.value"
467
+ :title="
468
+ access.canExecuteRuns.value ? undefined : t('access.noRunExecute')
469
+ "
456
470
  @click="setStatus(item, 'open')"
457
471
  >
458
472
  {{ t('brainstorm.reopen') }}
@@ -533,6 +547,8 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
533
547
  icon="i-lucide-arrow-right"
534
548
  :ui="{ leadingIcon: 'rtl:-scale-x-100', trailingIcon: 'rtl:-scale-x-100' }"
535
549
  :loading="acting"
550
+ :disabled="!access.canExecuteRuns.value"
551
+ :title="access.canExecuteRuns.value ? undefined : t('access.noRunExecute')"
536
552
  @click="proceed"
537
553
  >
538
554
  {{ t('brainstorm.proceedNothing') }}
@@ -544,7 +560,8 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
544
560
  block
545
561
  icon="i-lucide-wand-sparkles"
546
562
  :loading="reworking"
547
- :disabled="!canIncorporate"
563
+ :disabled="!canIncorporate || !access.canExecuteRuns.value"
564
+ :title="access.canExecuteRuns.value ? undefined : t('access.noRunExecute')"
548
565
  @click="incorporate()"
549
566
  >
550
567
  {{ t('brainstorm.incorporateChoices') }}
@@ -568,6 +585,8 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
568
585
  block
569
586
  icon="i-lucide-sparkles"
570
587
  :loading="busy"
588
+ :disabled="!access.canExecuteRuns.value"
589
+ :title="access.canExecuteRuns.value ? undefined : t('access.noRunExecute')"
571
590
  @click="reReview"
572
591
  >
573
592
  {{ busy ? t('brainstorm.reRunning') : t('brainstorm.reRun') }}
@@ -602,7 +621,8 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
602
621
  block
603
622
  icon="i-lucide-wand-sparkles"
604
623
  :loading="reworking"
605
- :disabled="!redoComment.trim()"
624
+ :disabled="!redoComment.trim() || !access.canExecuteRuns.value"
625
+ :title="access.canExecuteRuns.value ? undefined : t('access.noRunExecute')"
606
626
  @click="incorporate(redoComment.trim())"
607
627
  >
608
628
  {{ t('brainstorm.redoWithDirection') }}
@@ -25,6 +25,7 @@ const clarity = useClarityStore()
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>>({})
@@ -474,7 +475,10 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
474
475
  variant="ghost"
475
476
  size="xs"
476
477
  icon="i-lucide-x"
477
- :disabled="frozen"
478
+ :disabled="frozen || !access.canExecuteRuns.value"
479
+ :title="
480
+ access.canExecuteRuns.value ? undefined : t('access.noRunExecute')
481
+ "
478
482
  @click="setStatus(item, 'dismissed')"
479
483
  >
480
484
  {{ t('clarity.dismissIrrelevant') }}
@@ -489,7 +493,10 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
489
493
  variant="ghost"
490
494
  size="xs"
491
495
  icon="i-lucide-rotate-ccw"
492
- :disabled="frozen"
496
+ :disabled="frozen || !access.canExecuteRuns.value"
497
+ :title="
498
+ access.canExecuteRuns.value ? undefined : t('access.noRunExecute')
499
+ "
493
500
  @click="setStatus(item, 'open')"
494
501
  >
495
502
  {{ t('clarity.reopen') }}
@@ -577,6 +584,8 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
577
584
  icon="i-lucide-arrow-right"
578
585
  :ui="{ leadingIcon: 'rtl:-scale-x-100', trailingIcon: 'rtl:-scale-x-100' }"
579
586
  :loading="acting"
587
+ :disabled="!access.canExecuteRuns.value"
588
+ :title="access.canExecuteRuns.value ? undefined : t('access.noRunExecute')"
580
589
  @click="proceed"
581
590
  >
582
591
  {{ t('clarity.proceedNothing') }}
@@ -588,7 +597,8 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
588
597
  block
589
598
  icon="i-lucide-wand-sparkles"
590
599
  :loading="reworking"
591
- :disabled="!canIncorporate"
600
+ :disabled="!canIncorporate || !access.canExecuteRuns.value"
601
+ :title="access.canExecuteRuns.value ? undefined : t('access.noRunExecute')"
592
602
  @click="incorporate()"
593
603
  >
594
604
  {{ t('clarity.incorporateAnswers') }}
@@ -612,6 +622,8 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
612
622
  block
613
623
  icon="i-lucide-sparkles"
614
624
  :loading="busy"
625
+ :disabled="!access.canExecuteRuns.value"
626
+ :title="access.canExecuteRuns.value ? undefined : t('access.noRunExecute')"
615
627
  @click="reReview"
616
628
  >
617
629
  {{ busy ? t('clarity.reReviewing') : t('clarity.looksGoodReReview') }}
@@ -646,7 +658,8 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
646
658
  block
647
659
  icon="i-lucide-wand-sparkles"
648
660
  :loading="reworking"
649
- :disabled="!redoComment.trim()"
661
+ :disabled="!redoComment.trim() || !access.canExecuteRuns.value"
662
+ :title="access.canExecuteRuns.value ? undefined : t('access.noRunExecute')"
650
663
  @click="incorporate(redoComment.trim())"
651
664
  >
652
665
  {{ t('clarity.redoWithDirection') }}
@@ -12,6 +12,7 @@ import { computed, reactive, watch } from 'vue'
12
12
  const board = useBoardStore()
13
13
  const docInterview = useDocInterviewStore()
14
14
  const { t } = useI18n()
15
+ const access = useWorkspaceAccess()
15
16
 
16
17
  const { open, blockId, close } = useResultView('doc-interview', {
17
18
  onOpen: (id) => void docInterview.load(id),
@@ -186,6 +187,8 @@ const onProceed = () => flushThen((id) => docInterview.proceedInterview(id))
186
187
  variant="ghost"
187
188
  size="sm"
188
189
  :loading="resuming"
190
+ :disabled="!access.canExecuteRuns.value"
191
+ :title="access.canExecuteRuns.value ? undefined : t('access.noRunExecute')"
189
192
  data-testid="doc-interview-proceed"
190
193
  @click="onProceed"
191
194
  >
@@ -195,7 +198,8 @@ const onProceed = () => flushThen((id) => docInterview.proceedInterview(id))
195
198
  color="primary"
196
199
  size="sm"
197
200
  :loading="resuming"
198
- :disabled="!allAnswered"
201
+ :disabled="!allAnswered || !access.canExecuteRuns.value"
202
+ :title="access.canExecuteRuns.value ? undefined : t('access.noRunExecute')"
199
203
  data-testid="doc-interview-continue"
200
204
  @click="onContinue"
201
205
  >
@@ -16,6 +16,7 @@ import { FOLLOW_UP_COMPANION_META } from '~/utils/catalog'
16
16
  const execution = useExecutionStore()
17
17
  const board = useBoardStore()
18
18
  const followUps = useFollowUpsStore()
19
+ const access = useWorkspaceAccess()
19
20
 
20
21
  const { t } = useI18n()
21
22
 
@@ -207,7 +208,12 @@ const STATUS_META: Record<
207
208
  size="xs"
208
209
  color="primary"
209
210
  :loading="followUps.isActing(item.id)"
210
- :disabled="!(drafts[item.id] ?? '').trim()"
211
+ :disabled="
212
+ !(drafts[item.id] ?? '').trim() || !access.canExecuteRuns.value
213
+ "
214
+ :title="
215
+ access.canExecuteRuns.value ? undefined : t('access.noRunExecute')
216
+ "
211
217
  @click="onAnswer(item)"
212
218
  >
213
219
  {{ t('followUp.actions.answerAndSend') }}
@@ -217,6 +223,10 @@ const STATUS_META: Record<
217
223
  color="neutral"
218
224
  variant="ghost"
219
225
  :loading="followUps.isActing(item.id)"
226
+ :disabled="!access.canExecuteRuns.value"
227
+ :title="
228
+ access.canExecuteRuns.value ? undefined : t('access.noRunExecute')
229
+ "
220
230
  @click="onDismiss(item)"
221
231
  >
222
232
  {{ t('followUp.actions.dismiss') }}
@@ -231,6 +241,8 @@ const STATUS_META: Record<
231
241
  color="primary"
232
242
  icon="i-lucide-ticket"
233
243
  :loading="followUps.isActing(item.id)"
244
+ :disabled="!access.canExecuteRuns.value"
245
+ :title="access.canExecuteRuns.value ? undefined : t('access.noRunExecute')"
234
246
  @click="onFile(item)"
235
247
  >
236
248
  {{ t('followUp.actions.fileAsIssue') }}
@@ -241,6 +253,8 @@ const STATUS_META: Record<
241
253
  variant="soft"
242
254
  icon="i-lucide-corner-up-left"
243
255
  :loading="followUps.isActing(item.id)"
256
+ :disabled="!access.canExecuteRuns.value"
257
+ :title="access.canExecuteRuns.value ? undefined : t('access.noRunExecute')"
244
258
  @click="onQueue(item)"
245
259
  >
246
260
  {{ t('followUp.actions.sendToCoder') }}
@@ -18,6 +18,7 @@ import { FORK_DECISION_META } from '~/utils/catalog'
18
18
  const execution = useExecutionStore()
19
19
  const board = useBoardStore()
20
20
  const forkDecision = useForkDecisionStore()
21
+ const access = useWorkspaceAccess()
21
22
 
22
23
  const { t } = useI18n()
23
24
 
@@ -348,7 +349,10 @@ async function onSend() {
348
349
  size="sm"
349
350
  icon="i-lucide-send"
350
351
  :loading="forkDecision.chatting"
351
- :disabled="!canChat || chatInput.trim().length === 0"
352
+ :disabled="
353
+ !canChat || chatInput.trim().length === 0 || !access.canExecuteRuns.value
354
+ "
355
+ :title="access.canExecuteRuns.value ? undefined : t('access.noRunExecute')"
352
356
  @click="onSend"
353
357
  >
354
358
  {{ t('forkDecision.chat.send') }}
@@ -380,7 +384,8 @@ async function onSend() {
380
384
  size="sm"
381
385
  icon="i-lucide-check"
382
386
  :loading="forkDecision.choosing"
383
- :disabled="!canChoose"
387
+ :disabled="!canChoose || !access.canExecuteRuns.value"
388
+ :title="access.canExecuteRuns.value ? undefined : t('access.noRunExecute')"
384
389
  @click="onChoose"
385
390
  >
386
391
  {{ t('forkDecision.choose') }}
@@ -17,6 +17,7 @@ import CopyButton from '~/components/common/CopyButton.vue'
17
17
  const board = useBoardStore()
18
18
  const execution = useExecutionStore()
19
19
  const { t } = useI18n()
20
+ const access = useWorkspaceAccess()
20
21
 
21
22
  // Synchronous window: it reads its state straight off the execution step, so there's
22
23
  // nothing to fetch on open (no `onOpen` loader).
@@ -305,7 +306,12 @@ const conflictVerdict = computed(() => {
305
306
  color="primary"
306
307
  icon="i-lucide-wrench"
307
308
  :loading="fixBusy"
308
- :disabled="fixBusy || fixInstructions.trim().length === 0"
309
+ :disabled="
310
+ fixBusy ||
311
+ fixInstructions.trim().length === 0 ||
312
+ !access.canExecuteRuns.value
313
+ "
314
+ :title="access.canExecuteRuns.value ? undefined : t('access.noRunExecute')"
309
315
  @click="submitFix"
310
316
  >
311
317
  {{ t('gates.humanReview.requestFix') }}
@@ -20,6 +20,7 @@ const execution = useExecutionStore()
20
20
  const humanTest = useHumanTestStore()
21
21
  const { t, d } = useI18n()
22
22
  const toast = useToast()
23
+ const access = useWorkspaceAccess()
23
24
  const { confirmAction, toastDone } = useConfirmAction()
24
25
 
25
26
  // Shared seam contract (open/blockId/close + Escape). No `onOpen` loader: the gate state
@@ -230,7 +231,8 @@ const canDestroy = computed(
230
231
  color="neutral"
231
232
  icon="i-lucide-refresh-cw"
232
233
  :loading="busy"
233
- :disabled="busy || !canManageEnv"
234
+ :disabled="busy || !canManageEnv || !access.canExecuteRuns.value"
235
+ :title="access.canExecuteRuns.value ? undefined : t('access.noRunExecute')"
234
236
  @click="recreate"
235
237
  >
236
238
  {{ t('humanTest.actions.recreate') }}
@@ -240,7 +242,8 @@ const canDestroy = computed(
240
242
  variant="soft"
241
243
  color="neutral"
242
244
  icon="i-lucide-trash-2"
243
- :disabled="busy || !canDestroy"
245
+ :disabled="busy || !canDestroy || !access.canExecuteRuns.value"
246
+ :title="access.canExecuteRuns.value ? undefined : t('access.noRunExecute')"
244
247
  @click="destroy"
245
248
  >
246
249
  {{ t('humanTest.actions.destroy') }}
@@ -251,7 +254,8 @@ const canDestroy = computed(
251
254
  color="neutral"
252
255
  icon="i-lucide-git-merge"
253
256
  :loading="busy"
254
- :disabled="busy || !canManageEnv"
257
+ :disabled="busy || !canManageEnv || !access.canExecuteRuns.value"
258
+ :title="access.canExecuteRuns.value ? undefined : t('access.noRunExecute')"
255
259
  @click="pullMain"
256
260
  >
257
261
  {{ t('humanTest.actions.pullMain') }}
@@ -296,7 +300,8 @@ const canDestroy = computed(
296
300
  color="warning"
297
301
  icon="i-lucide-wrench"
298
302
  :loading="busy"
299
- :disabled="busy || !findings.trim()"
303
+ :disabled="busy || !findings.trim() || !access.canExecuteRuns.value"
304
+ :title="access.canExecuteRuns.value ? undefined : t('access.noRunExecute')"
300
305
  @click="submitFix"
301
306
  >
302
307
  {{ t('humanTest.fix.send') }}
@@ -366,7 +371,8 @@ const canDestroy = computed(
366
371
  color="primary"
367
372
  icon="i-lucide-circle-check"
368
373
  :loading="busy"
369
- :disabled="busy || !awaitingHuman"
374
+ :disabled="busy || !awaitingHuman || !access.canExecuteRuns.value"
375
+ :title="access.canExecuteRuns.value ? undefined : t('access.noRunExecute')"
370
376
  @click="confirm"
371
377
  >
372
378
  {{ t('humanTest.confirm') }}
@@ -12,6 +12,7 @@ const accounts = useAccountsStore()
12
12
  const workspace = useWorkspaceStore()
13
13
  const ui = useUiStore()
14
14
  const toast = useToast()
15
+ const access = useWorkspaceAccess()
15
16
  const { confirm } = useConfirm()
16
17
 
17
18
  const busy = ref(false)
@@ -92,7 +93,9 @@ const accountItems = computed<DropdownMenuItem[][]>(() => [
92
93
 
93
94
  const boardItems = computed<DropdownMenuItem[][]>(() => [
94
95
  workspace.accountWorkspaces.map((w) => ({
95
- label: w.name,
96
+ // Badge a board the caller only reaches as a read-only viewer (a restricted board
97
+ // they're not a member/admin of), so the switcher shows why it's read-only.
98
+ label: w.viewerRole === 'viewer' ? `${w.name} · ${t('access.viewerBadge')}` : w.name,
96
99
  icon: 'i-lucide-layout-dashboard',
97
100
  trailingIcon: w.id === workspace.workspaceId ? 'i-lucide-check' : undefined,
98
101
  onSelect: () => void switchBoard(w.id),
@@ -103,17 +106,23 @@ const boardItems = computed<DropdownMenuItem[][]>(() => [
103
106
  icon: 'i-lucide-plus',
104
107
  onSelect: () => openPrompt('board'),
105
108
  },
106
- {
107
- label: t('layout.boardSwitcher.board.rename'),
108
- icon: 'i-lucide-pencil',
109
- onSelect: () => openPrompt('rename'),
110
- },
111
- {
112
- label: t('layout.boardSwitcher.board.delete'),
113
- icon: 'i-lucide-trash-2',
114
- color: 'error' as const,
115
- onSelect: () => void removeBoard(),
116
- },
109
+ // Rename/delete of the active board is `settings.manage` — omit for a member/viewer,
110
+ // who would only get a 403 (the backend gates WorkspaceController update/delete).
111
+ ...(access.canManageSettings.value
112
+ ? [
113
+ {
114
+ label: t('layout.boardSwitcher.board.rename'),
115
+ icon: 'i-lucide-pencil',
116
+ onSelect: () => openPrompt('rename'),
117
+ },
118
+ {
119
+ label: t('layout.boardSwitcher.board.delete'),
120
+ icon: 'i-lucide-trash-2',
121
+ color: 'error' as const,
122
+ onSelect: () => void removeBoard(),
123
+ },
124
+ ]
125
+ : []),
117
126
  ],
118
127
  ])
119
128
 
@@ -10,6 +10,7 @@ const workspace = useWorkspaceStore()
10
10
  const workspaceSettings = useWorkspaceSettingsStore()
11
11
  const services = useServicesStore()
12
12
  const toast = useToast()
13
+ const access = useWorkspaceAccess()
13
14
  const { t, n } = useI18n()
14
15
  const { fitView, zoomIn, zoomOut, resetZoom } = useBoardFlow()
15
16
 
@@ -207,15 +208,18 @@ const decisionItems = computed(() =>
207
208
  </UButton>
208
209
  </UDropdownMenu>
209
210
 
210
- <!-- in-org sharing: add an existing org service to this board -->
211
- <UDropdownMenu v-if="mountableItems.length" :items="mountableItems">
211
+ <!-- in-org sharing: add an existing org service to this board (mount = board.write) -->
212
+ <UDropdownMenu
213
+ v-if="mountableItems.length && access.canWriteBoard.value"
214
+ :items="mountableItems"
215
+ >
212
216
  <UButton color="neutral" variant="ghost" size="sm" icon="i-lucide-plus-circle">
213
217
  <span class="hidden sm:inline">{{ t('board.toolbar.addService') }}</span>
214
218
  </UButton>
215
219
  </UDropdownMenu>
216
220
 
217
- <!-- archived services: hidden from the board, restorable with no expiry -->
218
- <UDropdownMenu v-if="archivedItems.length" :items="archivedItems">
221
+ <!-- archived services: hidden from the board, restorable with no expiry (board.write) -->
222
+ <UDropdownMenu v-if="archivedItems.length && access.canWriteBoard.value" :items="archivedItems">
219
223
  <UButton
220
224
  color="neutral"
221
225
  variant="ghost"