@cat-factory/app 0.129.0 → 0.131.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 (47) 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 +24 -13
  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/layout/WorkspaceMembersSettings.vue +256 -0
  20. package/app/components/panels/InspectorPanel.vue +23 -10
  21. package/app/components/panels/inspector/TaskExecution.vue +13 -4
  22. package/app/components/prReview/PrReviewWindow.vue +7 -3
  23. package/app/components/requirements/RequirementsReviewWindow.vue +33 -5
  24. package/app/components/settings/WorkspaceSettingsPanel.vue +20 -0
  25. package/app/components/visualConfirm/VisualConfirmationWindow.vue +7 -3
  26. package/app/composables/api/workspaces.ts +31 -1
  27. package/app/composables/useBlockDeletion.ts +7 -0
  28. package/app/composables/useBlockDrag.ts +5 -0
  29. package/app/composables/useFrameResize.ts +4 -0
  30. package/app/composables/useWorkspaceAccess.spec.ts +102 -0
  31. package/app/composables/useWorkspaceAccess.ts +84 -0
  32. package/app/stores/workspace.spec.ts +19 -0
  33. package/app/stores/workspace.ts +27 -7
  34. package/app/stores/workspaceMembers.spec.ts +121 -0
  35. package/app/stores/workspaceMembers.ts +74 -0
  36. package/app/types/domain.ts +6 -0
  37. package/i18n/locales/de.json +41 -1
  38. package/i18n/locales/en.json +44 -1
  39. package/i18n/locales/es.json +41 -1
  40. package/i18n/locales/fr.json +41 -1
  41. package/i18n/locales/he.json +41 -1
  42. package/i18n/locales/it.json +41 -1
  43. package/i18n/locales/ja.json +41 -1
  44. package/i18n/locales/pl.json +41 -1
  45. package/i18n/locales/tr.json +41 -1
  46. package/i18n/locales/uk.json +41 -1
  47. package/package.json +1 -1
@@ -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') }}
@@ -14,11 +14,14 @@ import IssueTrackerPanel from '~/components/settings/IssueTrackerPanel.vue'
14
14
  import ServiceFragmentDefaultsPanel from '~/components/settings/ServiceFragmentDefaultsPanel.vue'
15
15
  import BudgetSettings from '~/components/settings/BudgetSettings.vue'
16
16
  import UsageSettings from '~/components/settings/UsageSettings.vue'
17
+ import WorkspaceMembersSettings from '~/components/layout/WorkspaceMembersSettings.vue'
17
18
  import IntegrationBackTitle from '~/components/layout/IntegrationBackTitle.vue'
18
19
 
19
20
  const { t, te } = useI18n()
20
21
  const ui = useUiStore()
21
22
  const store = useWorkspaceSettingsStore()
23
+ const workspace = useWorkspaceStore()
24
+ const access = useWorkspaceAccess()
22
25
  const toast = useToast()
23
26
 
