@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.
Files changed (32) hide show
  1. package/app/components/auth/LoginScreen.vue +4 -3
  2. package/app/components/settings/ConnectionTestVerdict.vue +62 -0
  3. package/app/components/settings/InfraHandlersConfigurator.logic.spec.ts +64 -0
  4. package/app/components/settings/InfraHandlersConfigurator.logic.ts +41 -0
  5. package/app/components/settings/InfraHandlersConfigurator.vue +36 -9
  6. package/app/components/settings/KubernetesEngineForm.vue +24 -7
  7. package/app/components/settings/KubernetesEnvironmentForm.vue +20 -7
  8. package/app/components/settings/McpAuthorizeScreen.vue +262 -0
  9. package/app/components/settings/ProviderConnectionTab.vue +3 -7
  10. package/app/components/settings/ProviderManifestEditor.vue +3 -7
  11. package/app/composables/api/mcpAuthorization.ts +30 -0
  12. package/app/composables/useApi.ts +2 -0
  13. package/app/composables/useServiceAccountTokenProblem.ts +52 -0
  14. package/app/pages/mcp-authorize.vue +7 -0
  15. package/app/stores/auth/session.ts +14 -2
  16. package/app/stores/ui/k3sDeepLink.spec.ts +79 -0
  17. package/app/stores/ui/modals.ts +25 -2
  18. package/app/types/providerConnections.ts +9 -0
  19. package/app/utils/connectionFailures.ts +32 -0
  20. package/app/utils/postSignIn.spec.ts +29 -0
  21. package/app/utils/postSignIn.ts +29 -0
  22. package/i18n/locales/de.json +57 -0
  23. package/i18n/locales/en.json +57 -0
  24. package/i18n/locales/es.json +57 -0
  25. package/i18n/locales/fr.json +57 -0
  26. package/i18n/locales/he.json +57 -0
  27. package/i18n/locales/it.json +57 -0
  28. package/i18n/locales/ja.json +57 -0
  29. package/i18n/locales/pl.json +57 -0
  30. package/i18n/locales/tr.json +57 -0
  31. package/i18n/locales/uk.json +57 -0
  32. package/package.json +2 -2
@@ -5,6 +5,7 @@ import SecretInput from '~/components/common/SecretInput.vue'
5
5
  import type { VcsProvider } from '~/types/domain'
6
6
  import { VCS_PROVIDER_ICONS, VCS_PROVIDER_LABELS, vcsTokenCreateUrl } from '~/utils/vcs'
7
7
  import { SSO_ERROR_MESSAGE_KEYS } from '~/utils/sso'
8
+ import { postSignInUrl } from '~/utils/postSignIn'
8
9
 
9
10
  const auth = useAuthStore()
10
11
  const { t } = useI18n()
