@cat-factory/app 0.310.2 → 0.311.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 (63) hide show
  1. package/README.md +24 -10
  2. package/app/components/documents/DocumentSourceConnectModal.vue +1 -1
  3. package/app/components/foundational/FoundationalServiceCatalogList.vue +2 -0
  4. package/app/components/foundational/FoundationalServiceRegistry.vue +9 -2
  5. package/app/components/foundational/FoundationalServiceSources.vue +2 -0
  6. package/app/components/foundational/FoundationalSuppressions.vue +1 -0
  7. package/app/components/fragments/FragmentLibraryManager.vue +4 -0
  8. package/app/components/github/GitHubOnboarding.vue +9 -1
  9. package/app/components/github/RepoTreeBrowser.vue +3 -0
  10. package/app/components/guidedReview/GuidedReviewDraftCard.vue +16 -4
  11. package/app/components/guidedReview/GuidedReviewModal.vue +1 -0
  12. package/app/components/guidedReview/GuidedReviewOverview.vue +1 -0
  13. package/app/components/humanTest/HumanTestWindow.vue +3 -2
  14. package/app/components/initiative/InitiativePlanningWindow.vue +1 -1
  15. package/app/components/judge/JudgeResultView.vue +1 -0
  16. package/app/components/layout/AccountDeploymentSettings.vue +8 -8
  17. package/app/components/layout/AccountTeamSettings.vue +2 -2
  18. package/app/components/layout/BoardSwitcher.vue +33 -14
  19. package/app/components/layout/DefaultTestEnvBanner.vue +4 -1
  20. package/app/components/layout/InfraSetupBanner.vue +26 -2
  21. package/app/components/layout/InfraSetupNotice.vue +69 -0
  22. package/app/components/layout/ProviderConfigBanner.vue +8 -1
  23. package/app/components/panels/AgentStepDetail.vue +2 -0
  24. package/app/components/panels/InspectorPanel.vue +1 -1
  25. package/app/components/panels/inspector/ServiceReleaseHealthConfig.vue +1 -1
  26. package/app/components/panels/inspector/ServiceTestSecrets.vue +1 -1
  27. package/app/components/panels/inspector/ServiceValidationConfig.vue +1 -1
  28. package/app/components/panels/inspector/TaskExecution.vue +1 -1
  29. package/app/components/pipeline/PipelineBuilder.vue +1 -1
  30. package/app/components/pipeline/PipelineHealthModal.vue +2 -2
  31. package/app/components/providers/AiProviderOnboardingModal.vue +7 -1
  32. package/app/components/providers/ApiKeysSection.vue +21 -2
  33. package/app/components/provisioning/ProvisioningLogsDrawer.vue +1 -0
  34. package/app/components/sandbox/SandboxPanel.vue +1 -0
  35. package/app/components/settings/InfraHandlersConfigurator.vue +1 -1
  36. package/app/components/settings/ProviderConnectionTab.vue +1 -0
  37. package/app/components/settings/ToolServerChecklist.vue +1 -1
  38. package/app/components/skills/SkillLibraryManager.vue +2 -0
  39. package/app/components/slack/SlackPanel.vue +1 -1
  40. package/app/components/tasks/TaskSourceConnectModal.vue +1 -1
  41. package/app/components/testing/TestReportWindow.vue +2 -0
  42. package/app/components/vcs/VcsConnectSurfaces.vue +30 -14
  43. package/app/components/visualConfirm/VisualConfirmationWindow.vue +1 -0
  44. package/app/composables/useSetupActor.ts +25 -0
  45. package/app/composables/useWorkspaceAccess.ts +1 -1
  46. package/app/modular/nav-gates.ts +1 -2
  47. package/app/stores/accounts.ts +8 -0
  48. package/app/stores/workspace/infraSetup.ts +11 -3
  49. package/app/stores/workspace.ts +3 -1
  50. package/app/types/domain.ts +2 -0
  51. package/app/utils/setupAudience.spec.ts +115 -0
  52. package/app/utils/setupAudience.ts +102 -0
  53. package/i18n/locales/de.json +15 -1
  54. package/i18n/locales/en.json +15 -1
  55. package/i18n/locales/es.json +15 -1
  56. package/i18n/locales/fr.json +15 -1
  57. package/i18n/locales/he.json +15 -1
  58. package/i18n/locales/it.json +15 -1
  59. package/i18n/locales/ja.json +15 -1
  60. package/i18n/locales/pl.json +15 -1
  61. package/i18n/locales/tr.json +15 -1
  62. package/i18n/locales/uk.json +15 -1
  63. package/package.json +2 -2