24
27
  const open = computed({
@@ -71,6 +74,18 @@ const tabs = computed(() => [
71
74
  icon: 'i-lucide-book-open-check',
72
75
  slot: 'fragments',
73
76
  },
77
+ // Roster + access-mode management is `members.manage` (workspace admins only). Hidden
78
+ // for everyone else — the backend 403s the writes and the tab has nothing to read.
79
+ ...(access.canManageMembers.value
80
+ ? [
81
+ {
82
+ value: 'members',
83
+ label: t('settings.workspaceSettings.tabs.members'),
84
+ icon: 'i-lucide-users',
85
+ slot: 'members',
86
+ },
87
+ ]
88
+ : []),
74
89
  ])
75
90
 
76
91
  // Tab strip styling: the labels must always fit (never truncate) and the strip must never
@@ -359,6 +374,11 @@ async function save() {
359
374
  <template #fragments>
360
375
  <ServiceFragmentDefaultsPanel />
361
376
  </template>
377
+
378
+ <!-- Members (workspace RBAC roster + access mode; admins only) -->
379
+ <template v-if="access.canManageMembers.value && workspace.workspaceId" #members>
380
+ <WorkspaceMembersSettings :workspace-id="workspace.workspaceId" />
381
+ </template>
362
382
  </UTabs>
363
383
  </template>
364
384
  </UModal>
@@ -19,6 +19,7 @@ const board = useBoardStore()
19
19
  const execution = useExecutionStore()
20
20
  const visualConfirm = useVisualConfirmStore()
21
21
  const { t } = useI18n()
22
+ const access = useWorkspaceAccess()
22
23
 
23
24
  // Per-window blob cache; release the cached screenshot/reference object URLs when the window
24
25
  // goes away, so the (potentially large) blob bytes don't linger for the rest of the session.
@@ -351,7 +352,8 @@ const canApprove = computed(
351
352
  color="warning"
352
353
  icon="i-lucide-wrench"
353
354
  :loading="busy"
354
- :disabled="busy || !hasFindings"
355
+ :disabled="busy || !hasFindings || !access.canExecuteRuns.value"
356
+ :title="access.canExecuteRuns.value ? undefined : t('access.noRunExecute')"
355
357
  @click="submitFix"
356
358
  >
357
359
  {{ t('visualConfirm.requestFix.send') }}
@@ -430,7 +432,8 @@ const canApprove = computed(
430
432
  color="neutral"
431
433
  icon="i-lucide-refresh-cw"
432
434
  :loading="busy"
433
- :disabled="busy || !awaitingHuman"
435
+ :disabled="busy || !awaitingHuman || !access.canExecuteRuns.value"
436
+ :title="access.canExecuteRuns.value ? undefined : t('access.noRunExecute')"
434
437
  @click="recapture"
435
438
  >
436
439
  {{ t('visualConfirm.recapture') }}
@@ -439,7 +442,8 @@ const canApprove = computed(
439
442
  color="primary"
440
443
  icon="i-lucide-circle-check"
441
444
  :loading="busy"
442
- :disabled="!canApprove"
445
+ :disabled="!canApprove || !access.canExecuteRuns.value"
446
+ :title="access.canExecuteRuns.value ? undefined : t('access.noRunExecute')"
443
447
  @click="approve"
444
448
  >
445
449
  {{ t('visualConfirm.approve') }}
@@ -1,13 +1,22 @@
1
1
  import {
2
+ addWorkspaceMemberContract,
2
3
  createWorkspaceContract,
3
4
  deleteWorkspaceContract,
4
5
  getWorkspaceContract,
5
6
  getWorkspaceSettingsContract,
7
+ listWorkspaceMembersContract,
6
8
  listWorkspacesContract,
9
+ removeWorkspaceMemberContract,
10
+ setWorkspaceAccessModeContract,
11
+ setWorkspaceMemberRoleContract,
7
12
  updateWorkspaceContract,
8
13
  updateWorkspaceSettingsContract,
9
14
  } from '@cat-factory/contracts'
10
- import type { UpdateWorkspaceSettingsInput } from '~/types/domain'
15
+ import type {
16
+ UpdateWorkspaceSettingsInput,
17
+ WorkspaceAccessMode,
18
+ WorkspaceRole,
19
+ } from '~/types/domain'
11
20
  import type { ApiContext } from './context'
12
21
 
13
22
  /** Workspace CRUD + the full snapshot read. */
@@ -38,5 +47,26 @@ export function workspacesApi({ send, ws }: ApiContext) {
38
47
 
39
48
  updateWorkspaceSettings: (workspaceId: string, body: UpdateWorkspaceSettingsInput) =>
40
49
  send(updateWorkspaceSettingsContract, { pathPrefix: ws(workspaceId), body }),
50
+
51
+ // ---- workspace membership (RBAC roster + access-mode) -----------------
52
+ // The roster read is open to any resolved role; every write requires `members.manage`
53
+ // (the backend gates it — the SPA only shows this surface to workspace admins).
54
+ listWorkspaceMembers: (workspaceId: string) =>
55
+ send(listWorkspaceMembersContract, { pathParams: { workspaceId } }),
56
+
57
+ addWorkspaceMember: (workspaceId: string, userId: string, role: WorkspaceRole) =>
58
+ send(addWorkspaceMemberContract, { pathParams: { workspaceId }, body: { userId, role } }),
59
+
60
+ setWorkspaceMemberRole: (workspaceId: string, userId: string, role: WorkspaceRole) =>
61
+ send(setWorkspaceMemberRoleContract, {
62
+ pathParams: { workspaceId, userId },
63
+ body: { role },
64
+ }),
65
+
66
+ removeWorkspaceMember: (workspaceId: string, userId: string) =>
67
+ send(removeWorkspaceMemberContract, { pathParams: { workspaceId, userId } }),
68
+
69
+ setWorkspaceAccessMode: (workspaceId: string, accessMode: WorkspaceAccessMode) =>
70
+ send(setWorkspaceAccessModeContract, { pathParams: { workspaceId }, body: { accessMode } }),
41
71
  }
42
72
  }
@@ -18,6 +18,7 @@ export function useBlockDeletion() {
18
18
  const toast = useToast()
19
19
  const { confirm } = useConfirm()
20
20
  const { t } = useI18n()
21
+ const access = useWorkspaceAccess()
21
22
 
22
23
  /** A service is any top-level frame; only services are archivable. */
23
24
  function isService(block: Block): boolean {
@@ -37,6 +38,9 @@ export function useBlockDeletion() {
37
38
  */
38
39
  async function archiveBlock(block: Block | undefined | null): Promise<boolean> {
39
40
  if (!block || !isService(block)) return false
41
+ // Archiving is a `board.write` mutation — a read-only viewer's keyboard/inspector path
42
+ // no-ops (the inspector button is disabled for them; this guards the shortcut too).
43
+ if (!access.canWriteBoard.value) return false
40
44
  const ok = await confirm({
41
45
  title: t('panels.inspector.confirmArchive.title'),
42
46
  description: t('panels.inspector.confirmArchive.body', { name: block.title }),
@@ -94,6 +98,9 @@ export function useBlockDeletion() {
94
98
 
95
99
  async function deleteBlock(block: Block | undefined | null): Promise<boolean> {
96
100
  if (!block) return false
101
+ // Deletion is a `board.write` mutation — no-op for a read-only viewer (guards both the
102
+ // inspector Delete button and the global Delete-key shortcut, which share this path).
103
+ if (!access.canWriteBoard.value) return false
97
104
  // A service with unfinished work can't be deleted (the backend rejects it) — archive it
98
105
  // instead. Route straight to the archive flow so the user is never handed a dead-end error.
99
106
  if (isService(block) && unfinishedTaskCount(block) > 0) return archiveBlock(block)
@@ -18,6 +18,7 @@ const draggingId = ref<string | null>(null)
18
18
  export function useBlockDrag() {
19
19
  const board = useBoardStore()
20
20
  const ui = useUiStore()
21
+ const access = useWorkspaceAccess()
21
22
 
22
23
  function startDrag(
23
24
  block: Block,
@@ -25,6 +26,10 @@ export function useBlockDrag() {
25
26
  opts: { reparent?: boolean; clamp?: boolean } = {},
26
27
  ) {
27
28
  if (e.button !== 0) return
29
+ // Read-only viewers can pan/inspect but never move or reparent a block — the drag
30
+ // is a `board.write` mutation, so it no-ops for them (the SPA mirror of the backend
31
+ // member floor; the affordance itself is hidden/disabled at the button level too).
32
+ if (!access.canWriteBoard.value) return
28
33
  e.preventDefault()
29
34
  e.stopPropagation()
30
35
  const startX = e.clientX
@@ -12,6 +12,7 @@ import type { Block } from '~/types/domain'
12
12
  export function useFrameResize() {
13
13
  const board = useBoardStore()
14
14
  const ui = useUiStore()
15
+ const access = useWorkspaceAccess()
15
16
  /** Id of the frame currently being resized, for cursor/handle styling. */
16
17
  const resizingId = ref<string | null>(null)
17
18
 
@@ -21,6 +22,9 @@ export function useFrameResize() {
21
22
  */
22
23
  function startResize(block: Block, e: PointerEvent, edge: 'e' | 's' | 'se') {
23
24
  if (e.button !== 0) return
25
+ // Resizing a frame persists its size — a `board.write` mutation, so a read-only
26
+ // viewer's resize no-ops (the grips are hidden for them at the component level).
27
+ if (!access.canWriteBoard.value) return
24
28
  e.preventDefault()
25
29
  e.stopPropagation()
26
30
  const startX = e.clientX
@@ -0,0 +1,102 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import type { WorkspaceAccess } from '~/types/domain'
3
+ import { useWorkspaceStore } from '~/stores/workspace'
4
+ import { useWorkspaceAccess } from '~/composables/useWorkspaceAccess'
5
+
6
+ // `useWorkspaceAccess` reads the active board's resolved `{ role, permissions }` off the
7
+ // workspace store and answers "can the caller do X here". The store is real (a fresh Pinia
8
+ // per test via test/setup.ts); the composable calls it through the Nuxt auto-import, so
9
+ // expose it as a global. No API/i18n is touched by the composable.
10
+ beforeEach(() => {
11
+ vi.stubGlobal('useWorkspaceStore', useWorkspaceStore)
12
+ })
13
+
14
+ /** Set the active board's access on the store, then build the composable over it. */
15
+ function withAccess(access: WorkspaceAccess | null) {
16
+ useWorkspaceStore().access = access
17
+ return useWorkspaceAccess()
18
+ }
19
+
20
+ describe('useWorkspaceAccess', () => {
21
+ it('dev-open (no access resolved) allows everything — backend parity', () => {
22
+ const a = withAccess(null)
23
+ expect(a.role.value).toBeNull()
24
+ expect(a.permissions.value).toBeNull()
25
+ expect(a.can('board.write')).toBe(true)
26
+ expect(a.can('members.manage')).toBe(true)
27
+ expect(a.canWriteBoard.value).toBe(true)
28
+ expect(a.canExecuteRuns.value).toBe(true)
29
+ expect(a.canManageSettings.value).toBe(true)
30
+ // dev-open is NOT a viewer — it sees all, so isViewer is false and isMember/isAdmin true.
31
+ expect(a.isViewer.value).toBe(false)
32
+ expect(a.isMember.value).toBe(true)
33
+ expect(a.isAdmin.value).toBe(true)
34
+ })
35
+
36
+ it('viewer can only read', () => {
37
+ const a = withAccess({ role: 'viewer', permissions: ['workspace.read'] })
38
+ expect(a.role.value).toBe('viewer')
39
+ expect(a.can('workspace.read')).toBe(true)
40
+ expect(a.can('board.write')).toBe(false)
41
+ expect(a.can('runs.execute')).toBe(false)
42
+ expect(a.canWriteBoard.value).toBe(false)
43
+ expect(a.canExecuteRuns.value).toBe(false)
44
+ expect(a.isViewer.value).toBe(true)
45
+ expect(a.isMember.value).toBe(false)
46
+ expect(a.isAdmin.value).toBe(false)
47
+ })
48
+
49
+ it('member can write the board and execute runs but not manage', () => {
50
+ const a = withAccess({
51
+ role: 'member',
52
+ permissions: ['workspace.read', 'board.write', 'runs.execute'],
53
+ })
54
+ expect(a.canWriteBoard.value).toBe(true)
55
+ expect(a.canExecuteRuns.value).toBe(true)
56
+ expect(a.canManageSettings.value).toBe(false)
57
+ expect(a.canManageIntegrations.value).toBe(false)
58
+ expect(a.canManageSecrets.value).toBe(false)
59
+ expect(a.canManageMembers.value).toBe(false)
60
+ expect(a.isViewer.value).toBe(false)
61
+ expect(a.isMember.value).toBe(true)
62
+ expect(a.isAdmin.value).toBe(false)
63
+ })
64
+
65
+ it('admin holds every permission', () => {
66
+ const a = withAccess({
67
+ role: 'admin',
68
+ permissions: [
69
+ 'workspace.read',
70
+ 'board.write',
71
+ 'runs.execute',
72
+ 'settings.manage',
73
+ 'integrations.manage',
74
+ 'secrets.manage',
75
+ 'members.manage',
76
+ ],
77
+ })
78
+ expect(a.canManageSettings.value).toBe(true)
79
+ expect(a.canManageIntegrations.value).toBe(true)
80
+ expect(a.canManageSecrets.value).toBe(true)
81
+ expect(a.canManageMembers.value).toBe(true)
82
+ expect(a.isAdmin.value).toBe(true)
83
+ })
84
+
85
+ it('can() reads the granted permission SET, not the role name', () => {
86
+ // A future upgrade-overlay/custom grant could carry a permission beyond the role's
87
+ // default set; `can()` must honour the array the backend actually sent.
88
+ const a = withAccess({ role: 'member', permissions: ['workspace.read', 'runs.execute'] })
89
+ expect(a.can('board.write')).toBe(false)
90
+ expect(a.can('runs.execute')).toBe(true)
91
+ })
92
+
93
+ it('reacts to a live access change (board switch / snapshot refresh)', () => {
94
+ const a = withAccess({ role: 'viewer', permissions: ['workspace.read'] })
95
+ expect(a.canWriteBoard.value).toBe(false)
96
+ useWorkspaceStore().access = {
97
+ role: 'member',
98
+ permissions: ['workspace.read', 'board.write', 'runs.execute'],
99
+ }
100
+ expect(a.canWriteBoard.value).toBe(true)
101
+ })
102
+ })
@@ -0,0 +1,84 @@
1
+ import { computed } from 'vue'
2
+ import type { WorkspacePermission, WorkspaceRole } from '~/types/domain'
3
+
4
+ /**
5
+ * The signed-in caller's resolved workspace-RBAC access to the ACTIVE board — the
6
+ * central helper for gating _workspace-scoped_ affordances in the SPA (board editing,
7
+ * run controls, HITL actions, admin settings panels). It reads the `access` the auth
8
+ * gate resolved server-side and attached to the workspace snapshot (`{ role, permissions }`),
9
+ * so the frontend never re-derives the permission math — it consumes the same source of
10
+ * truth the backend enforces against.
11
+ *
12
+ * **Dev-open parity.** When auth is disabled the backend attaches no `access` (it resolves
13
+ * no access object and allows everything), so an absent snapshot access here means dev-open
14
+ * ⇒ `can()` returns `true` for every permission and `role` is `null`. This mirrors the
15
+ * backend's `requirePermission` "absent access with no user ⇒ allow" branch exactly, so the
16
+ * SPA never hides an affordance the backend would have permitted.
17
+ *
18
+ * This is deliberately distinct from the ACCOUNT-scoped admin checks
19
+ * (`accounts.activeAccount?.roles?.includes('admin')`), which stay account-scoped: this
20
+ * composable answers "what can you do inside THIS board", the account check answers "what
21
+ * can you do to the tenant".
22
+ */
23
+ export function useWorkspaceAccess() {
24
+ const workspace = useWorkspaceStore()
25
+
26
+ /** The caller's effective role on the active board, or `null` in dev-open (auth off). */
27
+ const role = computed<WorkspaceRole | null>(() => workspace.access?.role ?? null)
28
+
29
+ /**
30
+ * The permission set the backend granted the caller, or `null` in dev-open. `can()`
31
+ * short-circuits to allow-all when this is null, so a null set is never a "deny".
32
+ */
33
+ const permissions = computed<ReadonlySet<WorkspacePermission> | null>(() =>
34
+ workspace.access ? new Set(workspace.access.permissions) : null,
35
+ )
36
+
37
+ /**
38
+ * Whether the caller holds `permission` on the active board. Absent access (dev-open)
39
+ * ⇒ `true` (backend-parity). Use this — never a raw role comparison — so a future
40
+ * custom-role model or an escape-hatch grant is honoured automatically.
41
+ */
42
+ function can(permission: WorkspacePermission): boolean {
43
+ const set = permissions.value
44
+ return set === null || set.has(permission)
45
+ }
46
+
47
+ /** Board mutation (blocks CRUD/move/reparent/archive, epics, initiatives, pipelines). */
48
+ const canWriteBoard = computed(() => can('board.write'))
49
+ /** Run lifecycle + all HITL windows (start/stop/merge, retry, decision approvals). */
50
+ const canExecuteRuns = computed(() => can('runs.execute'))
51
+ /** Board configuration (workspace settings, presets, risk/merge policies, observability). */
52
+ const canManageSettings = computed(() => can('settings.manage'))
53
+ /** Integration connections (GitHub/Slack/environments/runner-pool/sources, bootstrap). */
54
+ const canManageIntegrations = computed(() => can('integrations.manage'))
55
+ /** Secrets (vendor credentials, workspace + public API keys, test secrets). */
56
+ const canManageSecrets = computed(() => can('secrets.manage'))
57
+ /** Roster + access-mode flip (workspace member CRUD). */
58
+ const canManageMembers = computed(() => can('members.manage'))
59
+
60
+ /**
61
+ * A read-only viewer (resolved a role, but not `>= member`). Distinct from dev-open:
62
+ * `isViewer` is `false` when there is no access object at all, since dev-open sees all.
63
+ */
64
+ const isViewer = computed(() => role.value === 'viewer')
65
+ /** Resolved at least the member tier (or dev-open). */
66
+ const isMember = computed(() => role.value === null || role.value !== 'viewer')
67
+ /** A workspace admin (or dev-open). */
68
+ const isAdmin = computed(() => role.value === null || role.value === 'admin')
69
+
70
+ return {
71
+ role,
72
+ permissions,
73
+ can,
74
+ canWriteBoard,
75
+ canExecuteRuns,
76
+ canManageSettings,
77
+ canManageIntegrations,
78
+ canManageSecrets,
79
+ canManageMembers,
80
+ isViewer,
81
+ isMember,
82
+ isAdmin,
83
+ }
84
+ }
@@ -216,4 +216,23 @@ describe('workspace store cold-open speculative snapshot', () => {
216
216
  expect(getWorkspace).toHaveBeenCalledWith('ws3')
217
217
  expect(useBoardStore().getBlock('f3')).toBeDefined()
218
218
  })
219
+
220
+ // Slice 8 (workspace-RBAC frontend): the resolved `access` the auth gate attaches to the
221
+ // snapshot must land on the store so `useWorkspaceAccess()` can gate affordances.
222
+ it('hydrates the resolved workspace access from the snapshot', async () => {
223
+ const snap = snapshot('ws1', [block('f1')]) as WorkspaceSnapshot
224
+ snap.access = { role: 'viewer', permissions: ['workspace.read'] }
225
+ const getWorkspace = vi.fn().mockResolvedValue(snap)
226
+ vi.stubGlobal('useApi', () => ({ getWorkspace }))
227
+
228
+ const ws = useWorkspaceStore()
229
+ await ws.switchTo('ws1')
230
+ expect(ws.access).toEqual({ role: 'viewer', permissions: ['workspace.read'] })
231
+
232
+ // A subsequent snapshot WITHOUT access (older backend / dev-open) clears it back to null.
233
+ const snap2 = snapshot('ws1', [block('f1')]) as WorkspaceSnapshot
234
+ getWorkspace.mockResolvedValue(snap2)
235
+ await ws.refresh()
236
+ expect(ws.access).toBeNull()
237
+ })
219
238
  })