@@ -62,7 +63,7 @@ async function submitPat(provider: PatProvider) {
62
63
  patBusy.value = true
63
64
  try {
64
65
  await auth.patLogin({ provider })
65
- if (typeof window !== 'undefined') window.location.assign(window.location.pathname)
66
+ if (typeof window !== 'undefined') window.location.assign(postSignInUrl(window.location))
66
67
  } catch (e) {
67
68
  patError.value = apiErrorEnvelope(e)?.message ?? t('auth.localMode.failed')
68
69
  } finally {
@@ -105,7 +106,7 @@ async function submitPassword() {
105
106
  await auth.passwordLogin({ email: email.value, password: password.value })
106
107
  }
107
108
  // Reload so the app boots with the new session.
108
- if (typeof window !== 'undefined') window.location.assign(window.location.pathname)
109
+ if (typeof window !== 'undefined') window.location.assign(postSignInUrl(window.location))
109
110
  } catch (e) {
110
111
  error.value = apiErrorEnvelope(e)?.message ?? t('auth.login.signInFailed')
111
112
  } finally {
@@ -177,7 +178,7 @@ async function submitRemotePat() {
177
178
  remotePatBusy.value = true
178
179
  try {
179
180
  await auth.patLogin({ provider: remotePatProvider.value, token: remotePatToken.value.trim() })
180
- if (typeof window !== 'undefined') window.location.assign(window.location.pathname)
181
+ if (typeof window !== 'undefined') window.location.assign(postSignInUrl(window.location))
181
182
  } catch (e) {
182
183
  remotePatError.value = apiErrorEnvelope(e)?.message ?? t('auth.login.signInFailed')
183
184
  } finally {
@@ -0,0 +1,62 @@
1
+ <script setup lang="ts">
2
+ // The verdict of one "Test connection" probe, rendered identically everywhere a connect form
3
+ // offers that button.
4
+ //
5
+ // Its own component because the message stopped being a few words. A failed probe now reports
6
+ // the EXACT transport failure plus what to do about it (kernel's `connectionFailureResult`),
7
+ // which runs to a sentence or three, and the inline `<span>` each form used to carry sat inside
8
+ // a `flex items-center` row beside the button, where a long message squashes the button and
9
+ // overflows the panel. So the result is a block that wraps, and the six forms share it rather
10
+ // than each growing its own copy of the same markup.
11
+ //
12
+ // Two lines, because that account is English by construction and this SPA ships in ten languages.
13
+ // The backend states the failure CLASS as a machine-readable `failureCause`; the headline is that
14
+ // class in the operator's own language, and the backend's prose sits under it as the detail. The
15
+ // detail stays VISIBLE rather than folding behind a disclosure: it is the half that names the
16
+ // concrete host, port and remedy, and a probe verdict is read to find out what to go fix.
17
+ import { computed } from 'vue'
18
+ import type { ConnectionFailureCause } from '@cat-factory/contracts'
19
+ import { CONNECTION_FAILURE_CAUSE_KEYS } from '~/utils/connectionFailures'
20
+
21
+ const props = defineProps<{
22
+ /** The probe verdict; null before the first test (renders nothing). */
23
+ result: { ok: boolean; message?: string; failureCause?: ConnectionFailureCause } | null
24
+ }>()
25
+
26
+ const { t, te } = useI18n()
27
+
28
+ /**
29
+ * The translated-headline key for this failure, or null when there is none: an `unknown` cause, a
30
+ * cause this SPA build predates, or a failure that was an ANSWER (an HTTP status the provider
31
+ * mapped itself, which carries no transport cause). In every one of those the backend's own
32
+ * message becomes the primary line, so a missing translation is never a blank verdict.
33
+ */
34
+ const causeKey = computed(() => {
35
+ const cause = props.result && !props.result.ok ? props.result.failureCause : undefined
36
+ const key = cause ? CONNECTION_FAILURE_CAUSE_KEYS[cause] : null
37
+ return key && te(key) ? key : null
38
+ })
39
+
40
+ const headline = computed(() =>
41
+ causeKey.value
42
+ ? t(causeKey.value)
43
+ : (props.result?.message ?? t('settings.providerConnection.test.failed')),
44
+ )
45
+
46
+ /** The backend's English account, shown only when a headline took the primary line from it. */
47
+ const detail = computed(() => (causeKey.value ? (props.result?.message ?? '') : ''))
48
+ </script>
49
+
50
+ <template>
51
+ <p
52
+ v-if="result?.ok"
53
+ class="text-xs text-emerald-400 break-words"
54
+ data-testid="connection-test-result"
55
+ >
56
+ {{ result.message ?? t('settings.providerConnection.test.ok') }}
57
+ </p>
58
+ <div v-else-if="result" class="space-y-0.5" data-testid="connection-test-result">
59
+ <p class="text-xs text-rose-400 break-words">{{ headline }}</p>
60
+ <p v-if="detail" class="text-[11px] text-slate-400 break-words">{{ detail }}</p>
61
+ </div>
62
+ </template>
@@ -0,0 +1,64 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ consumeKubernetesScrollAnchor,
4
+ type ScrollableSection,
5
+ } from '~/components/settings/InfraHandlersConfigurator.logic'
6
+
7
+ /**
8
+ * The `cat-factory k3s` hand-off promises the operator lands on the ONE form the CLI just filled
9
+ * in. `k3sDeepLink.spec.ts` pins that the link arms the anchor; this pins the other half, that the
10
+ * panel consumes it exactly once and only when there is something to scroll to.
11
+ */
12
+ function recorder(): ScrollableSection & { calls: ScrollIntoViewOptions[] } {
13
+ const calls: ScrollIntoViewOptions[] = []
14
+ return { calls, scrollIntoView: (options) => calls.push(options) }
15
+ }
16
+
17
+ describe('consumeKubernetesScrollAnchor', () => {
18
+ it('scrolls the section into view once the panel and the section are both there', () => {
19
+ const section = recorder()
20
+ const outcome = consumeKubernetesScrollAnchor({
21
+ target: 'kubernetes',
22
+ available: true,
23
+ section,
24
+ })
25
+
26
+ expect(outcome).toBe('scrolled')
27
+ expect(section.calls).toEqual([{ behavior: 'smooth', block: 'start' }])
28
+ })
29
+
30
+ it('does nothing when no anchor is pending, so a plain open never jumps', () => {
31
+ const section = recorder()
32
+ expect(consumeKubernetesScrollAnchor({ target: null, available: true, section })).toBe(
33
+ 'not-anchored',
34
+ )
35
+ expect(section.calls).toEqual([])
36
+ })
37
+
38
+ it('does nothing while the infra probe is still resolving', () => {
39
+ // The whole configurator is behind `v-if="infra.available === true"`, and that probe resolves
40
+ // AFTER the deep link fires: the section cannot be scrolled to before it exists.
41
+ const section = recorder()
42
+ expect(consumeKubernetesScrollAnchor({ target: 'kubernetes', available: null, section })).toBe(
43
+ 'not-anchored',
44
+ )
45
+ expect(section.calls).toEqual([])
46
+ })
47
+
48
+ it('leaves the anchor for the next attempt when the section has not rendered yet', () => {
49
+ // `not-rendered` is what keeps the hand-off alive across the render the probe gates: only
50
+ // `scrolled` tells the caller to clear the store's target, so a miss cannot swallow the link.
51
+ const first = consumeKubernetesScrollAnchor({
52
+ target: 'kubernetes',
53
+ available: true,
54
+ section: null,
55
+ })
56
+ expect(first).toBe('not-rendered')
57
+
58
+ const section = recorder()
59
+ expect(consumeKubernetesScrollAnchor({ target: 'kubernetes', available: true, section })).toBe(
60
+ 'scrolled',
61
+ )
62
+ expect(section.calls).toHaveLength(1)
63
+ })
64
+ })
@@ -0,0 +1,41 @@
1
+ import type { InfrastructureScrollTarget } from '~/types/providerConnections'
2
+
3
+ // The deep-link SECTION anchor the `cat-factory k3s` hand-off lands on, as a plain function so the
4
+ // rule is testable: the component half is a `watch` plus an `onMounted` retry, and neither is
5
+ // reachable from a spec (this SPA has no component-mounting harness).
6
+ //
7
+ // The rule that needed pinning is the three-way outcome, not the scroll. The anchor is one-shot and
8
+ // is consumed only once the section is actually IN the DOM, but the section renders behind an async
9
+ // probe, so an attempt that finds nothing must leave the anchor ARMED for the next attempt rather
10
+ // than clear it. Clearing on a miss silently swallows the hand-off on a slow probe; never clearing
11
+ // leaves a dead anchor, which is why "scrolled" is the only outcome the caller acts on and why
12
+ // closing the window drops whatever is left (`closeProviderConnection`).
13
+
14
+ /** The minimum of an element this needs, so a spec can pass a recorder instead of a DOM node. */
15
+ export interface ScrollableSection {
16
+ scrollIntoView: (options: ScrollIntoViewOptions) => void
17
+ }
18
+
19
+ export interface ScrollAnchorAttempt {
20
+ /** The store's pending anchor, or null when there is nothing to land on. */
21
+ target: InfrastructureScrollTarget | null
22
+ /** The infra-probe gate the sections render behind; null while it is still resolving. */
23
+ available: boolean | null
24
+ /** The section element, or null when it has not rendered yet. */
25
+ section: ScrollableSection | null
26
+ }
27
+
28
+ /**
29
+ * - `scrolled`: the anchor was honoured and the CALLER must now clear it.
30
+ * - `not-anchored`: nothing is pending for this section (or the panel is not showing yet).
31
+ * - `not-rendered`: it IS pending, but the section is not in the DOM, so the anchor stays armed.
32
+ */
33
+ export type ScrollAnchorOutcome = 'scrolled' | 'not-anchored' | 'not-rendered'
34
+
35
+ /** Honour a pending Kubernetes-section anchor, reporting which of the three cases this was. */
36
+ export function consumeKubernetesScrollAnchor(attempt: ScrollAnchorAttempt): ScrollAnchorOutcome {
37
+ if (attempt.target !== 'kubernetes' || attempt.available !== true) return 'not-anchored'
38
+ if (!attempt.section) return 'not-rendered'
39
+ attempt.section.scrollIntoView({ behavior: 'smooth', block: 'start' })
40
+ return 'scrolled'
41
+ }
@@ -12,7 +12,7 @@
12
12
  // per custom type (matched to a service's pinned `manifestId`).
13
13
  // In LOCAL mode each handler additionally offers a per-USER override (this-machine only),
14
14
  // written to the `/me/environment-handlers` endpoints. Drives the infraConfig store.
15
- import { computed, ref, watch } from 'vue'
15
+ import { computed, nextTick, onMounted, ref, watch } from 'vue'
16
16
  import type {
17
17
  CustomManifestType,
18
18
  EnvironmentHandlerView,
@@ -28,6 +28,8 @@ import KubernetesEngineForm from '~/components/settings/KubernetesEngineForm.vue
28
28
  import ProviderManifestEditor from '~/components/settings/ProviderManifestEditor.vue'
29
29
  import CustomManifestTypeEditor from '~/components/settings/CustomManifestTypeEditor.vue'
30
30
  import CloudflareHandlerSection from '~/components/settings/CloudflareHandlerSection.vue'
31
+ import ConnectionTestVerdict from '~/components/settings/ConnectionTestVerdict.vue'
32
+ import { consumeKubernetesScrollAnchor } from '~/components/settings/InfraHandlersConfigurator.logic'
31
33
 
32
34
  const { t } = useI18n()
33
35
  const infra = useInfraConfigStore()
@@ -94,6 +96,32 @@ watch(
94
96
  { immediate: true },
95
97
  )
96
98
 
99
+ // Deep-link anchor: the `cat-factory k3s` hand-off opens this window with the ui store's scroll
100
+ // target set to `kubernetes`, so bring that section into view once rather than dropping the
101
+ // operator at the top of the tab to hunt for the form the CLI just described.
102
+ //
103
+ // Attempted from BOTH the watch and `onMounted`, and the anchor is cleared only on a real scroll:
104
+ // the section is behind `v-if="infra.available === true"`, whose probe resolves after the deep link
105
+ // fires, so a single attempt that finds nothing rendered would swallow the hand-off with neither
106
+ // watched value ever changing again to re-drive it. The decision itself is in the logic module,
107
+ // where it is tested.
108
+ const kubeSection = ref<HTMLElement | null>(null)
109
+ async function anchorKubernetesSection() {
110
+ await nextTick()
111
+ const outcome = consumeKubernetesScrollAnchor({
112
+ target: ui.infrastructureScrollTarget,
113
+ available: infra.available,
114
+ section: kubeSection.value,
115
+ })
116
+ if (outcome === 'scrolled') ui.clearInfrastructureScrollTarget()
117
+ }
118
+ watch([() => ui.infrastructureScrollTarget, () => infra.available], () => {
119
+ void anchorKubernetesSection()
120
+ })
121
+ onMounted(() => {
122
+ void anchorKubernetesSection()
123
+ })
124
+
97
125
  const busy = ref(false)
98
126
 
99
127
  // Connection-probe state for the kube engine forms (workspace + per-user override kept
@@ -400,7 +428,11 @@ function notifyError(e: unknown) {
400
428
  <p class="text-xs text-slate-400">{{ t('settings.infrastructure.handler.intro') }}</p>
401
429
 
402
430
  <!-- kubernetes -->
403
- <section class="space-y-2 rounded-lg border border-slate-700 bg-slate-900/40 p-3">
431
+ <section
432
+ ref="kubeSection"
433
+ class="space-y-2 rounded-lg border border-slate-700 bg-slate-900/40 p-3"
434
+ data-testid="infra-kubernetes-section"
435
+ >
404
436
  <h3 class="text-sm font-semibold text-slate-200">
405
437
  {{ t('inspector.testConfig.provisionTypes.kubernetes') }}
406
438
  </h3>
@@ -433,7 +465,7 @@ function notifyError(e: unknown) {
433
465
  {{ t('settings.infrastructure.handler.activeEngine') }}
434
466
  <span class="text-slate-200">{{ kubeHandlerEngineLabel }}</span>
435
467
  </p>
436
- <div class="flex items-center gap-2 pl-7">
468
+ <div class="space-y-1.5 pl-7">
437
469
  <UButton
438
470
  color="neutral"
439
471
  variant="soft"
@@ -444,12 +476,7 @@ function notifyError(e: unknown) {
444
476
  >
445
477
  {{ t('settings.providerConnection.test.button') }}
446
478
  </UButton>
447
- <span v-if="kubeSavedTestResult?.ok" class="text-xs text-emerald-400">
448
- {{ kubeSavedTestResult.message ?? t('settings.providerConnection.test.ok') }}
449
- </span>
450
- <span v-else-if="kubeSavedTestResult" class="text-xs text-rose-400">
451
- {{ kubeSavedTestResult.message ?? t('settings.providerConnection.test.failed') }}
452
- </span>
479
+ <ConnectionTestVerdict :result="kubeSavedTestResult" />
453
480
  </div>
454
481
  </div>
455
482
  <p v-else class="flex items-center gap-1.5 text-[12px] text-slate-500">
@@ -9,6 +9,7 @@
9
9
  import { computed, reactive, ref, watch } from 'vue'
10
10
  import { KUBERNETES_ENV_TOKEN_SECRET_KEY } from '@cat-factory/contracts'
11
11
  import SecretInput from '~/components/common/SecretInput.vue'
12
+ import ConnectionTestVerdict from '~/components/settings/ConnectionTestVerdict.vue'
12
13
  import type {
13
14
  EnvironmentHandlerView,
14
15
  InfraEngine,
@@ -75,6 +76,13 @@ const form = reactive({
75
76
  urlScheme: 'default' as 'default' | 'http' | 'https',
76
77
  })
77
78
  const apiToken = ref('')
79
+ // Flag a bad paste ON THE FIELD, before Test is ever clicked. The guided CLI flow ends with
80
+ // "copy this token out of your terminal", and a terminal wraps: a token copied across that wrap
81
+ // carries an invisible newline that survives `.trim()` and can never become an `authorization`
82
+ // header. Left to the probe it comes back as an opaque transport failure minutes later.
83
+ // Destructured at the top level so the template auto-unwraps the refs (a ref nested in a plain
84
+ // object is not unwrapped in a template, only a top-level one is).
85
+ const { blocking: tokenBlocking, message: tokenProblem } = useServiceAccountTokenProblem(apiToken)
78
86
 
79
87
  const urlSourceItems = computed(() => [
80
88
  {
@@ -210,6 +218,7 @@ const canSave = computed(
210
218
  !!form.label.trim() &&
211
219
  !!form.apiServerUrl.trim() &&
212
220
  (tokenStored.value || !!apiToken.value.trim()) &&
221
+ !tokenBlocking.value &&
213
222
  urlValid.value,
214
223
  )
215
224
 
@@ -232,6 +241,9 @@ const connectBlockedReason = computed(() => {
232
241
  missing.push(t('settings.infrastructure.kubernetesEngine.serviceName'))
233
242
  if (missing.length)
234
243
  return t('settings.providerConnection.form.missingFields', { fields: missing.join(', ') })
244
+ // Repeated from under the token field, so the disabled button is never left unexplained for a
245
+ // reader whose eye is on it rather than on the field above.
246
+ if (tokenBlocking.value) return tokenProblem.value
235
247
  return t('settings.infrastructure.kubernetesEngine.invalidPort')
236
248
  })
237
249
 
@@ -414,6 +426,16 @@ async function copyAutoSetupCommand() {
414
426
  : undefined
415
427
  "
416
428
  />
429
+ <!-- Rose when the paste is impossible (blocks Test/Save), amber when it is only suspicious
430
+ and the operator may legitimately overrule it. -->
431
+ <p
432
+ v-if="tokenProblem"
433
+ class="mt-1 text-[11px]"
434
+ :class="tokenBlocking ? 'text-rose-400' : 'text-amber-400'"
435
+ data-testid="service-account-token-problem"
436
+ >
437
+ {{ tokenProblem }}
438
+ </p>
417
439
  </UFormField>
418
440
 
419
441
  <!-- URL derivation: how the live environment URL is resolved once the service's
@@ -517,7 +539,7 @@ async function copyAutoSetupCommand() {
517
539
  />
518
540
  </UFormField>
519
541
 
520
- <div v-if="supportsTest" class="flex items-center gap-2">
542
+ <div v-if="supportsTest" class="space-y-1.5">
521
543
  <UButton
522
544
  color="neutral"
523
545
  variant="soft"
@@ -529,12 +551,7 @@ async function copyAutoSetupCommand() {
529
551
  >
530
552
  {{ t('settings.providerConnection.test.button') }}
531
553
  </UButton>
532
- <span v-if="testResult && testResult.ok" class="text-xs text-emerald-400">
533
- {{ testResult.message ?? t('settings.providerConnection.test.ok') }}
534
- </span>
535
- <span v-else-if="testResult" class="text-xs text-rose-400">
536
- {{ testResult.message ?? t('settings.providerConnection.test.failed') }}
537
- </span>
554
+ <ConnectionTestVerdict :result="testResult" />
538
555
  </div>
539
556
 
540
557
  <div class="flex items-center justify-end gap-3">
@@ -12,6 +12,7 @@ import { computed, reactive, ref, watch } from 'vue'
12
12
  import { KUBERNETES_ENV_TOKEN_SECRET_KEY } from '@cat-factory/contracts'
13
13
  import type { ConnectionTestResult } from '@cat-factory/contracts'
14
14
  import ConnectionWarnings from '~/components/settings/ConnectionWarnings.vue'
15
+ import ConnectionTestVerdict from '~/components/settings/ConnectionTestVerdict.vue'
15
16
  import SecretInput from '~/components/common/SecretInput.vue'
16
17
  import type { ProviderConnection } from '~/types/providerConnections'
17
18
 
@@ -54,6 +55,10 @@ const form = reactive({
54
55
  urlScheme: 'default' as 'default' | 'http' | 'https',
55
56
  })
56
57
  const apiToken = ref('')
58
+ // Flag a bad paste on the field itself rather than leaving it to surface as an opaque probe
59
+ // failure. Destructured at the top level so the template auto-unwraps the refs. Same rule and
60
+ // same copy as the per-type engine form, via the shared composable.
61
+ const { blocking: tokenBlocking, message: tokenProblem } = useServiceAccountTokenProblem(apiToken)
57
62
 
58
63
  const manifestSourceItems = computed(() => [
59
64
  { label: t('settings.providerConnection.kubernetesEnv.sourceColocated'), value: 'colocated' },
@@ -168,6 +173,7 @@ const canSave = computed(
168
173
  !!form.label.trim() &&
169
174
  !!form.apiServerUrl.trim() &&
170
175
  !!apiToken.value.trim() &&
176
+ !tokenBlocking.value &&
171
177
  manifestSourceValid.value &&
172
178
  urlValid.value,
173
179
  )
@@ -191,6 +197,8 @@ const connectBlockedReason = computed(() => {
191
197
  missing.push(t('settings.providerConnection.kubernetesEnv.serviceName'))
192
198
  if (missing.length)
193
199
  return t('settings.providerConnection.form.missingFields', { fields: missing.join(', ') })
200
+ // Repeated from under the token field, so the disabled button is never left unexplained.
201
+ if (tokenBlocking.value) return tokenProblem.value
194
202
  return t('settings.providerConnection.kubernetesEnv.invalidFields')
195
203
  })
196
204
 
@@ -273,6 +281,16 @@ function optional(label: string): string {
273
281
  :help="t('settings.providerConnection.kubernetesEnv.apiTokenHelp')"
274
282
  >
275
283
  <SecretInput v-model="apiToken" class="w-full font-mono" />
284
+ <!-- Rose when the paste is impossible (blocks Test/Save), amber when it is only suspicious
285
+ and the operator may legitimately overrule it. -->
286
+ <p
287
+ v-if="tokenProblem"
288
+ class="mt-1 text-[11px]"
289
+ :class="tokenBlocking ? 'text-rose-400' : 'text-amber-400'"
290
+ data-testid="service-account-token-problem"
291
+ >
292
+ {{ tokenProblem }}
293
+ </p>
276
294
  </UFormField>
277
295
 
278
296
  <!-- Manifest source: where the per-PR resources are read from. -->
@@ -391,7 +409,7 @@ function optional(label: string): string {
391
409
  />
392
410
  </UFormField>
393
411
 
394
- <div v-if="supportsTest" class="flex items-center gap-2">
412
+ <div v-if="supportsTest" class="space-y-1.5">
395
413
  <UButton
396
414
  color="neutral"
397
415
  variant="soft"
@@ -403,12 +421,7 @@ function optional(label: string): string {
403
421
  >
404
422
  {{ t('settings.providerConnection.test.button') }}
405
423
  </UButton>
406
- <span v-if="testResult && testResult.ok" class="text-xs text-emerald-400">
407
- {{ testResult.message ?? t('settings.providerConnection.test.ok') }}
408
- </span>
409
- <span v-else-if="testResult" class="text-xs text-rose-400">
410
- {{ testResult.message ?? t('settings.providerConnection.test.failed') }}
411
- </span>
424
+ <ConnectionTestVerdict :result="testResult" />
412
425
  </div>
413
426
 
414
427
  <ConnectionWarnings :warnings="testResult?.warnings" />