@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.
- package/README.md +24 -10
- package/app/components/documents/DocumentSourceConnectModal.vue +1 -1
- package/app/components/foundational/FoundationalServiceCatalogList.vue +2 -0
- package/app/components/foundational/FoundationalServiceRegistry.vue +9 -2
- package/app/components/foundational/FoundationalServiceSources.vue +2 -0
- package/app/components/foundational/FoundationalSuppressions.vue +1 -0
- package/app/components/fragments/FragmentLibraryManager.vue +4 -0
- package/app/components/github/GitHubOnboarding.vue +9 -1
- package/app/components/github/RepoTreeBrowser.vue +3 -0
- package/app/components/guidedReview/GuidedReviewDraftCard.vue +16 -4
- package/app/components/guidedReview/GuidedReviewModal.vue +1 -0
- package/app/components/guidedReview/GuidedReviewOverview.vue +1 -0
- package/app/components/humanTest/HumanTestWindow.vue +3 -2
- package/app/components/initiative/InitiativePlanningWindow.vue +1 -1
- package/app/components/judge/JudgeResultView.vue +1 -0
- package/app/components/layout/AccountDeploymentSettings.vue +8 -8
- package/app/components/layout/AccountTeamSettings.vue +2 -2
- package/app/components/layout/BoardSwitcher.vue +33 -14
- package/app/components/layout/DefaultTestEnvBanner.vue +4 -1
- package/app/components/layout/InfraSetupBanner.vue +26 -2
- package/app/components/layout/InfraSetupNotice.vue +69 -0
- package/app/components/layout/ProviderConfigBanner.vue +8 -1
- package/app/components/panels/AgentStepDetail.vue +2 -0
- package/app/components/panels/InspectorPanel.vue +1 -1
- package/app/components/panels/inspector/ServiceReleaseHealthConfig.vue +1 -1
- package/app/components/panels/inspector/ServiceTestSecrets.vue +1 -1
- package/app/components/panels/inspector/ServiceValidationConfig.vue +1 -1
- package/app/components/panels/inspector/TaskExecution.vue +1 -1
- package/app/components/pipeline/PipelineBuilder.vue +1 -1
- package/app/components/pipeline/PipelineHealthModal.vue +2 -2
- package/app/components/providers/AiProviderOnboardingModal.vue +7 -1
- package/app/components/providers/ApiKeysSection.vue +21 -2
- package/app/components/provisioning/ProvisioningLogsDrawer.vue +1 -0
- package/app/components/sandbox/SandboxPanel.vue +1 -0
- package/app/components/settings/InfraHandlersConfigurator.vue +1 -1
- package/app/components/settings/ProviderConnectionTab.vue +1 -0
- package/app/components/settings/ToolServerChecklist.vue +1 -1
- package/app/components/skills/SkillLibraryManager.vue +2 -0
- package/app/components/slack/SlackPanel.vue +1 -1
- package/app/components/tasks/TaskSourceConnectModal.vue +1 -1
- package/app/components/testing/TestReportWindow.vue +2 -0
- package/app/components/vcs/VcsConnectSurfaces.vue +30 -14
- package/app/components/visualConfirm/VisualConfirmationWindow.vue +1 -0
- package/app/composables/useSetupActor.ts +25 -0
- package/app/composables/useWorkspaceAccess.ts +1 -1
- package/app/modular/nav-gates.ts +1 -2
- package/app/stores/accounts.ts +8 -0
- package/app/stores/workspace/infraSetup.ts +11 -3
- package/app/stores/workspace.ts +3 -1
- package/app/types/domain.ts +2 -0
- package/app/utils/setupAudience.spec.ts +115 -0
- package/app/utils/setupAudience.ts +102 -0
- package/i18n/locales/de.json +15 -1
- package/i18n/locales/en.json +15 -1
- package/i18n/locales/es.json +15 -1
- package/i18n/locales/fr.json +15 -1
- package/i18n/locales/he.json +15 -1
- package/i18n/locales/it.json +15 -1
- package/i18n/locales/ja.json +15 -1
- package/i18n/locales/pl.json +15 -1
- package/i18n/locales/tr.json +15 -1
- package/i18n/locales/uk.json +15 -1
- 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
|
-
|
|
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"
|
|
@@ -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="
|
|
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="
|
|
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="
|
|
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
|
-
|
|
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
|
-
/**
|
|
41
|
-
|
|
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
|
|
@@ -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"
|
|
@@ -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"
|
|
@@ -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"
|
|
@@ -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="
|
|
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.
|
|
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>
|
|
@@ -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.
|
|
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
|
*/
|
package/app/modular/nav-gates.ts
CHANGED
|
@@ -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
|
|
107
|
+
return accounts.isActiveAccountAdmin
|
|
109
108
|
},
|
|
110
109
|
get advancedMode() {
|
|
111
110
|
return uiMode.isAdvanced
|
package/app/stores/accounts.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/app/stores/workspace.ts
CHANGED
|
@@ -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,
|
package/app/types/domain.ts
CHANGED
|
@@ -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
|
+
})
|