@cat-factory/app 0.261.0 → 0.261.2

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 (36) hide show
  1. package/app/components/foundational/FoundationalServiceManager.vue +1 -1
  2. package/app/components/fragments/FragmentLibraryManager.vue +1 -1
  3. package/app/components/layout/BoardTopOverlays.vue +6 -0
  4. package/app/components/layout/GitHubPatPermissionsBanner.vue +224 -0
  5. package/app/components/settings/ConnectionTestVerdict.vue +62 -0
  6. package/app/components/settings/InfraHandlersConfigurator.logic.spec.ts +64 -0
  7. package/app/components/settings/InfraHandlersConfigurator.logic.ts +41 -0
  8. package/app/components/settings/InfraHandlersConfigurator.vue +36 -9
  9. package/app/components/settings/KubernetesEngineForm.vue +24 -7
  10. package/app/components/settings/KubernetesEnvironmentForm.vue +20 -7
  11. package/app/components/settings/ProviderConnectionTab.vue +3 -7
  12. package/app/components/settings/ProviderManifestEditor.vue +3 -7
  13. package/app/components/skills/SkillLibraryManager.vue +1 -1
  14. package/app/composables/api/github.ts +7 -0
  15. package/app/composables/useServiceAccountTokenProblem.ts +52 -0
  16. package/app/stores/github/probe.ts +97 -0
  17. package/app/stores/github.spec.ts +109 -1
  18. package/app/stores/github.ts +26 -42
  19. package/app/stores/ui/k3sDeepLink.spec.ts +79 -0
  20. package/app/stores/ui/modals.ts +25 -2
  21. package/app/types/github.ts +4 -0
  22. package/app/types/providerConnections.ts +9 -0
  23. package/app/utils/connectionFailures.ts +32 -0
  24. package/app/utils/connectionWarnings.ts +1 -0
  25. package/app/utils/vcs.ts +28 -3
  26. package/i18n/locales/de.json +41 -1
  27. package/i18n/locales/en.json +59 -1
  28. package/i18n/locales/es.json +41 -1
  29. package/i18n/locales/fr.json +41 -1
  30. package/i18n/locales/he.json +41 -1
  31. package/i18n/locales/it.json +41 -1
  32. package/i18n/locales/ja.json +41 -1
  33. package/i18n/locales/pl.json +41 -1
  34. package/i18n/locales/tr.json +41 -1
  35. package/i18n/locales/uk.json +41 -1
  36. package/package.json +2 -2
@@ -45,7 +45,7 @@ watch(
45
45
  () => {
46
46
  void catalog.probe()
47
47
  // The repo pickers need the active board's installation state; probe once so they light up.
48
- void github.probe()
48
+ void github.ensureProbed()
49
49
  },
50
50
  { immediate: true },
51
51
  )
@@ -62,7 +62,7 @@ watch(
62
62
  void documents.probe()
63
63
  // The GitHub pickers (repo search + tree browser) need the active board's
64
64
  // installation state; probe once so they light up when the App is connected.
65
- void github.probe()
65
+ void github.ensureProbed()
66
66
  },
67
67
  { immediate: true },
68
68
  )
@@ -3,6 +3,7 @@ import BoardToolbar from '~/components/layout/BoardToolbar.vue'
3
3
  import ConnectionStatusBanner from '~/components/layout/ConnectionStatusBanner.vue'
4
4
  import SpendWarningBanner from '~/components/layout/SpendWarningBanner.vue'
5
5
  import GitHubPatBanner from '~/components/layout/GitHubPatBanner.vue'
6
+ import GitHubPatPermissionsBanner from '~/components/layout/GitHubPatPermissionsBanner.vue'
6
7
  import AiProvidersBanner from '~/components/layout/AiProvidersBanner.vue'
7
8
  import ProviderConfigBanner from '~/components/layout/ProviderConfigBanner.vue'
8
9
  import InfraSetupBanner from '~/components/layout/InfraSetupBanner.vue'
