@cat-factory/app 0.310.3 → 0.311.1
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 +17 -0
- package/app/assets/css/main.css +10 -0
- package/app/components/board/DependencyConnectOverlay.vue +1 -0
- package/app/components/common/AsyncViewError.vue +1 -0
- package/app/components/github/GitHubOnboarding.vue +9 -1
- package/app/components/kaizen/KaizenPanel.vue +3 -1
- package/app/components/layout/AccountTeamSettings.vue +1 -1
- 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/layout/SideBar.vue +4 -1
- package/app/components/media/ArtifactLightbox.vue +3 -2
- package/app/components/panels/AgentStepDetail.vue +5 -3
- package/app/components/panels/InspectorPanel.vue +2 -1
- package/app/components/panels/ObservabilityPanel.vue +19 -12
- package/app/components/panels/OperatorDashboardPanel.vue +36 -28
- package/app/components/panels/ReportsPanel.vue +3 -1
- package/app/components/panels/ResultWindowShell.logic.ts +4 -4
- package/app/components/panels/ResultWindowShell.vue +8 -3
- package/app/components/providers/AiProviderOnboardingModal.vue +7 -1
- package/app/components/providers/ApiKeysSection.vue +21 -2
- package/app/components/settings/ModelConfigurationPanel.vue +3 -1
- package/app/components/vcs/VcsConnectSurfaces.vue +30 -14
- 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/safeArea.spec.ts +33 -0
- package/app/utils/safeArea.ts +41 -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 +4 -4
|
@@ -7,6 +7,7 @@ import { formatMs } from '~/utils/observability'
|
|
|
7
7
|
import { FAILURE_KIND_KEYS, isAgentFailureKind } from '~/utils/failureKinds'
|
|
8
8
|
import SectionLabel from '~/components/common/SectionLabel.vue'
|
|
9
9
|
import IconButton from '~/components/common/IconButton.vue'
|
|
10
|
+
import { FULL_BLEED_TAKEOVER_CLASS } from '~/utils/safeArea'
|
|
10
11
|
|
|
11
12
|
// Deployment-level (platform-operator) observability dashboard: the aggregate health of the
|
|
12
13
|
// active account's runs — outcome totals + success rate, a time-bucketed outcome trend, the
|
|
@@ -122,24 +123,29 @@ watch(
|
|
|
122
123
|
<Transition name="obs-fade">
|
|
123
124
|
<div
|
|
124
125
|
v-if="open"
|
|
125
|
-
class="
|
|
126
|
+
class="z-[60] flex-col bg-app-950/96 backdrop-blur-sm"
|
|
127
|
+
:class="FULL_BLEED_TAKEOVER_CLASS"
|
|
126
128
|
role="dialog"
|
|
127
129
|
aria-modal="true"
|
|
128
130
|
data-testid="operator-dashboard"
|
|
129
131
|
>
|
|
130
|
-
<header class="flex items-center gap-3 border-b border-default px-6 py-4">
|
|
132
|
+
<header class="flex flex-wrap items-center gap-3 border-b border-default px-6 py-4">
|
|
131
133
|
<div
|
|
132
134
|
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-app-info-500/15"
|
|
133
135
|
>
|
|
134
136
|
<UIcon name="i-lucide-gauge" class="h-5 w-5 text-app-info-400" />
|
|
135
137
|
</div>
|
|
136
|
-
<div class="min-w-0">
|
|
138
|
+
<div class="min-w-0 flex-1">
|
|
137
139
|
<h1 class="truncate text-base font-semibold text-highlighted">
|
|
138
140
|
{{ t('platformObservability.title') }}
|
|
139
141
|
</h1>
|
|
140
142
|
<p v-if="accountName" class="truncate text-xs text-dimmed">{{ accountName }}</p>
|
|
141
143
|
</div>
|
|
142
|
-
|
|
144
|
+
<!-- Below lg the window tabs take their own row, so refresh and close stay on screen.
|
|
145
|
+
The row scrolls sideways when the tabs are wider than the phone. -->
|
|
146
|
+
<div
|
|
147
|
+
class="order-last flex w-full min-w-0 items-center gap-1.5 overflow-x-auto lg:order-none lg:w-auto"
|
|
148
|
+
>
|
|
143
149
|
<UTabs
|
|
144
150
|
:model-value="platform.window"
|
|
145
151
|
:items="WINDOWS"
|
|
@@ -154,31 +160,33 @@ watch(
|
|
|
154
160
|
<span :data-testid="`operator-window-${item.value}`">{{ item.label }}</span>
|
|
155
161
|
</template>
|
|
156
162
|
</UTabs>
|
|
157
|
-
<IconButton
|
|
158
|
-
color="neutral"
|
|
159
|
-
variant="ghost"
|
|
160
|
-
icon="i-lucide-refresh-cw"
|
|
161
|
-
:label="t('platformObservability.refresh')"
|
|
162
|
-
:ui="{
|
|
163
|
-
base: 'rounded-lg border border-default p-1.5 text-muted transition hover:text-default',
|
|
164
|
-
leadingIcon: loading ? 'h-4 w-4 animate-spin' : 'h-4 w-4',
|
|
165
|
-
}"
|
|
166
|
-
data-testid="operator-refresh"
|
|
167
|
-
@click="refresh"
|
|
168
|
-
/>
|
|
169
|
-
<IconButton
|
|
170
|
-
color="neutral"
|
|
171
|
-
variant="ghost"
|
|
172
|
-
icon="i-lucide-x"
|
|
173
|
-
:label="t('platformObservability.close')"
|
|
174
|
-
:ui="{
|
|
175
|
-
base: 'rounded-lg border border-default p-1.5 text-muted transition hover:text-default',
|
|
176
|
-
leadingIcon: 'h-4 w-4',
|
|
177
|
-
}"
|
|
178
|
-
data-testid="operator-close"
|
|
179
|
-
@click="close"
|
|
180
|
-
/>
|
|
181
163
|
</div>
|
|
164
|
+
<IconButton
|
|
165
|
+
class="lg:-ms-1.5"
|
|
166
|
+
color="neutral"
|
|
167
|
+
variant="ghost"
|
|
168
|
+
icon="i-lucide-refresh-cw"
|
|
169
|
+
:label="t('platformObservability.refresh')"
|
|
170
|
+
:ui="{
|
|
171
|
+
base: 'rounded-lg border border-default p-1.5 text-muted transition hover:text-default',
|
|
172
|
+
leadingIcon: loading ? 'h-4 w-4 animate-spin' : 'h-4 w-4',
|
|
173
|
+
}"
|
|
174
|
+
data-testid="operator-refresh"
|
|
175
|
+
@click="refresh"
|
|
176
|
+
/>
|
|
177
|
+
<IconButton
|
|
178
|
+
class="-ms-1.5"
|
|
179
|
+
color="neutral"
|
|
180
|
+
variant="ghost"
|
|
181
|
+
icon="i-lucide-x"
|
|
182
|
+
:label="t('platformObservability.close')"
|
|
183
|
+
:ui="{
|
|
184
|
+
base: 'rounded-lg border border-default p-1.5 text-muted transition hover:text-default',
|
|
185
|
+
leadingIcon: 'h-4 w-4',
|
|
186
|
+
}"
|
|
187
|
+
data-testid="operator-close"
|
|
188
|
+
@click="close"
|
|
189
|
+
/>
|
|
182
190
|
</header>
|
|
183
191
|
|
|
184
192
|
<div class="flex-1 overflow-y-auto px-6 py-5">
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
import ReportsSpendBreakdown from '~/components/panels/ReportsSpendBreakdown.vue'
|
|
22
22
|
import SectionLabel from '~/components/common/SectionLabel.vue'
|
|
23
23
|
import IconButton from '~/components/common/IconButton.vue'
|
|
24
|
+
import { FULL_BLEED_TAKEOVER_CLASS } from '~/utils/safeArea'
|
|
24
25
|
|
|
25
26
|
// Reports: cross-cutting usage analytics for the active account — where the spend and the
|
|
26
27
|
// work actually go. Spend per model and agent kind, spend + run activity per workspace /
|
|
@@ -196,7 +197,8 @@ watch(
|
|
|
196
197
|
<Transition name="reports-fade">
|
|
197
198
|
<div
|
|
198
199
|
v-if="open"
|
|
199
|
-
class="
|
|
200
|
+
class="z-[60] flex-col bg-app-950/96 backdrop-blur-sm"
|
|
201
|
+
:class="FULL_BLEED_TAKEOVER_CLASS"
|
|
200
202
|
role="dialog"
|
|
201
203
|
aria-modal="true"
|
|
202
204
|
data-testid="reports-panel"
|
|
@@ -11,10 +11,10 @@ export type ResultWindowWidth = '3xl' | '4xl' | '5xl' | 'full'
|
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
13
|
* The bucket → cap mapping. `full` is deliberately `max-w-none` rather than a bigger number:
|
|
14
|
-
* the panel's `w-full` then spans the backdrop, which
|
|
15
|
-
*
|
|
16
|
-
* than a repaint of the app. A `Record` over the union, so a new bucket fails to
|
|
17
|
-
* it is mapped.
|
|
14
|
+
* the panel's `w-full` then spans the backdrop, which insets it by one gutter
|
|
15
|
+
* (`WINDOWED_OVERLAY_CLASS`, `utils/safeArea.ts`), so the window fills the screen and still reads
|
|
16
|
+
* as a window rather than a repaint of the app. A `Record` over the union, so a new bucket fails to
|
|
17
|
+
* compile until it is mapped.
|
|
18
18
|
*/
|
|
19
19
|
export const RESULT_WINDOW_WIDTH_CLASS: Record<ResultWindowWidth, string> = {
|
|
20
20
|
'3xl': 'max-w-3xl',
|
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
RESULT_WINDOW_WIDTH_CLASS,
|
|
41
41
|
type ResultWindowWidth,
|
|
42
42
|
} from '~/components/panels/ResultWindowShell.logic'
|
|
43
|
+
import { WINDOWED_OVERLAY_CLASS } from '~/utils/safeArea'
|
|
43
44
|
import SectionLabel from '~/components/common/SectionLabel.vue'
|
|
44
45
|
|
|
45
46
|
/** A pipeline step reference — passed by step-result windows to surface the shared
|
|
@@ -255,13 +256,17 @@ const chipClass = computed(() =>
|
|
|
255
256
|
)
|
|
256
257
|
|
|
257
258
|
const backdropClass = computed(() => [
|
|
258
|
-
|
|
259
|
-
|
|
259
|
+
WINDOWED_OVERLAY_CLASS,
|
|
260
|
+
'z-50 justify-center bg-app-950/70 backdrop-blur-sm',
|
|
261
|
+
props.variant === 'centered' ? 'items-center' : 'items-stretch',
|
|
260
262
|
])
|
|
263
|
+
// The centered cap is the smaller of 90dvh and the backdrop's padded box: with a large inset (a
|
|
264
|
+
// Dynamic Island in standalone mode) the gutter alone can exceed 10% of the height, and a window
|
|
265
|
+
// sized to 90dvh would then push its top edge back under the status bar.
|
|
261
266
|
const panelClass = computed(() => [
|
|
262
267
|
'flex w-full flex-col overflow-hidden rounded-2xl border border-default bg-default shadow-2xl',
|
|
263
268
|
RESULT_WINDOW_WIDTH_CLASS[props.width],
|
|
264
|
-
props.variant === 'centered'
|
|
269
|
+
props.variant === 'centered' && 'max-h-[min(90dvh,100%)]',
|
|
265
270
|
])
|
|
266
271
|
</script>
|
|
267
272
|
|
|
@@ -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
|
|
@@ -24,6 +24,7 @@ import ProviderPreferenceEditor from '~/components/settings/ProviderPreferenceEd
|
|
|
24
24
|
import { showOverrideField } from '~/utils/uiMode'
|
|
25
25
|
import SectionLabel from '~/components/common/SectionLabel.vue'
|
|
26
26
|
import IconButton from '~/components/common/IconButton.vue'
|
|
27
|
+
import { FULL_BLEED_TAKEOVER_CLASS } from '~/utils/safeArea'
|
|
27
28
|
|
|
28
29
|
const { t } = useI18n()
|
|
29
30
|
const ui = useUiStore()
|
|
@@ -300,7 +301,8 @@ async function save() {
|
|
|
300
301
|
<Transition name="reader-fade">
|
|
301
302
|
<div
|
|
302
303
|
v-if="open"
|
|
303
|
-
class="
|
|
304
|
+
class="z-50 flex-col bg-app-950/96 backdrop-blur-sm"
|
|
305
|
+
:class="FULL_BLEED_TAKEOVER_CLASS"
|
|
304
306
|
role="dialog"
|
|
305
307
|
aria-modal="true"
|
|
306
308
|
>
|
|
@@ -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,33 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { FULL_BLEED_TAKEOVER_CLASS, WINDOWED_OVERLAY_CLASS } from '~/utils/safeArea'
|
|
3
|
+
|
|
4
|
+
// A missing or misspelled side is silent on every screen without a notch, so the exact spellings
|
|
5
|
+
// are pinned here. That every full-screen surface binds one of these is
|
|
6
|
+
// `scripts/check-frontend-safe-area.mjs`'s job.
|
|
7
|
+
describe('full-screen overlay root classes', () => {
|
|
8
|
+
it('gives a takeover only the inset, so it stays edge to edge without one', () => {
|
|
9
|
+
expect(FULL_BLEED_TAKEOVER_CLASS.split(' ')).toEqual([
|
|
10
|
+
'fixed',
|
|
11
|
+
'inset-0',
|
|
12
|
+
'flex',
|
|
13
|
+
'max-h-[100dvh]',
|
|
14
|
+
'pt-[env(safe-area-inset-top)]',
|
|
15
|
+
'pr-[env(safe-area-inset-right)]',
|
|
16
|
+
'pb-[env(safe-area-inset-bottom)]',
|
|
17
|
+
'pl-[env(safe-area-inset-left)]',
|
|
18
|
+
])
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('gives a windowed backdrop 1rem or the inset, whichever is larger, on every side', () => {
|
|
22
|
+
expect(WINDOWED_OVERLAY_CLASS.split(' ')).toEqual([
|
|
23
|
+
'fixed',
|
|
24
|
+
'inset-0',
|
|
25
|
+
'flex',
|
|
26
|
+
'max-h-[100dvh]',
|
|
27
|
+
'pt-[max(1rem,env(safe-area-inset-top))]',
|
|
28
|
+
'pr-[max(1rem,env(safe-area-inset-right))]',
|
|
29
|
+
'pb-[max(1rem,env(safe-area-inset-bottom))]',
|
|
30
|
+
'pl-[max(1rem,env(safe-area-inset-left))]',
|
|
31
|
+
])
|
|
32
|
+
})
|
|
33
|
+
})
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The root classes of the full-screen overlays, with the safe-area padding built in, so a phone's
|
|
3
|
+
* notch, rounded corners and home indicator never cover a header, a close button or an edge. The
|
|
4
|
+
* insets are physical, so the sides are too. They resolve to 0 unless the viewport meta carries
|
|
5
|
+
* `viewport-fit=cover` (`nuxt.config.ts`).
|
|
6
|
+
*
|
|
7
|
+
* A full-screen overlay binds one of these two constants on its root instead of spelling
|
|
8
|
+
* `fixed inset-0` itself, so it cannot drop the padding or the `100dvh` cap. The site adds only its
|
|
9
|
+
* stacking, direction and backdrop. `scripts/check-frontend-safe-area.mjs` refuses a full-screen
|
|
10
|
+
* `fixed` surface written anywhere else.
|
|
11
|
+
*
|
|
12
|
+
* Every class is spelled out literally: Tailwind generates only the class names it finds in the
|
|
13
|
+
* source text, so a name assembled at runtime would compile to nothing.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const FULL_SCREEN = 'fixed inset-0 flex max-h-[100dvh]'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A full-bleed takeover (`AgentStepDetail`, the model configuration takeover, the panels and the
|
|
20
|
+
* lightbox): only the inset, so on a screen without one the layout is exactly the edge-to-edge one
|
|
21
|
+
* it was designed as.
|
|
22
|
+
*/
|
|
23
|
+
export const FULL_BLEED_TAKEOVER_CLASS = [
|
|
24
|
+
FULL_SCREEN,
|
|
25
|
+
'pt-[env(safe-area-inset-top)]',
|
|
26
|
+
'pr-[env(safe-area-inset-right)]',
|
|
27
|
+
'pb-[env(safe-area-inset-bottom)]',
|
|
28
|
+
'pl-[env(safe-area-inset-left)]',
|
|
29
|
+
].join(' ')
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A windowed overlay's backdrop (`ResultWindowShell`): 1rem, or the inset on that side when it is
|
|
33
|
+
* larger, so the window always floats clear of the screen edge and of the notch.
|
|
34
|
+
*/
|
|
35
|
+
export const WINDOWED_OVERLAY_CLASS = [
|
|
36
|
+
FULL_SCREEN,
|
|
37
|
+
'pt-[max(1rem,env(safe-area-inset-top))]',
|
|
38
|
+
'pr-[max(1rem,env(safe-area-inset-right))]',
|
|
39
|
+
'pb-[max(1rem,env(safe-area-inset-bottom))]',
|
|
40
|
+
'pl-[max(1rem,env(safe-area-inset-left))]',
|
|
41
|
+
].join(' ')
|
|
@@ -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
|
+
})
|