@cat-factory/app 0.260.1 → 0.261.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/app/components/auth/LoginScreen.vue +4 -3
- package/app/components/settings/ConnectionTestVerdict.vue +62 -0
- package/app/components/settings/InfraHandlersConfigurator.logic.spec.ts +64 -0
- package/app/components/settings/InfraHandlersConfigurator.logic.ts +41 -0
- package/app/components/settings/InfraHandlersConfigurator.vue +36 -9
- package/app/components/settings/KubernetesEngineForm.vue +24 -7
- package/app/components/settings/KubernetesEnvironmentForm.vue +20 -7
- package/app/components/settings/McpAuthorizeScreen.vue +262 -0
- package/app/components/settings/ProviderConnectionTab.vue +3 -7
- package/app/components/settings/ProviderManifestEditor.vue +3 -7
- package/app/composables/api/mcpAuthorization.ts +30 -0
- package/app/composables/useApi.ts +2 -0
- package/app/composables/useServiceAccountTokenProblem.ts +52 -0
- package/app/pages/mcp-authorize.vue +7 -0
- package/app/stores/auth/session.ts +14 -2
- package/app/stores/ui/k3sDeepLink.spec.ts +79 -0
- package/app/stores/ui/modals.ts +25 -2
- package/app/types/providerConnections.ts +9 -0
- package/app/utils/connectionFailures.ts +32 -0
- package/app/utils/postSignIn.spec.ts +29 -0
- package/app/utils/postSignIn.ts +29 -0
- package/i18n/locales/de.json +57 -0
- package/i18n/locales/en.json +57 -0
- package/i18n/locales/es.json +57 -0
- package/i18n/locales/fr.json +57 -0
- package/i18n/locales/he.json +57 -0
- package/i18n/locales/it.json +57 -0
- package/i18n/locales/ja.json +57 -0
- package/i18n/locales/pl.json +57 -0
- package/i18n/locales/tr.json +57 -0
- package/i18n/locales/uk.json +57 -0
- package/package.json +2 -2
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, onMounted, ref } from 'vue'
|
|
3
|
+
import {
|
|
4
|
+
MCP_AUTHORIZATION_REQUEST_INVALID,
|
|
5
|
+
PUBLIC_API_SCOPES,
|
|
6
|
+
type PublicApiScope,
|
|
7
|
+
} from '@cat-factory/contracts'
|
|
8
|
+
import { apiErrorEnvelope, apiErrorReason } from '~/composables/api/errors'
|
|
9
|
+
|
|
10
|
+
// The consent screen an MCP host's authorization request lands on
|
|
11
|
+
// (`/mcp-authorize?request=…`), reached by a redirect from `GET /oauth/authorize`.
|
|
12
|
+
//
|
|
13
|
+
// A page in the APP rather than a screen the backend renders, and that is the security shape of
|
|
14
|
+
// this flow: the authorization endpoint is a top-level navigation a third party triggers, carrying
|
|
15
|
+
// no bearer token, so a screen served there could never say WHO is approving. Here the session is
|
|
16
|
+
// the app's own, and the two calls this page makes are ordinary gated API where the board choice
|
|
17
|
+
// and its `secrets.manage` check actually run.
|
|
18
|
+
//
|
|
19
|
+
// Not a public route: an expired session renders the login screen on this same URL, and once the
|
|
20
|
+
// person signs in the query string is still here and the flow continues (`postSignInUrl`, which
|
|
21
|
+
// LoginScreen reloads to, exists to keep it). That is correct rather than a gap, and it is also how
|
|
22
|
+
// an SSO deployment gets its identity provider into a flow that otherwise has no idea who anyone
|
|
23
|
+
// is.
|
|
24
|
+
|
|
25
|
+
const api = useApi()
|
|
26
|
+
const { t } = useI18n()
|
|
27
|
+
|
|
28
|
+
type Screen = 'loading' | 'deciding' | 'submitting' | 'failed'
|
|
29
|
+
|
|
30
|
+
const screen = ref<Screen>('loading')
|
|
31
|
+
const detail = ref<string | null>(null)
|
|
32
|
+
/**
|
|
33
|
+
* A refusal that did NOT consume the request, shown beside the choices rather than instead of
|
|
34
|
+
* them. See `decide`: the two failures are answered differently because only one of them ends the
|
|
35
|
+
* flow.
|
|
36
|
+
*/
|
|
37
|
+
const decisionError = ref<string | null>(null)
|
|
38
|
+
const clientName = ref('')
|
|
39
|
+
const redirectOrigin = ref('')
|
|
40
|
+
const workspaces = ref<{ label: string; value: string }[]>([])
|
|
41
|
+
const workspaceId = ref<string | undefined>(undefined)
|
|
42
|
+
/**
|
|
43
|
+
* Starts at the FLOOR of the ladder and is replaced by the server's `defaultScope` once the request
|
|
44
|
+
* resolves. The screen never preselects from the host's own ask: an unauthenticated registration
|
|
45
|
+
* can name any scope, so the server clamps it (`consentDefaultScope`) and this page renders what it
|
|
46
|
+
* was given. Least privilege before that answer arrives, in case a render ever beats it.
|
|
47
|
+
*/
|
|
48
|
+
const scope = ref<PublicApiScope>('read')
|
|
49
|
+
/** What the host asked for, when the server preselected something else. Shown, never applied. */
|
|
50
|
+
const requestedScope = ref<PublicApiScope | null>(null)
|
|
51
|
+
|
|
52
|
+
const sealedRequest = computed(() =>
|
|
53
|
+
typeof window === 'undefined'
|
|
54
|
+
? ''
|
|
55
|
+
: (new URLSearchParams(window.location.search).get('request') ?? ''),
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The ladder, as choices. Every rung is offered rather than a curated subset: the rungs are what
|
|
60
|
+
* the surface itself enforces, and hiding one would leave a host that genuinely needs it unable to
|
|
61
|
+
* be granted it from the screen built for granting.
|
|
62
|
+
*/
|
|
63
|
+
const scopeItems = computed(() =>
|
|
64
|
+
PUBLIC_API_SCOPES.map((value) => ({
|
|
65
|
+
value,
|
|
66
|
+
label: t(`mcpAuthorize.scope.${value}.label`),
|
|
67
|
+
description: t(`mcpAuthorize.scope.${value}.description`),
|
|
68
|
+
})),
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
onMounted(async () => {
|
|
72
|
+
if (!sealedRequest.value) {
|
|
73
|
+
screen.value = 'failed'
|
|
74
|
+
detail.value = t('mcpAuthorize.error.noRequest')
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
const [request, boards] = await Promise.all([
|
|
79
|
+
api.describeMcpAuthorization(sealedRequest.value),
|
|
80
|
+
api.listWorkspaces(),
|
|
81
|
+
])
|
|
82
|
+
clientName.value = request.clientName
|
|
83
|
+
redirectOrigin.value = request.redirectOrigin
|
|
84
|
+
scope.value = request.defaultScope
|
|
85
|
+
// Only worth saying when the two differ: identical values would be a line telling a person
|
|
86
|
+
// that the thing in front of them is the thing in front of them.
|
|
87
|
+
requestedScope.value =
|
|
88
|
+
request.requestedScope && request.requestedScope !== request.defaultScope
|
|
89
|
+
? request.requestedScope
|
|
90
|
+
: null
|
|
91
|
+
workspaces.value = boards.map((board) => ({ label: board.name, value: board.id }))
|
|
92
|
+
workspaceId.value = workspaces.value[0]?.value
|
|
93
|
+
screen.value = 'deciding'
|
|
94
|
+
} catch (e) {
|
|
95
|
+
// Terminal whatever the cause: with no request to describe there is nothing to decide, and the
|
|
96
|
+
// person is told to start again from the host, which is the only place a new one comes from.
|
|
97
|
+
screen.value = 'failed'
|
|
98
|
+
detail.value = apiErrorEnvelope(e)?.message ?? t('mcpAuthorize.error.expired')
|
|
99
|
+
}
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
async function decide(decision: 'approve' | 'deny') {
|
|
103
|
+
if (decision === 'approve' && !workspaceId.value) return
|
|
104
|
+
screen.value = 'submitting'
|
|
105
|
+
decisionError.value = null
|
|
106
|
+
try {
|
|
107
|
+
const result = await api.decideMcpAuthorization(
|
|
108
|
+
decision === 'approve'
|
|
109
|
+
? {
|
|
110
|
+
decision,
|
|
111
|
+
request: sealedRequest.value,
|
|
112
|
+
workspaceId: workspaceId.value as string,
|
|
113
|
+
scope: scope.value,
|
|
114
|
+
}
|
|
115
|
+
: { decision, request: sealedRequest.value },
|
|
116
|
+
)
|
|
117
|
+
// A full navigation, never a router push: the destination is the HOST's own callback, which is
|
|
118
|
+
// waiting for a browser to arrive at it with the code on the query string.
|
|
119
|
+
if (typeof window !== 'undefined') window.location.assign(result.redirectTo)
|
|
120
|
+
} catch (e) {
|
|
121
|
+
// Two outcomes, because two things can be wrong and only one of them ends the flow. The sealed
|
|
122
|
+
// request gone is TERMINAL: nothing on this page can mint another, so it says so and offers the
|
|
123
|
+
// way out. Anything else (this board is one the person cannot mint a key on, it disappeared,
|
|
124
|
+
// the deployment hiccuped) leaves the request valid and this screen the only place the decision
|
|
125
|
+
// can be made, so dropping to a dead end over it would strand a person who has a board they
|
|
126
|
+
// COULD have picked one dropdown away.
|
|
127
|
+
if (apiErrorReason(e) === MCP_AUTHORIZATION_REQUEST_INVALID) {
|
|
128
|
+
screen.value = 'failed'
|
|
129
|
+
detail.value = apiErrorEnvelope(e)?.message ?? t('mcpAuthorize.error.expired')
|
|
130
|
+
return
|
|
131
|
+
}
|
|
132
|
+
screen.value = 'deciding'
|
|
133
|
+
decisionError.value = apiErrorEnvelope(e)?.message ?? t('mcpAuthorize.error.failed')
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function backToApp() {
|
|
138
|
+
if (typeof window !== 'undefined') window.location.assign('/')
|
|
139
|
+
}
|
|
140
|
+
</script>
|
|
141
|
+
|
|
142
|
+
<template>
|
|
143
|
+
<div
|
|
144
|
+
class="flex min-h-screen w-screen items-center justify-center bg-slate-950 p-4 text-slate-100"
|
|
145
|
+
data-testid="mcp-authorize"
|
|
146
|
+
>
|
|
147
|
+
<div
|
|
148
|
+
class="w-full max-w-md rounded-xl border border-slate-800 bg-slate-900/80 p-8 backdrop-blur"
|
|
149
|
+
>
|
|
150
|
+
<template v-if="screen === 'loading'">
|
|
151
|
+
<UIcon name="i-lucide-loader" class="mx-auto h-10 w-10 animate-spin text-indigo-400" />
|
|
152
|
+
</template>
|
|
153
|
+
|
|
154
|
+
<template v-else-if="screen === 'failed'">
|
|
155
|
+
<UIcon name="i-lucide-alert-triangle" class="mx-auto mb-3 h-10 w-10 text-red-400" />
|
|
156
|
+
<h1
|
|
157
|
+
class="mb-1 text-center text-lg font-semibold text-white"
|
|
158
|
+
data-testid="mcp-authorize-failed"
|
|
159
|
+
>
|
|
160
|
+
{{ t('mcpAuthorize.error.title') }}
|
|
161
|
+
</h1>
|
|
162
|
+
<p class="mb-6 text-center text-sm break-words text-slate-400">{{ detail }}</p>
|
|
163
|
+
<UButton block color="neutral" variant="subtle" @click="backToApp">
|
|
164
|
+
{{ t('mcpAuthorize.back') }}
|
|
165
|
+
</UButton>
|
|
166
|
+
</template>
|
|
167
|
+
|
|
168
|
+
<template v-else>
|
|
169
|
+
<UIcon name="i-lucide-plug-zap" class="mx-auto mb-3 h-10 w-10 text-indigo-400" />
|
|
170
|
+
<h1 class="mb-1 text-center text-lg font-semibold text-white">
|
|
171
|
+
{{ t('mcpAuthorize.title', { client: clientName }) }}
|
|
172
|
+
</h1>
|
|
173
|
+
<!-- The origin is the one fact here an attacker cannot choose: it was matched against what
|
|
174
|
+
the client registered before this screen was ever reached. The name beside it is a
|
|
175
|
+
stranger's own words, so the copy presents it as a claim rather than as identity.
|
|
176
|
+
The copy reads "It says it is {client}, and …" in every locale, so BOTH holes have to
|
|
177
|
+
be filled: an unpassed `client` renders a sentence naming nobody, on the one screen
|
|
178
|
+
whose whole subject is who is asking. -->
|
|
179
|
+
<p class="mb-6 text-center text-sm text-slate-400">
|
|
180
|
+
{{ t('mcpAuthorize.subtitle', { client: clientName, origin: redirectOrigin }) }}
|
|
181
|
+
</p>
|
|
182
|
+
|
|
183
|
+
<div v-if="!workspaces.length" class="mb-6 text-center text-sm text-amber-300">
|
|
184
|
+
{{ t('mcpAuthorize.noWorkspaces') }}
|
|
185
|
+
</div>
|
|
186
|
+
|
|
187
|
+
<template v-else>
|
|
188
|
+
<UFormField :label="t('mcpAuthorize.workspace.label')" class="mb-4">
|
|
189
|
+
<USelect
|
|
190
|
+
v-model="workspaceId"
|
|
191
|
+
:items="workspaces"
|
|
192
|
+
value-key="value"
|
|
193
|
+
class="w-full"
|
|
194
|
+
data-testid="mcp-authorize-workspace"
|
|
195
|
+
/>
|
|
196
|
+
</UFormField>
|
|
197
|
+
|
|
198
|
+
<UFormField
|
|
199
|
+
:label="t('mcpAuthorize.scopeLabel')"
|
|
200
|
+
:description="t('mcpAuthorize.scopeHint')"
|
|
201
|
+
:class="requestedScope ? 'mb-2' : 'mb-6'"
|
|
202
|
+
>
|
|
203
|
+
<URadioGroup
|
|
204
|
+
v-model="scope"
|
|
205
|
+
:items="scopeItems"
|
|
206
|
+
value-key="value"
|
|
207
|
+
data-testid="mcp-authorize-scope"
|
|
208
|
+
/>
|
|
209
|
+
</UFormField>
|
|
210
|
+
|
|
211
|
+
<!-- Only when the host asked for something other than what is preselected. Anyone can
|
|
212
|
+
register a client and ask for `admin`, so the ask is reported as a fact ABOUT the
|
|
213
|
+
host rather than acted on: raising the grant stays a thing a person does. -->
|
|
214
|
+
<p
|
|
215
|
+
v-if="requestedScope"
|
|
216
|
+
class="mb-6 text-xs text-amber-300"
|
|
217
|
+
data-testid="mcp-authorize-requested-scope"
|
|
218
|
+
>
|
|
219
|
+
{{
|
|
220
|
+
t('mcpAuthorize.requestedScope', {
|
|
221
|
+
client: clientName,
|
|
222
|
+
scope: t(`mcpAuthorize.scope.${requestedScope}.label`),
|
|
223
|
+
})
|
|
224
|
+
}}
|
|
225
|
+
</p>
|
|
226
|
+
</template>
|
|
227
|
+
|
|
228
|
+
<p
|
|
229
|
+
v-if="decisionError"
|
|
230
|
+
class="mb-4 text-center text-sm break-words text-red-400"
|
|
231
|
+
data-testid="mcp-authorize-decision-error"
|
|
232
|
+
>
|
|
233
|
+
{{ decisionError }}
|
|
234
|
+
</p>
|
|
235
|
+
|
|
236
|
+
<div class="flex gap-2">
|
|
237
|
+
<UButton
|
|
238
|
+
block
|
|
239
|
+
color="neutral"
|
|
240
|
+
variant="subtle"
|
|
241
|
+
:disabled="screen === 'submitting'"
|
|
242
|
+
@click="decide('deny')"
|
|
243
|
+
>
|
|
244
|
+
{{ t('mcpAuthorize.deny') }}
|
|
245
|
+
</UButton>
|
|
246
|
+
<UButton
|
|
247
|
+
block
|
|
248
|
+
color="primary"
|
|
249
|
+
:loading="screen === 'submitting'"
|
|
250
|
+
:disabled="!workspaceId"
|
|
251
|
+
data-testid="mcp-authorize-approve"
|
|
252
|
+
@click="decide('approve')"
|
|
253
|
+
>
|
|
254
|
+
{{ t('mcpAuthorize.approve') }}
|
|
255
|
+
</UButton>
|
|
256
|
+
</div>
|
|
257
|
+
|
|
258
|
+
<p class="mt-4 text-center text-xs text-slate-500">{{ t('mcpAuthorize.revokeHint') }}</p>
|
|
259
|
+
</template>
|
|
260
|
+
</div>
|
|
261
|
+
</div>
|
|
262
|
+
</template>
|
|
@@ -15,6 +15,7 @@ import { computed, ref, toRaw, watch } from 'vue'
|
|
|
15
15
|
import type { ConnectionTestResult } from '@cat-factory/contracts'
|
|
16
16
|
import type { ProviderConfigField, ProviderConnectionKind } from '~/types/providerConnections'
|
|
17
17
|
import ConnectionWarnings from '~/components/settings/ConnectionWarnings.vue'
|
|
18
|
+
import ConnectionTestVerdict from '~/components/settings/ConnectionTestVerdict.vue'
|
|
18
19
|
import ProvisioningLogsDrawer from '~/components/provisioning/ProvisioningLogsDrawer.vue'
|
|
19
20
|
import ProviderManifestEditor from '~/components/settings/ProviderManifestEditor.vue'
|
|
20
21
|
import KubernetesEnvironmentForm from '~/components/settings/KubernetesEnvironmentForm.vue'
|
|
@@ -498,7 +499,7 @@ function fieldHelp(key: string): string | undefined {
|
|
|
498
499
|
/>
|
|
499
500
|
</UFormField>
|
|
500
501
|
|
|
501
|
-
<div v-if="descriptor.supportsTest" class="
|
|
502
|
+
<div v-if="descriptor.supportsTest" class="space-y-1.5">
|
|
502
503
|
<UButton
|
|
503
504
|
color="neutral"
|
|
504
505
|
variant="soft"
|
|
@@ -509,12 +510,7 @@ function fieldHelp(key: string): string | undefined {
|
|
|
509
510
|
>
|
|
510
511
|
{{ t('settings.providerConnection.test.button') }}
|
|
511
512
|
</UButton>
|
|
512
|
-
<
|
|
513
|
-
{{ testResult.message ?? t('settings.providerConnection.test.ok') }}
|
|
514
|
-
</span>
|
|
515
|
-
<span v-else-if="testResult" class="text-xs text-rose-400">
|
|
516
|
-
{{ testResult.message ?? t('settings.providerConnection.test.failed') }}
|
|
517
|
-
</span>
|
|
513
|
+
<ConnectionTestVerdict :result="testResult" />
|
|
518
514
|
</div>
|
|
519
515
|
|
|
520
516
|
<ConnectionWarnings :warnings="testResult?.warnings" />
|
|
@@ -20,6 +20,7 @@ import { environmentManifestSchema, runnerPoolManifestSchema } from '@cat-factor
|
|
|
20
20
|
import type { ConnectionTestResult } from '@cat-factory/contracts'
|
|
21
21
|
import type { ProviderConnectionKind } from '~/types/providerConnections'
|
|
22
22
|
import ConnectionWarnings from '~/components/settings/ConnectionWarnings.vue'
|
|
23
|
+
import ConnectionTestVerdict from '~/components/settings/ConnectionTestVerdict.vue'
|
|
23
24
|
import SecretInput from '~/components/common/SecretInput.vue'
|
|
24
25
|
|
|
25
26
|
const props = defineProps<{
|
|
@@ -267,7 +268,7 @@ function onSave() {
|
|
|
267
268
|
</UFormField>
|
|
268
269
|
</div>
|
|
269
270
|
|
|
270
|
-
<div v-if="supportsTest" class="
|
|
271
|
+
<div v-if="supportsTest" class="space-y-1.5">
|
|
271
272
|
<UButton
|
|
272
273
|
color="neutral"
|
|
273
274
|
variant="soft"
|
|
@@ -280,12 +281,7 @@ function onSave() {
|
|
|
280
281
|
>
|
|
281
282
|
{{ t('settings.providerConnection.test.button') }}
|
|
282
283
|
</UButton>
|
|
283
|
-
<
|
|
284
|
-
{{ testResult.message ?? t('settings.providerConnection.test.ok') }}
|
|
285
|
-
</span>
|
|
286
|
-
<span v-else-if="testResult" class="text-xs text-rose-400">
|
|
287
|
-
{{ testResult.message ?? t('settings.providerConnection.test.failed') }}
|
|
288
|
-
</span>
|
|
284
|
+
<ConnectionTestVerdict :result="testResult" />
|
|
289
285
|
</div>
|
|
290
286
|
|
|
291
287
|
<ConnectionWarnings :warnings="testResult?.warnings" />
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import {
|
|
2
|
+
decideMcpAuthorizationContract,
|
|
3
|
+
describeMcpAuthorizationContract,
|
|
4
|
+
type McpAuthorizationDecision,
|
|
5
|
+
} from '@cat-factory/contracts'
|
|
6
|
+
import type { ApiContext } from './context'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The consent screen an MCP host's authorization request lands on.
|
|
10
|
+
*
|
|
11
|
+
* The mirror image of the tool-server OAuth calls beside it: those connect THIS deployment to
|
|
12
|
+
* someone else's MCP server, these let someone else's host connect to this one. Neither is
|
|
13
|
+
* workspace-prefixed, and for opposite reasons: there the board is sealed into the vendor's state,
|
|
14
|
+
* here it is what the person on the screen is choosing.
|
|
15
|
+
*
|
|
16
|
+
* Both are POSTs, the read included. The sealed request is a value the page carries rather than an
|
|
17
|
+
* id it looks up, and a query string would write it into browser history and every log in between.
|
|
18
|
+
*/
|
|
19
|
+
export function mcpAuthorizationApi({ send }: ApiContext) {
|
|
20
|
+
return {
|
|
21
|
+
describeMcpAuthorization: (request: string) =>
|
|
22
|
+
send(describeMcpAuthorizationContract, { body: { request } }),
|
|
23
|
+
|
|
24
|
+
// Answers with WHERE to send the browser, rather than redirecting: a 302 on a `fetch` is
|
|
25
|
+
// followed by the browser without this page seeing it, which would deliver the host's callback
|
|
26
|
+
// an XHR instead of the navigation it is waiting for.
|
|
27
|
+
decideMcpAuthorization: (body: McpAuthorizationDecision) =>
|
|
28
|
+
send(decideMcpAuthorizationContract, { body }),
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -35,6 +35,7 @@ import { modelsApi } from './api/models'
|
|
|
35
35
|
import { notificationsApi } from './api/notifications'
|
|
36
36
|
import { packageRegistriesApi } from './api/packageRegistries'
|
|
37
37
|
import { capabilityCredentialsApi } from './api/capabilityCredentials'
|
|
38
|
+
import { mcpAuthorizationApi } from './api/mcpAuthorization'
|
|
38
39
|
import { toolServersApi } from './api/toolServers'
|
|
39
40
|
import { preflightsApi } from './api/preflights'
|
|
40
41
|
import { presetsApi } from './api/presets'
|
|
@@ -161,6 +162,7 @@ export function useApi() {
|
|
|
161
162
|
...testSecretsApi(ctx),
|
|
162
163
|
...packageRegistriesApi(ctx),
|
|
163
164
|
...capabilityCredentialsApi(ctx),
|
|
165
|
+
...mcpAuthorizationApi(ctx),
|
|
164
166
|
...toolServersApi(ctx),
|
|
165
167
|
...previewApi(ctx),
|
|
166
168
|
...environmentsApi(ctx),
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { computed, type ComputedRef, type Ref } from 'vue'
|
|
2
|
+
import {
|
|
3
|
+
classifyServiceAccountToken,
|
|
4
|
+
isFatalServiceAccountTokenProblem,
|
|
5
|
+
type ServiceAccountTokenProblem,
|
|
6
|
+
} from '@cat-factory/contracts'
|
|
7
|
+
|
|
8
|
+
// Inline validation of a pasted Kubernetes ServiceAccount token, shared by the two kube connect
|
|
9
|
+
// forms so they cannot drift on what a bad paste is or on what to say about it.
|
|
10
|
+
//
|
|
11
|
+
// The rule itself is in `@cat-factory/contracts` because the backend enforces the same one (see
|
|
12
|
+
// `KubernetesApiClient`), and this is the SPA half of the split CLAUDE.md prescribes: the backend
|
|
13
|
+
// emits a machine-readable code, the SPA owns the translated prose. So the map below is the one
|
|
14
|
+
// place a problem code becomes copy.
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The message key per problem, as an exhaustive `Record`: a code added to the contract union fails
|
|
18
|
+
* the typecheck here until it has copy, rather than rendering as a silently missing hint. The keys
|
|
19
|
+
* are literals for the same reason they are elsewhere in the SPA (an assembled key is invisible to
|
|
20
|
+
* the typed-message-key check), and they sit under the shared `providerConnection` namespace
|
|
21
|
+
* because both the per-type engine form and the legacy single-connection form show them.
|
|
22
|
+
*/
|
|
23
|
+
const MESSAGE_KEYS: Record<ServiceAccountTokenProblem, string> = {
|
|
24
|
+
whitespace: 'settings.providerConnection.serviceAccountToken.whitespace',
|
|
25
|
+
'base64-encoded': 'settings.providerConnection.serviceAccountToken.base64Encoded',
|
|
26
|
+
'not-a-jwt': 'settings.providerConnection.serviceAccountToken.notAJwt',
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ServiceAccountTokenCheck {
|
|
30
|
+
/** The problem code, or null when the value looks fine (and when it is empty). */
|
|
31
|
+
problem: ComputedRef<ServiceAccountTokenProblem | null>
|
|
32
|
+
/**
|
|
33
|
+
* Whether the problem should BLOCK Test and Save. True only for the impossible case (whitespace
|
|
34
|
+
* inside the token), never for the merely-suspicious shapes: a `--token-auth-file` apiserver
|
|
35
|
+
* accepts an arbitrary static bearer token, and a check that cannot be sure must not be the
|
|
36
|
+
* thing that stops a legitimate cluster being configured.
|
|
37
|
+
*/
|
|
38
|
+
blocking: ComputedRef<boolean>
|
|
39
|
+
/** The translated hint to render under the field, or '' when there is nothing to say. */
|
|
40
|
+
message: ComputedRef<string>
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Classify the live value of a token field and render the verdict as translated copy. */
|
|
44
|
+
export function useServiceAccountTokenProblem(token: Ref<string>): ServiceAccountTokenCheck {
|
|
45
|
+
const { t } = useI18n()
|
|
46
|
+
const problem = computed(() => classifyServiceAccountToken(token.value))
|
|
47
|
+
return {
|
|
48
|
+
problem,
|
|
49
|
+
blocking: computed(() => !!problem.value && isFatalServiceAccountTokenProblem(problem.value)),
|
|
50
|
+
message: computed(() => (problem.value ? t(MESSAGE_KEYS[problem.value]) : '')),
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Ref } from 'vue'
|
|
2
2
|
import type { AuthUser } from '~/types/domain'
|
|
3
|
+
import { postSignInUrl } from '~/utils/postSignIn'
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Shared reactive state + injected dependencies the auth-store sign-in factory closes over.
|
|
@@ -24,9 +25,20 @@ export interface AuthSessionContext {
|
|
|
24
25
|
export function createAuthSessionActions(ctx: AuthSessionContext) {
|
|
25
26
|
const { api, apiBase, token, user, autoLoginProvider } = ctx
|
|
26
27
|
|
|
27
|
-
/**
|
|
28
|
+
/**
|
|
29
|
+
* Build a post-login redirect back to the current page, with an optional invite.
|
|
30
|
+
*
|
|
31
|
+
* "The current page" includes its QUERY STRING, through the same `postSignInUrl` the credential
|
|
32
|
+
* forms reload to, so the round-trip through an identity provider lands where the person started
|
|
33
|
+
* rather than one level up. A flow that carries its whole subject there (`/mcp-authorize?request=`)
|
|
34
|
+
* otherwise comes back from the IdP to a page that no longer knows what it was asked, and this is
|
|
35
|
+
* the path an SSO deployment takes for EVERY sign-in, not an unusual one.
|
|
36
|
+
*
|
|
37
|
+
* The `invite` is dropped from the returned-to URL by that helper and named as its own parameter
|
|
38
|
+
* here, which is the same token travelling as itself rather than twice.
|
|
39
|
+
*/
|
|
28
40
|
function redirectTarget(invite?: string): string {
|
|
29
|
-
const here = window.location.origin + window.location
|
|
41
|
+
const here = window.location.origin + postSignInUrl(window.location)
|
|
30
42
|
const params = new URLSearchParams({ redirect: here })
|
|
31
43
|
if (invite) params.set('invite', invite)
|
|
32
44
|
return params.toString()
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest'
|
|
2
|
+
import { createUiModals } from '~/stores/ui/modals'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The `cat-factory k3s` CLI hand-off (`?infraSetup=local-k3s&…`), driven through the modals slice
|
|
6
|
+
* directly (plain refs/functions, no Pinia) exactly as the overlay-host slice tests do.
|
|
7
|
+
*
|
|
8
|
+
* What is worth pinning here is the ARRIVAL, not the parsing: the CLI's whole promise is that the
|
|
9
|
+
* operator lands on the one form it just filled in, and both halves of that (the tab AND the
|
|
10
|
+
* section anchor within it) are set in this one function. The prefill is asserted alongside
|
|
11
|
+
* because the deep link is the only thing that ever sets it.
|
|
12
|
+
*/
|
|
13
|
+
function openWith(search: string): void {
|
|
14
|
+
window.history.replaceState(null, '', `/${search}`)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const K3S_LINK =
|
|
18
|
+
'?infraSetup=local-k3s&label=Local+k3s&apiServerUrl=https%3A%2F%2F127.0.0.1%3A6443' +
|
|
19
|
+
'&namespaceTemplate=cf-env-%7B%7BpullNumber%7D%7D&hostTemplate=%7B%7Bbranch%7D%7D.127.0.0.1.nip.io' +
|
|
20
|
+
'&insecureSkipTlsVerify=1'
|
|
21
|
+
|
|
22
|
+
describe('consumeK3sSetupDeepLink', () => {
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
openWith('')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('opens the Test-environments tab ANCHORED on the Kubernetes section', () => {
|
|
28
|
+
const ui = createUiModals()
|
|
29
|
+
openWith(K3S_LINK)
|
|
30
|
+
ui.consumeK3sSetupDeepLink()
|
|
31
|
+
|
|
32
|
+
expect(ui.infrastructureOpen.value).toBe(true)
|
|
33
|
+
expect(ui.infrastructureTab.value).toBe('environment')
|
|
34
|
+
// The tab opens on the default-provision picker, so without this the operator lands above
|
|
35
|
+
// the form the CLI just described and has to scroll to find it.
|
|
36
|
+
expect(ui.infrastructureScrollTarget.value).toBe('kubernetes')
|
|
37
|
+
expect(ui.k3sSetupPrefill.value).toEqual({
|
|
38
|
+
label: 'Local k3s',
|
|
39
|
+
apiServerUrl: 'https://127.0.0.1:6443',
|
|
40
|
+
namespaceTemplate: 'cf-env-{{pullNumber}}',
|
|
41
|
+
hostTemplate: '{{branch}}.127.0.0.1.nip.io',
|
|
42
|
+
insecureSkipTlsVerify: true,
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('strips the params so a reload neither re-opens the window nor re-anchors it', () => {
|
|
47
|
+
const ui = createUiModals()
|
|
48
|
+
openWith(K3S_LINK)
|
|
49
|
+
ui.consumeK3sSetupDeepLink()
|
|
50
|
+
expect(window.location.search).toBe('')
|
|
51
|
+
|
|
52
|
+
const reloaded = createUiModals()
|
|
53
|
+
reloaded.consumeK3sSetupDeepLink()
|
|
54
|
+
expect(reloaded.infrastructureOpen.value).toBe(false)
|
|
55
|
+
expect(reloaded.infrastructureScrollTarget.value).toBeNull()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('drops an UNCONSUMED anchor on close, so the next plain open does not scroll', () => {
|
|
59
|
+
// The panel clears the target once it has scrolled. Closing before it rendered (the window
|
|
60
|
+
// was dismissed, or the infra probe never resolved) must not leave the anchor armed.
|
|
61
|
+
const ui = createUiModals()
|
|
62
|
+
openWith(K3S_LINK)
|
|
63
|
+
ui.consumeK3sSetupDeepLink()
|
|
64
|
+
ui.closeProviderConnection()
|
|
65
|
+
|
|
66
|
+
expect(ui.infrastructureScrollTarget.value).toBeNull()
|
|
67
|
+
expect(ui.k3sSetupPrefill.value).toBeNull()
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('is a no-op for an unrelated query string', () => {
|
|
71
|
+
const ui = createUiModals()
|
|
72
|
+
openWith('?settings=default-test-env')
|
|
73
|
+
ui.consumeK3sSetupDeepLink()
|
|
74
|
+
|
|
75
|
+
expect(ui.infrastructureOpen.value).toBe(false)
|
|
76
|
+
expect(ui.infrastructureScrollTarget.value).toBeNull()
|
|
77
|
+
expect(window.location.search).toBe('?settings=default-test-env')
|
|
78
|
+
})
|
|
79
|
+
})
|
package/app/stores/ui/modals.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { ref } from 'vue'
|
|
2
2
|
import type { DocumentSourceKind, InfraSetupArea, TaskSourceKind } from '~/types/domain'
|
|
3
|
-
import type {
|
|
3
|
+
import type {
|
|
4
|
+
InfrastructureScrollTarget,
|
|
5
|
+
InfrastructureTab,
|
|
6
|
+
ProviderConnectionKind,
|
|
7
|
+
} from '~/types/providerConnections'
|
|
4
8
|
import type { PendingContext } from '~/composables/useContextLinking'
|
|
5
9
|
import {
|
|
6
10
|
infraSetupDismissalKey,
|
|
@@ -737,6 +741,13 @@ function createInfraModals(resetHubReturn: ResetHubReturn) {
|
|
|
737
741
|
// `local-k3s` connection from it; the ServiceAccount token is deliberately NOT in the link (a
|
|
738
742
|
// secret in a URL leaks into history/logs), so the user still pastes it before Test → Save.
|
|
739
743
|
const k3sSetupPrefill = ref<K3sSetupPrefill | null>(null)
|
|
744
|
+
// A one-shot deep-link anchor into a SECTION of the open tab, mirroring
|
|
745
|
+
// `accountSettingsScrollTarget`. The Test-environments tab opens on the default-provision
|
|
746
|
+
// picker and the Compose wizard, with the per-type handler sections between them, so landing an
|
|
747
|
+
// operator at the top of it after a `cat-factory k3s` hand-off leaves them scrolling to find the
|
|
748
|
+
// very form the CLI just filled in. The owning panel scrolls the section into view once and
|
|
749
|
+
// then calls `clearInfrastructureScrollTarget`, so a later plain open doesn't re-scroll.
|
|
750
|
+
const infrastructureScrollTarget = ref<InfrastructureScrollTarget | null>(null)
|
|
740
751
|
// Environment setup wizard (shared-stacks slice 7): the guided detect → review → preflight →
|
|
741
752
|
// trial → save flow for a service frame's `docker-compose` provisioning. `environmentWizardOpen`
|
|
742
753
|
// is the modal flag; `environmentWizardFrameId` preselects the service frame the flow targets
|
|
@@ -761,8 +772,14 @@ function createInfraModals(resetHubReturn: ResetHubReturn) {
|
|
|
761
772
|
}
|
|
762
773
|
function closeProviderConnection() {
|
|
763
774
|
infrastructureOpen.value = false
|
|
764
|
-
// Drop any consumed CLI prefill so re-opening the window normally doesn't re-seed the form
|
|
775
|
+
// Drop any consumed CLI prefill so re-opening the window normally doesn't re-seed the form,
|
|
776
|
+
// and the anchor with it: an unconsumed target (the window was closed before the section
|
|
777
|
+
// rendered) would otherwise scroll the next, unrelated open.
|
|
765
778
|
k3sSetupPrefill.value = null
|
|
779
|
+
infrastructureScrollTarget.value = null
|
|
780
|
+
}
|
|
781
|
+
function clearInfrastructureScrollTarget() {
|
|
782
|
+
infrastructureScrollTarget.value = null
|
|
766
783
|
}
|
|
767
784
|
// Capture a `cat-factory k3s` deep-link (`?infraSetup=local-k3s&…`) on app load: stash the
|
|
768
785
|
// non-secret connection values, open the Infrastructure window on the Test-environments tab so
|
|
@@ -786,6 +803,10 @@ function createInfraModals(resetHubReturn: ResetHubReturn) {
|
|
|
786
803
|
}
|
|
787
804
|
resetHubReturn()
|
|
788
805
|
infrastructureTab.value = 'environment'
|
|
806
|
+
// The hand-off is about ONE form, so land on it: the Kubernetes section sits below the
|
|
807
|
+
// default-provision picker, far enough down the tab that an operator arriving from the CLI
|
|
808
|
+
// would otherwise have to go looking for the fields it just told them about.
|
|
809
|
+
infrastructureScrollTarget.value = 'kubernetes'
|
|
789
810
|
infrastructureOpen.value = true
|
|
790
811
|
for (const key of [
|
|
791
812
|
'infraSetup',
|
|
@@ -840,6 +861,8 @@ function createInfraModals(resetHubReturn: ResetHubReturn) {
|
|
|
840
861
|
infrastructureTab,
|
|
841
862
|
openInfrastructure,
|
|
842
863
|
k3sSetupPrefill,
|
|
864
|
+
infrastructureScrollTarget,
|
|
865
|
+
clearInfrastructureScrollTarget,
|
|
843
866
|
consumeK3sSetupDeepLink,
|
|
844
867
|
environmentWizardOpen,
|
|
845
868
|
environmentWizardFrameId,
|
|
@@ -33,6 +33,15 @@ export type InfrastructureTab =
|
|
|
33
33
|
| 'package-registries'
|
|
34
34
|
| 'capability-credentials'
|
|
35
35
|
|
|
36
|
+
/**
|
|
37
|
+
* A SECTION within an Infrastructure tab that a deep link can land the user on, rather than at
|
|
38
|
+
* the top of the tab with the section to hunt for. A closed union rather than a bare string, so
|
|
39
|
+
* the store's setter and the panel that honours it cannot drift apart silently: today's only
|
|
40
|
+
* member is the `kubernetes` provision-type section the `cat-factory k3s` hand-off targets, which
|
|
41
|
+
* sits below the default-provision picker in a tab long enough to need scrolling.
|
|
42
|
+
*/
|
|
43
|
+
export type InfrastructureScrollTarget = 'kubernetes'
|
|
44
|
+
|
|
36
45
|
/** A workspace's provider binding, as exposed to clients (never secret values). */
|
|
37
46
|
export interface ProviderConnection {
|
|
38
47
|
/** The runner-backend kind for a runner-pool connection (`manifest` | `kubernetes`). */
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { ConnectionFailureCause } from '@cat-factory/contracts'
|
|
2
|
+
|
|
3
|
+
// A connection test that never got an ANSWER reports the transport failure CLASS as a
|
|
4
|
+
// machine-readable `failureCause` (the backend does not localize prose), and the copy the operator
|
|
5
|
+
// reads lives here. The backend's own English account of the failure, including the remedy it can
|
|
6
|
+
// phrase with the concrete host in it, stays beside the headline as the technical detail.
|
|
7
|
+
//
|
|
8
|
+
// The exhaustive `Record<ConnectionFailureCause, …>` is the tier-2 drift guard, as in
|
|
9
|
+
// `connectionWarnings.ts`: a backend that adds a cause fails this typecheck until the SPA has copy
|
|
10
|
+
// for it, which the typed-key check cannot catch for a runtime-assembled key.
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Failure class → i18n key, or `null` where there is deliberately no headline to render.
|
|
14
|
+
*
|
|
15
|
+
* `unknown` is that case, and it is the reason the values are nullable: the chain was read and
|
|
16
|
+
* matched nothing, so the only honest statement about it is the backend's verbatim account, which
|
|
17
|
+
* is then rendered as the primary line instead of a headline that would have to invent a class.
|
|
18
|
+
*/
|
|
19
|
+
export const CONNECTION_FAILURE_CAUSE_KEYS: Record<ConnectionFailureCause, string | null> = {
|
|
20
|
+
refused: 'settings.providerConnection.test.causes.refused',
|
|
21
|
+
dns: 'settings.providerConnection.test.causes.dns',
|
|
22
|
+
timeout: 'settings.providerConnection.test.causes.timeout',
|
|
23
|
+
aborted: 'settings.providerConnection.test.causes.aborted',
|
|
24
|
+
unreachable: 'settings.providerConnection.test.causes.unreachable',
|
|
25
|
+
reset: 'settings.providerConnection.test.causes.reset',
|
|
26
|
+
'tls-untrusted': 'settings.providerConnection.test.causes.tlsUntrusted',
|
|
27
|
+
'tls-expired': 'settings.providerConnection.test.causes.tlsExpired',
|
|
28
|
+
'tls-hostname': 'settings.providerConnection.test.causes.tlsHostname',
|
|
29
|
+
'tls-protocol': 'settings.providerConnection.test.causes.tlsProtocol',
|
|
30
|
+
'invalid-header': 'settings.providerConnection.test.causes.invalidHeader',
|
|
31
|
+
unknown: null,
|
|
32
|
+
}
|