@@ -6,11 +6,13 @@
6
6
  // link into its connect panel. Dismissible per session.
7
7
  import { computed, ref, watch } from 'vue'
8
8
  import type { ProviderConnectionKind } from '~/types/providerConnections'
9
+ import { canActOnSetup } from '~/utils/setupAudience'
9
10
 
10
11
  const { t } = useI18n()
11
12
  const ui = useUiStore()
12
13
  const workspace = useWorkspaceStore()
13
14
  const store = useProviderConnectionsStore()
15
+ const { actor } = useSetupActor()
14
16
 
15
17
  // Exhaustive per-kind title/action keys (leaf names mirror the kind verbatim).
16
18
  const TITLE_KEYS: Record<ProviderConnectionKind, string> = {
@@ -33,7 +35,12 @@ watch(
33
35
 
34
36
  const dismissed = ref(false)
35
37
  const pending = computed(() => store.needingConfig as ProviderConnectionKind[])
36
- const show = computed(() => pending.value.length > 0 && !dismissed.value)
38
+ // Only for the people whose save would land: the connection fields are a workspace-admin write,
39
+ // and a member or a designer could only open the panel and be refused.
40
+ const show = computed(
41
+ () =>
42
+ pending.value.length > 0 && !dismissed.value && canActOnSetup('workspace_admin', actor.value),
43
+ )
37
44
  </script>
38
45
 
39
46
  <template>
@@ -657,6 +657,7 @@ async function copyOutput() {
657
657
  provider error, behind a toggle (most useful on a failed-to-start run) -->
658
658
  <div v-if="executionId">
659
659
  <UButton
660
+ color="neutral"
660
661
  :icon="showProvisioning ? 'i-lucide-chevron-up' : 'i-lucide-scroll-text'"
661
662
  variant="ghost"
662
663
  size="xs"
@@ -685,6 +686,7 @@ async function copyOutput() {
685
686
  superseded and the FAILED attempts, scoped to the step being looked at -->
686
687
  <div v-if="hasStepHistory">
687
688
  <UButton
689
+ color="neutral"
688
690
  :icon="showHistory ? 'i-lucide-chevron-up' : 'i-lucide-history'"
689
691
  variant="ghost"
690
692
  size="xs"
@@ -678,7 +678,7 @@ const showOriginalDescription = ref(false)
678
678
  </UButton>
679
679
  <UButton
680
680
  color="error"
681
- variant="ghost"
681
+ variant="soft"
682
682
  size="sm"
683
683
  icon="i-lucide-trash-2"
684
684
  :class="isServiceFrame ? '' : 'ms-auto'"
@@ -101,7 +101,7 @@ async function clear() {
101
101
  <UButton
102
102
  v-if="saved"
103
103
  color="error"
104
- variant="ghost"
104
+ variant="soft"
105
105
  size="xs"
106
106
  icon="i-lucide-trash-2"
107
107
  :loading="busy"
@@ -152,7 +152,7 @@ async function clearAll() {
152
152
  <UButton
153
153
  v-if="configured.length"
154
154
  color="error"
155
- variant="ghost"
155
+ variant="soft"
156
156
  size="xs"
157
157
  icon="i-lucide-trash-2"
158
158
  :loading="busy"
@@ -209,7 +209,7 @@ async function clear() {
209
209
  <UButton
210
210
  v-if="configured"
211
211
  color="error"
212
- variant="ghost"
212
+ variant="soft"
213
213
  size="xs"
214
214
  icon="i-lucide-trash-2"
215
215
  :loading="busy"
@@ -347,7 +347,7 @@ async function mergePr() {
347
347
  <UButton
348
348
  icon="i-lucide-trash-2"
349
349
  color="error"
350
- variant="ghost"
350
+ variant="soft"
351
351
  size="xs"
352
352
  :loading="resetting"
353
353
  :disabled="stopping || !access.canExecuteRuns.value"
@@ -1340,7 +1340,7 @@ const { toggleArchive, toggleDefault, edit, removePipeline, clone } = usePipelin
1340
1340
  <IconButton
1341
1341
  v-if="!p.builtin"
1342
1342
  icon="i-lucide-trash-2"
1343
- color="neutral"
1343
+ color="error"
1344
1344
  variant="ghost"
1345
1345
  size="xs"
1346
1346
  :label="t('pipeline.builder.delete')"
@@ -218,7 +218,7 @@ const reseedableCount = computed(
218
218
  v-else
219
219
  size="xs"
220
220
  color="error"
221
- variant="subtle"
221
+ variant="soft"
222
222
  icon="i-lucide-trash-2"
223
223
  :loading="isBusy(h.pipeline.id)"
224
224
  :disabled="locked"
@@ -259,7 +259,7 @@ const reseedableCount = computed(
259
259
  <UButton
260
260
  size="xs"
261
261
  color="error"
262
- variant="subtle"
262
+ variant="soft"
263
263
  icon="i-lucide-trash-2"
264
264
  :loading="isBusy(r.pipeline.id)"
265
265
  :disabled="locked"
@@ -9,6 +9,7 @@ import { computed } from 'vue'
9
9
 
10
10
  const { t } = useI18n()
11
11
  const ui = useUiStore()
12
+ const { canManageSecrets } = useWorkspaceAccess()
12
13
 
13
14
  const open = computed({
14
15
  get: () => ui.aiProviderSetupOpen,
@@ -49,7 +50,12 @@ const routes = computed<Route[]>(() => [
49
50
  title: t('providers.onboarding.routes.keys.title'),
50
51
  body: t('providers.onboarding.routes.keys.body'),
51
52
  cta: t('providers.onboarding.routes.keys.cta'),
52
- onSelect: () => go(ui.openVendorCredentials),
53
+ // The default tab is the workspace's pooled subscriptions, a `secrets.manage` write. Anyone else
54
+ // lands on the direct keys, which open on their own "My keys" scope (see ApiKeysSection).
55
+ onSelect: () =>
56
+ go(() =>
57
+ canManageSecrets.value ? ui.openVendorCredentials() : ui.openVendorCredentials('direct'),
58
+ ),
53
59
  },
54
60
  {
55
61
  icon: 'i-lucide-route',
@@ -37,8 +37,27 @@ const { confirmAction, toastDone } = useConfirmAction()
37
37
  /** Account-wide mode (single account scope) vs the default workspace/user toggle. */
38
38
  const isAccount = computed(() => !!props.accountId)
39
39
 
40
- /** Which store the form writes to: the shared workspace keys or the user's own. */
41
- const scope = ref<'workspace' | 'user'>('workspace')
40
+ /**
41
+ * Which store the form writes to: the shared workspace keys or the user's own. The DEFAULT follows
42
+ * the caller's grant on the active board: the workspace for whoever may write it (`secrets.manage`)
43
+ * and "My keys" for everyone else, so a first save lands instead of being refused. It is derived,
44
+ * not captured once, because this section stays mounted while the board (and so the grant)
45
+ * changes. A scope the user picks by hand wins until they move to another board.
46
+ */
47
+ const { canManageSecrets } = useWorkspaceAccess()
48
+ const pickedScope = ref<'workspace' | 'user' | null>(null)
49
+ const scope = computed<'workspace' | 'user'>({
50
+ get: () => pickedScope.value ?? (canManageSecrets.value ? 'workspace' : 'user'),
51
+ set: (value) => {
52
+ pickedScope.value = value
53
+ },
54
+ })
55
+ watch(
56
+ () => workspace.workspaceId,
57
+ () => {
58
+ pickedScope.value = null
59
+ },
60
+ )
42
61
 
43
62
  // "My keys" (user scope) are stored per-user, so they need a signed-in user. Block just
44
63
  // that scope when there's none (a deployment without sign-in); workspace/account keys are
@@ -120,6 +120,7 @@ function when(epochMs: number): string {
120
120
  {{ t('provisioning.title') }}
121
121
  </SectionLabel>
122
122
  <UButton
123
+ color="neutral"
123
124
  icon="i-lucide-rotate-ccw"
124
125
  variant="ghost"
125
126
  size="xs"
@@ -629,6 +629,7 @@ async function archive(prompt: SandboxPromptVersion) {
629
629
  Offered on a graded candidate and on an older workspace revision (rolling
630
630
  back), but not on the one already live, where it would be a no-op. -->
631
631
  <IconButton
632
+ color="neutral"
632
633
  v-if="canPromote(p)"
633
634
  icon="i-lucide-rocket"
634
635
  variant="ghost"
@@ -540,7 +540,7 @@ function toastRemoved() {
540
540
  <UButton
541
541
  icon="i-lucide-trash-2"
542
542
  color="error"
543
- variant="ghost"
543
+ variant="soft"
544
544
  size="xs"
545
545
  :disabled="busy"
546
546
  @click="removeKubeOverride"
@@ -356,6 +356,7 @@ function fieldHelp(key: string): string | undefined {
356
356
  <div class="flex items-start justify-between gap-3">
357
357
  <p class="text-xs text-muted">{{ blurb }}</p>
358
358
  <UButton
359
+ color="neutral"
359
360
  :icon="showLogs ? 'i-lucide-chevron-up' : 'i-lucide-scroll-text'"
360
361
  variant="ghost"
361
362
  size="xs"
@@ -242,7 +242,7 @@ async function runProbe(id: string) {
242
242
  <UButton
243
243
  v-if="server.oauth.connected"
244
244
  size="xs"
245
- variant="ghost"
245
+ variant="soft"
246
246
  color="error"
247
247
  :loading="store.connecting === server.id"
248
248
  :disabled="store.connecting !== null"
@@ -236,6 +236,7 @@ async function unlinkSource(id: string) {
236
236
  </UBadge>
237
237
  <div class="ms-auto flex gap-1">
238
238
  <IconButton
239
+ color="neutral"
239
240
  icon="i-lucide-search-check"
240
241
  size="xs"
241
242
  variant="ghost"
@@ -244,6 +245,7 @@ async function unlinkSource(id: string) {
244
245
  @click="checkSource(s.id)"
245
246
  />
246
247
  <IconButton
248
+ color="neutral"
247
249
  icon="i-lucide-refresh-cw"
248
250
  size="xs"
249
251
  variant="ghost"
@@ -257,7 +257,7 @@ async function saveMapping() {
257
257
  </span>
258
258
  <UButton
259
259
  color="error"
260
- variant="ghost"
260
+ variant="soft"
261
261
  size="xs"
262
262
  icon="i-lucide-unplug"
263
263
  @click="disconnect"
@@ -193,7 +193,7 @@ async function toggleEnabled(enabled: boolean) {
193
193
  <UButton
194
194
  v-if="connected"
195
195
  color="error"
196
- variant="ghost"
196
+ variant="soft"
197
197
  icon="i-lucide-unplug"
198
198
  @click="disconnect"
199
199
  >
@@ -438,6 +438,7 @@ const GROUP_STATUS_META: Record<ScenarioGroup['status'], { icon: string; text: s
438
438
  </p>
439
439
  <template v-if="infraSetup.logs">
440
440
  <UButton
441
+ color="neutral"
441
442
  :icon="showInfraSetupLogs ? 'i-lucide-chevron-up' : 'i-lucide-scroll-text'"
442
443
  variant="ghost"
443
444
  size="xs"
@@ -476,6 +477,7 @@ const GROUP_STATUS_META: Record<ScenarioGroup['status'], { icon: string; text: s
476
477
 
477
478
  <div v-if="executionId">
478
479
  <UButton
480
+ color="neutral"
479
481
  :icon="showProvisioning ? 'i-lucide-chevron-up' : 'i-lucide-scroll-text'"
480
482
  variant="ghost"
481
483
  size="xs"
@@ -27,6 +27,12 @@ const props = defineProps<{
27
27
 
28
28
  const { t } = useI18n()
29
29
  const github = useGitHubStore()
30
+ const { canManageIntegrations } = useWorkspaceAccess()
31
+
32
+ // Connecting is an `integrations.manage` write, and the connect-options read is gated the same way,
33
+ // so for anyone else the options come back empty. That empty list says nothing about the
34
+ // deployment: name the board admin instead of telling a member the operator configured nothing.
35
+ const adminRequired = computed(() => !canManageIntegrations.value)
30
36
 
31
37
  // Whether the deployment serves neither surface. Rendered as a statement rather than an empty
32
38
  // box: "nothing is configured" and "we couldn't read what is configured" both land here, and a
@@ -36,23 +42,33 @@ const nothingConfigured = computed(() => !github.canConnectGitHubApp && !github.
36
42
 
37
43
  <template>
38
44
  <div class="space-y-3">
39
- <template v-if="github.canConnectGitHubApp">
40
- <p v-if="props.appIntro" class="text-sm text-muted">{{ props.appIntro }}</p>
41
- <GitHubConnect />
42
- </template>
43
-
44
- <USeparator
45
- v-if="github.canConnectGitHubApp && github.canConnectGitLabPat"
46
- :label="t('vcs.connect.or')"
47
- />
48
-
49
- <GitLabConnect v-if="github.canConnectGitLabPat" />
50
-
51
45
  <p
52
- v-if="nothingConfigured"
46
+ v-if="adminRequired"
53
47
  class="rounded-md border border-dashed border-default px-3 py-3 text-sm text-muted"
48
+ data-testid="vcs-connect-admin-required"
54
49
  >
55
- {{ t('vcs.connect.noneConfigured') }}
50
+ {{ t('vcs.connect.adminRequired') }}
56
51
  </p>
52
+
53
+ <template v-else>
54
+ <template v-if="github.canConnectGitHubApp">
55
+ <p v-if="props.appIntro" class="text-sm text-muted">{{ props.appIntro }}</p>
56
+ <GitHubConnect />
57
+ </template>
58
+
59
+ <USeparator
60
+ v-if="github.canConnectGitHubApp && github.canConnectGitLabPat"
61
+ :label="t('vcs.connect.or')"
62
+ />
63
+
64
+ <GitLabConnect v-if="github.canConnectGitLabPat" />
65
+
66
+ <p
67
+ v-if="nothingConfigured"
68
+ class="rounded-md border border-dashed border-default px-3 py-3 text-sm text-muted"
69
+ >
70
+ {{ t('vcs.connect.noneConfigured') }}
71
+ </p>
72
+ </template>
57
73
  </div>
58
74
  </template>
@@ -406,6 +406,7 @@ watch(pendingUpload, async (file) => {
406
406
  {{ t('visualConfirm.requestFix.foldedHint') }}
407
407
  </span>
408
408
  <UButton
409
+ variant="soft"
409
410
  size="sm"
410
411
  color="warning"
411
412
  icon="i-lucide-wrench"
@@ -0,0 +1,25 @@
1
+ import { computed } from 'vue'
2
+ import type { SetupActor } from '~/utils/setupAudience'
3
+
4
+ /**
5
+ * The caller as a setup prompt sees them: which app surface they chose, and the board and account
6
+ * grants that decide whether a "configure this" button would let them save. Every input is read
7
+ * from the source the nav gates read too (the role store, `useWorkspaceAccess`, and the accounts
8
+ * store's `isActiveAccountAdmin`), so a setup prompt and the sidebar cannot disagree about who the
9
+ * caller is.
10
+ */
11
+ export function useSetupActor() {
12
+ const access = useWorkspaceAccess()
13
+ const uiRole = useUiRoleStore()
14
+ const accounts = useAccountsStore()
15
+
16
+ const actor = computed<SetupActor>(() => ({
17
+ fullSurface: uiRole.fullSurface,
18
+ canManageIntegrations: access.canManageIntegrations.value,
19
+ canExecuteRuns: access.canExecuteRuns.value,
20
+ isAccountAdmin: accounts.isActiveAccountAdmin,
21
+ accountsEnabled: accounts.enabled,
22
+ }))
23
+
24
+ return { actor }
25
+ }
@@ -16,7 +16,7 @@ import type { WorkspacePermission, WorkspaceRole } from '~/types/domain'
16
16
  * SPA never hides an affordance the backend would have permitted.
17
17
  *
18
18
  * This is deliberately distinct from the ACCOUNT-scoped admin checks
19
- * (`accounts.activeAccount?.roles?.includes('admin')`), which stay account-scoped: this
19
+ * (`accounts.isActiveAccountAdmin`), which stay account-scoped: this
20
20
  * composable answers "what can you do inside THIS board", the account check answers "what
21
21
  * can you do to the tenant".
22
22
  */
@@ -75,7 +75,6 @@ export function createNavGates(): NavGates {
75
75
  providerConnections.isAvailable('runner-pool') ||
76
76
  providerConnections.isAvailable('environment'),
77
77
  )
78
- const isAccountAdmin = computed(() => accounts.activeAccount?.roles?.includes('admin') ?? false)
79
78
 
80
79
  return {
81
80
  get canWriteBoard() {
@@ -105,7 +104,7 @@ export function createNavGates(): NavGates {
105
104
  return accounts.enabled
106
105
  },
107
106
  get isAccountAdmin() {
108
- return isAccountAdmin.value
107
+ return accounts.isActiveAccountAdmin
109
108
  },
110
109
  get advancedMode() {
111
110
  return uiMode.isAdvanced
@@ -35,6 +35,13 @@ export const useAccountsStore = defineStore(
35
35
  )
36
36
  /** Whether accounts exist (auth on); gates the switcher UI. */
37
37
  const enabled = computed(() => accounts.value.length > 0)
38
+ /**
39
+ * Whether the caller is an admin of the ACTIVE account. The one reading of it, so the nav gates,
40
+ * the setup prompts and the account panels cannot disagree about who the account admin is.
41
+ */
42
+ const isActiveAccountAdmin = computed(
43
+ () => activeAccount.value?.roles?.includes('admin') ?? false,
44
+ )
38
45
 
39
46
  /** Load the user's accounts and resolve the active one (persisted or first). */
40
47
  async function load() {
@@ -209,6 +216,7 @@ export const useAccountsStore = defineStore(
209
216
  activeAccountId,
210
217
  activeAccount,
211
218
  enabled,
219
+ isActiveAccountAdmin,
212
220
  ready,
213
221
  members,
214
222
  invitations,
@@ -1,6 +1,6 @@
1
1
  import { ref } from 'vue'
2
2
  import { applyInfraSetupTransition, isInfraSetupHealthStatus } from '@cat-factory/contracts'
3
- import type { InfraSetup, InfraSetupArea, InfraSetupStatus } from '~/types/domain'
3
+ import type { InfraSetup, InfraSetupArea, InfraSetupOwners, InfraSetupStatus } from '~/types/domain'
4
4
 
5
5
  // The workspace store's infra-setup slice: the per-area setup/health projection the setup banner
6
6
  // renders, and the live `infraSetup` event patch the reachability watcher pushes into it. Its own
@@ -32,8 +32,16 @@ export function createInfraSetupState() {
32
32
  */
33
33
  const infraSetupDetails = ref<Partial<Record<InfraSetupArea, string>>>({})
34
34
 
35
- function hydrate(next: InfraSetup | null | undefined) {
35
+ /**
36
+ * Who can close each area's gap (workspace admin, account admin or the operator), from the
37
+ * snapshot. Static for the life of a deployment, so the live event never touches it. Null on an
38
+ * older backend, which the banner reads as "owner unknown".
39
+ */
40
+ const infraSetupOwners = ref<InfraSetupOwners | null>(null)
41
+
42
+ function hydrate(next: InfraSetup | null | undefined, owners?: InfraSetupOwners | null) {
36
43
  infraSetup.value = next ?? null
44
+ infraSetupOwners.value = owners ?? null
37
45
  // The reasons belong to the live pushes this session observed, so an authoritative snapshot
38
46
  // supersedes them: keeping one would caption a freshly-read status with a stale probe.
39
47
  infraSetupDetails.value = {}
@@ -73,5 +81,5 @@ export function createInfraSetupState() {
73
81
  if (!isInfraSetupHealthStatus(status)) useUiStore().clearInfraSetupSessionDismissal(area)
74
82
  }
75
83
 
76
- return { infraSetup, infraSetupDetails, hydrate, patchInfraSetup }
84
+ return { infraSetup, infraSetupDetails, infraSetupOwners, hydrate, patchInfraSetup }
77
85
  }
@@ -59,6 +59,7 @@ export const useWorkspaceStore = defineStore(
59
59
  const {
60
60
  infraSetup,
61
61
  infraSetupDetails,
62
+ infraSetupOwners,
62
63
  hydrate: hydrateInfraSetup,
63
64
  patchInfraSetup,
64
65
  } = createInfraSetupState()
@@ -98,7 +99,7 @@ export const useWorkspaceStore = defineStore(
98
99
  accountSpend.value = snapshot.accountSpend ?? null
99
100
  userSpend.value = snapshot.userSpend ?? null
100
101
  budgetCaps.value = snapshot.budgetCaps ?? null
101
- hydrateInfraSetup(snapshot.infraSetup)
102
+ hydrateInfraSetup(snapshot.infraSetup, snapshot.infraSetupOwners)
102
103
  access.value = snapshot.access ?? null
103
104
  // Keep the board list in step (e.g. a freshly created board, or a rename). The
104
105
  // snapshot's `workspace` carries no `viewerRole` (that's a `GET /workspaces` list
@@ -241,6 +242,7 @@ export const useWorkspaceStore = defineStore(
241
242
  budgetCaps,
242
243
  infraSetup,
243
244
  infraSetupDetails,
245
+ infraSetupOwners,
244
246
  patchInfraSetup,
245
247
  access,
246
248
  init,
@@ -108,6 +108,8 @@ export type {
108
108
  InfraSetup,
109
109
  InfraSetupStatus,
110
110
  InfraSetupArea,
111
+ InfraSetupOwner,
112
+ InfraSetupOwners,
111
113
  ServiceFragmentDefaults,
112
114
  KaizenGradingStatus,
113
115
  KaizenGrading,
@@ -0,0 +1,115 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type { InfraSetup, InfraSetupOwners } from '~/types/domain'
3
+ import { blockingSetupNotice, canActOnSetup, type SetupActor } from './setupAudience'
4
+
5
+ const admin: SetupActor = {
6
+ fullSurface: true,
7
+ canManageIntegrations: true,
8
+ canExecuteRuns: true,
9
+ isAccountAdmin: false,
10
+ accountsEnabled: true,
11
+ }
12
+ const member: SetupActor = { ...admin, canManageIntegrations: false }
13
+ const viewer: SetupActor = { ...member, canExecuteRuns: false }
14
+ const designerAdmin: SetupActor = { ...admin, fullSurface: false }
15
+ const designerMember: SetupActor = { ...member, fullSurface: false }
16
+
17
+ const owners: InfraSetupOwners = {
18
+ agentExecutor: 'workspace_admin',
19
+ ephemeralEnvironments: 'workspace_admin',
20
+ binaryStorage: 'account_admin',
21
+ }
22
+
23
+ function status(patch: Partial<InfraSetup>): InfraSetup {
24
+ return {
25
+ agentExecutor: 'configured',
26
+ ephemeralEnvironments: 'configured',
27
+ binaryStorage: 'configured',
28
+ ...patch,
29
+ }
30
+ }
31
+
32
+ const noRunner = status({ agentExecutor: 'not_defined' })
33
+
34
+ describe('canActOnSetup', () => {
35
+ it('lets a workspace admin act on the workspace areas, and not a member', () => {
36
+ expect(canActOnSetup('workspace_admin', admin)).toBe(true)
37
+ expect(canActOnSetup('workspace_admin', member)).toBe(false)
38
+ })
39
+
40
+ it('keeps every prompt off the designer surface, whatever the permissions', () => {
41
+ expect(canActOnSetup('workspace_admin', designerAdmin)).toBe(false)
42
+ expect(canActOnSetup('account_admin', { ...designerAdmin, isAccountAdmin: true })).toBe(false)
43
+ })
44
+
45
+ it('gives an account-owned gap to the account admin, not to a workspace admin', () => {
46
+ expect(canActOnSetup('account_admin', admin)).toBe(false)
47
+ expect(canActOnSetup('account_admin', { ...member, isAccountAdmin: true })).toBe(true)
48
+ })
49
+
50
+ it('lets the single user act on an account-owned gap when there are no accounts', () => {
51
+ expect(canActOnSetup('account_admin', { ...admin, accountsEnabled: false })).toBe(true)
52
+ })
53
+
54
+ it('lets nobody act on an operator-owned gap', () => {
55
+ expect(canActOnSetup('operator', { ...admin, isAccountAdmin: true })).toBe(false)
56
+ })
57
+
58
+ it('falls back to the workspace admin for an absent owner or one this build does not know', () => {
59
+ // An older backend sends nothing; a newer one may send a value past the compiled union. Both
60
+ // read as the workspace admin, and neither throws inside the banner's render.
61
+ for (const owner of [undefined, 'board_owner']) {
62
+ expect(canActOnSetup(owner, admin)).toBe(true)
63
+ expect(canActOnSetup(owner, member)).toBe(false)
64
+ }
65
+ })
66
+ })
67
+
68
+ describe('blockingSetupNotice', () => {
69
+ it('tells a member that agents cannot run, and who can fix it', () => {
70
+ expect(blockingSetupNotice(noRunner, owners, member)).toEqual({
71
+ area: 'agentExecutor',
72
+ kind: 'setup',
73
+ remedy: 'workspace_admin',
74
+ })
75
+ })
76
+
77
+ it('reports an executor outage as an outage', () => {
78
+ expect(
79
+ blockingSetupNotice(status({ agentExecutor: 'unreachable' }), owners, member)?.kind,
80
+ ).toBe('outage')
81
+ })
82
+
83
+ it('says nothing to the admin, who gets the full card instead', () => {
84
+ expect(blockingSetupNotice(noRunner, owners, admin)).toBeNull()
85
+ })
86
+
87
+ it('says nothing to a viewer, whose work the gap does not stop', () => {
88
+ expect(blockingSetupNotice(noRunner, owners, viewer)).toBeNull()
89
+ })
90
+
91
+ it('tells an admin on the designer surface to switch surface, not to find an admin', () => {
92
+ expect(blockingSetupNotice(noRunner, owners, designerAdmin)?.remedy).toBe('full_surface')
93
+ // A designer without the grant is still pointed at the owner.
94
+ expect(blockingSetupNotice(noRunner, owners, designerMember)?.remedy).toBe('workspace_admin')
95
+ })
96
+
97
+ it('says nothing about gaps that only degrade some runs', () => {
98
+ const optional = status({ ephemeralEnvironments: 'not_defined', binaryStorage: 'not_defined' })
99
+ expect(blockingSetupNotice(optional, owners, member)).toBeNull()
100
+ })
101
+
102
+ it('says nothing when the executor is configured or not used here', () => {
103
+ expect(blockingSetupNotice(status({}), owners, member)).toBeNull()
104
+ expect(
105
+ blockingSetupNotice(status({ agentExecutor: 'not_applicable' }), owners, member),
106
+ ).toBeNull()
107
+ expect(blockingSetupNotice(null, owners, member)).toBeNull()
108
+ })
109
+
110
+ it('names the workspace admin when the backend reports no owner, or one it does not know', () => {
111
+ expect(blockingSetupNotice(noRunner, null, member)?.remedy).toBe('workspace_admin')
112
+ const unknown = { ...owners, agentExecutor: 'board_owner' } as unknown as InfraSetupOwners
113
+ expect(blockingSetupNotice(noRunner, unknown, member)?.remedy).toBe('workspace_admin')
114
+ })
115
+ })