@@ -68,6 +69,10 @@ const ui = useUiStore()
68
69
  - Connection status: what is on screen right now may already be stale.
69
70
  - Spend exceeded: runs are blocked until the budget moves.
70
71
  - GitHub PAT (local mode): every repo-operating step will fail.
72
+ - GitHub PAT permissions: a token IS configured, but it cannot push or open pull
73
+ requests, so every repo-operating step will fail just as surely. Directly after its
74
+ missing-token sibling, which it can never appear beside (that one raises only when
75
+ there is no token, this one only when there is).
71
76
  - AI readiness: no usable model source, or the default preset names unavailable models.
72
77
  - Infrastructure provider: env/runner-pool wired but missing mandatory config.
73
78
  - Infra setup: an executor / test env / storage this deployment needs is undefined, so
@@ -82,6 +87,7 @@ const ui = useUiStore()
82
87
  />
83
88
  <SpendWarningBanner />
84
89
  <GitHubPatBanner />
90
+ <GitHubPatPermissionsBanner />
85
91
  <AiProvidersBanner />
86
92
  <ProviderConfigBanner />
87
93
  <InfraSetupBanner />
@@ -0,0 +1,224 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref } from 'vue'
3
+ import {
4
+ GITHUB_PAT_FINE_GRAINED_PERMISSIONS,
5
+ githubPatCheckNeedsAttention,
6
+ githubPatCheckSource,
7
+ missingGitHubPatCapabilities,
8
+ } from '@cat-factory/contracts'
9
+ import type { GitHubPatCapability, GitHubPatKind } from '~/types/domain'
10
+ import { githubPatRemintUrl } from '~/utils/vcs'
11
+
12
+ // The token a run would authenticate with cannot do what the pipeline needs.
13
+ //
14
+ // This is the board-load half of a warning the backend already logs at boot in local mode: a
15
+ // developer's terminal is easy to miss, and on a HOSTED deployment there is no terminal at all
16
+ // for the case this also covers, where the run initiator's own stored token outranks the App
17
+ // installation. Either way the alternative to saying it here is saying it eight steps into a
18
+ // pipeline as a 403 out of a container, after the run has spent money.
19
+ //
20
+ // It raises on ESTABLISHED blocking gaps only (`githubPatCheckNeedsAttention`). An unreachable
21
+ // GitHub, an advisory-only finding and an unknowable fine-grained permission each render
22
+ // nothing over the board, because none of them is something the reader can act on right now and
23
+ // a banner that appears when nothing is wrong is one people learn to dismiss unread.
24
+ //
25
+ // Positioning/stacking is owned by `BoardTopOverlays`; this renders only its card.
26
+
27
+ const { t } = useI18n()
28
+ const github = useGitHubStore()
29
+
30
+ const dismissed = ref(false)
31
+ const check = computed(() => github.patCheck)
32
+ const show = computed(
33
+ () => !dismissed.value && check.value !== null && githubPatCheckNeedsAttention(check.value),
34
+ )
35
+
36
+ /** The report, when the check produced one. Absent for a token GitHub rejected outright. */
37
+ const report = computed(() => (check.value?.state === 'checked' ? check.value.report : undefined))
38
+
39
+ /**
40
+ * Which claim the card is making. `rejected` is the strictly worse problem (the token does not
41
+ * authenticate at all), so it gets its own copy rather than being folded into "some capability
42
+ * is missing" — every capability would read as missing, which describes the symptom and not the
43
+ * cause.
44
+ */
45
+ const claim = computed<'rejected' | 'underscoped'>(() =>
46
+ check.value?.state === 'token_rejected' ? 'rejected' : 'underscoped',
47
+ )
48
+
49
+ const missing = computed<GitHubPatCapability[]>(() =>
50
+ report.value ? missingGitHubPatCapabilities(report.value).blocking : [],
51
+ )
52
+ /**
53
+ * Established gaps that do NOT stop a pipeline, listed inside the card but never the reason it
54
+ * opened. Today that is `workflows`: worth fixing while you are on the token page, not worth
55
+ * interrupting a board for on its own.
56
+ */
57
+ const advisory = computed<GitHubPatCapability[]>(() =>
58
+ report.value ? missingGitHubPatCapabilities(report.value).advisory : [],
59
+ )
60
+
61
+ const CAPABILITY_KEYS: Record<GitHubPatCapability, string> = {
62
+ push: 'layout.githubPatPermissionsBanner.capability.push',
63
+ pullRequests: 'layout.githubPatPermissionsBanner.capability.pullRequests',
64
+ workflows: 'layout.githubPatPermissionsBanner.capability.workflows',
65
+ }
66
+ function capabilityLabel(capability: GitHubPatCapability): string {
67
+ return t(CAPABILITY_KEYS[capability])
68
+ }
69
+
70
+ /**
71
+ * The kind carried over to the re-mint link. A rejected token was never classified, so it falls
72
+ * back to `unknown` — which lands on the classic form, the only one GitHub lets us pre-fill.
73
+ */
74
+ const kind = computed<GitHubPatKind>(() => report.value?.kind ?? 'unknown')
75
+ const remintUrl = computed(() =>
76
+ githubPatRemintUrl(kind.value, report.value?.webUrl ?? github.connection?.webUrl),
77
+ )
78
+
79
+ /**
80
+ * Who has to act. A deployment token is replaced by whoever runs the deployment (local mode:
81
+ * the developer at the terminal); an initiator token belongs to the signed-in user and is
82
+ * replaced in their own settings. Sending one to the other's remedy is worse than saying
83
+ * nothing, which is why the source rides the wire rather than being guessed from the shape.
84
+ *
85
+ * Read through the contract's own accessor rather than off `report`, because a REJECTED token
86
+ * produces no report and every state this banner renders carries a source. Deriving it from the
87
+ * report alone left the rejected case falling through to whichever branch the ternary ended on,
88
+ * which told a local developer whose deployment token had expired to replace it in their
89
+ * personal settings: the exact misrouting the wire field exists to prevent.
90
+ */
91
+ const sourceKey = computed(() =>
92
+ check.value && githubPatCheckSource(check.value) === 'deployment'
93
+ ? 'layout.githubPatPermissionsBanner.sourceDeployment'
94
+ : 'layout.githubPatPermissionsBanner.sourceInitiator',
95
+ )
96
+
97
+ /** The fine-grained form takes no prefill, so the permissions have to be named as prose. */
98
+ const fineGrainedPermissions = GITHUB_PAT_FINE_GRAINED_PERMISSIONS.join(', ')
99
+ </script>
100
+
101
+ <template>
102
+ <Transition name="fade">
103
+ <div v-if="show" class="pointer-events-auto w-full max-w-3xl">
104
+ <div
105
+ class="w-full max-w-3xl rounded-2xl border-2 border-red-500/70 bg-red-950/95 p-5 shadow-2xl backdrop-blur"
106
+ role="alert"
107
+ data-testid="github-pat-permissions-banner"
108
+ >
109
+ <div class="flex items-start gap-4">
110
+ <UIcon name="i-lucide-shield-alert" class="mt-0.5 h-9 w-9 shrink-0 text-red-400" />
111
+ <div class="min-w-0 flex-1">
112
+ <div class="flex items-start justify-between gap-3">
113
+ <h2 class="text-lg font-semibold text-red-100">
114
+ {{
115
+ claim === 'rejected'
116
+ ? t('layout.githubPatPermissionsBanner.rejectedTitle')
117
+ : t('layout.githubPatPermissionsBanner.title')
118
+ }}
119
+ </h2>
120
+ <UButton
121
+ color="neutral"
122
+ variant="ghost"
123
+ size="xs"
124
+ icon="i-lucide-x"
125
+ :aria-label="t('common.close')"
126
+ @click="
127
+ () => {
128
+ dismissed = true
129
+ }
130
+ "
131
+ />
132
+ </div>
133
+
134
+ <p class="mt-1 text-sm text-red-200/90">
135
+ {{
136
+ claim === 'rejected'
137
+ ? t('layout.githubPatPermissionsBanner.rejectedBody')
138
+ : t('layout.githubPatPermissionsBanner.body')
139
+ }}
140
+ </p>
141
+
142
+ <!-- The established gaps, named one by one. A bare "permissions are missing" leaves
143
+ the reader to guess which box to tick on a form with dozens. -->
144
+ <p v-if="missing.length" class="mt-3 text-sm text-red-100">
145
+ <span class="font-medium">{{ t('layout.githubPatPermissionsBanner.missing') }}</span>
146
+ {{ missing.map(capabilityLabel).join(', ') }}
147
+ </p>
148
+ <p v-if="advisory.length" class="mt-1 text-xs text-red-200/80">
149
+ {{
150
+ t('layout.githubPatPermissionsBanner.alsoMissing', {
151
+ capabilities: advisory.map(capabilityLabel).join(', '),
152
+ })
153
+ }}
154
+ </p>
155
+
156
+ <!-- For a fine-grained token, WHICH repositories it was not granted is the whole
157
+ remedy: the permission list is right and the repository selection is not. -->
158
+ <p v-if="report && report.deniedRepos.length" class="mt-1 text-xs text-red-200/80">
159
+ {{
160
+ t('layout.githubPatPermissionsBanner.deniedRepos', {
161
+ repos: report.deniedRepos.join(', '),
162
+ })
163
+ }}
164
+ </p>
165
+
166
+ <p class="mt-2 text-xs text-red-200/80">{{ t(sourceKey) }}</p>
167
+
168
+ <div class="mt-4">
169
+ <UButton
170
+ :to="remintUrl"
171
+ target="_blank"
172
+ rel="noopener noreferrer"
173
+ color="error"
174
+ variant="solid"
175
+ icon="i-lucide-external-link"
176
+ trailing
177
+ >
178
+ {{
179
+ kind === 'fine_grained'
180
+ ? t('layout.githubPatPermissionsBanner.createFineGrained')
181
+ : t('layout.githubPatPermissionsBanner.createClassic')
182
+ }}
183
+ </UButton>
184
+ <!-- The classic form arrives with the scopes ticked; the fine-grained one accepts
185
+ no prefill at all, so its permissions are spelled out. Saying so is the point:
186
+ a link that silently arrived with nothing selected reads as "already done for
187
+ you", which is how the missing permission got there in the first place. -->
188
+ <p class="mt-2 text-xs text-red-300/70">
189
+ {{
190
+ kind === 'fine_grained'
191
+ ? t('layout.githubPatPermissionsBanner.fineGrainedHint', {
192
+ permissions: fineGrainedPermissions,
193
+ })
194
+ : t('layout.githubPatPermissionsBanner.classicHint')
195
+ }}
196
+ </p>
197
+ <!-- A fine-grained verdict is a SAMPLE of the linked repositories. Declaring the
198
+ remainder keeps a clean-looking list from reading as a guarantee. -->
199
+ <p v-if="report && report.unprobedRepoCount > 0" class="mt-1 text-xs text-red-300/60">
200
+ {{
201
+ t('layout.githubPatPermissionsBanner.sampled', {
202
+ checked: report.probedRepos.length,
203
+ remaining: report.unprobedRepoCount,
204
+ })
205
+ }}
206
+ </p>
207
+ </div>
208
+ </div>
209
+ </div>
210
+ </div>
211
+ </div>
212
+ </Transition>
213
+ </template>
214
+
215
+ <style scoped>
216
+ .fade-enter-active,
217
+ .fade-leave-active {
218
+ transition: opacity 0.2s ease;
219
+ }
220
+ .fade-enter-from,
221
+ .fade-leave-to {
222
+ opacity: 0;
223
+ }
224
+ </style>
@@ -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">