@cat-factory/app 0.96.3 → 0.96.5

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 +3 -4
  2. package/app/components/auth/ResetPasswordScreen.vue +3 -4
  3. package/app/components/board/BoardCanvas.vue +28 -7
  4. package/app/components/common/SecretInput.vue +49 -0
  5. package/app/components/documents/DocumentSourceConnectModal.vue +3 -2
  6. package/app/components/layout/AccountDeploymentSettings.vue +7 -12
  7. package/app/components/layout/AccountTeamSettings.vue +2 -2
  8. package/app/components/layout/BoardToolbar.vue +29 -9
  9. package/app/components/providers/ApiKeysSection.vue +3 -3
  10. package/app/components/providers/PersonalCredentialModal.vue +2 -2
  11. package/app/components/providers/PersonalSubscriptionSection.vue +4 -5
  12. package/app/components/providers/VendorCredentialsModal.vue +3 -3
  13. package/app/components/settings/KubernetesEngineForm.vue +3 -3
  14. package/app/components/settings/KubernetesEnvironmentForm.vue +2 -1
  15. package/app/components/settings/LocalModelEndpointsPanel.vue +2 -2
  16. package/app/components/settings/ObservabilityConnectionPanel.vue +5 -10
  17. package/app/components/settings/OpenRouterCatalogPanel.vue +3 -3
  18. package/app/components/settings/PackageRegistriesPanel.vue +2 -6
  19. package/app/components/settings/ProviderManifestEditor.vue +2 -2
  20. package/app/components/settings/UserSecretsSection.vue +3 -2
  21. package/app/components/slack/SlackPanel.vue +2 -7
  22. package/app/composables/useBoardFlow.ts +9 -2
  23. package/app/utils/dnd.ts +3 -2
  24. package/i18n/locales/en.json +7 -1
  25. package/i18n/locales/es.json +7 -1
  26. package/i18n/locales/fr.json +7 -1
  27. package/i18n/locales/he.json +7 -1
  28. package/i18n/locales/ja.json +7 -1
  29. package/i18n/locales/pl.json +7 -1
  30. package/i18n/locales/tr.json +7 -1
  31. package/i18n/locales/uk.json +7 -1
  32. package/package.json +2 -2
@@ -1,6 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, ref, watch } from 'vue'
3
3
  import { apiErrorEnvelope } from '~/composables/api/errors'
4
+ import SecretInput from '~/components/common/SecretInput.vue'
4
5
 
5
6
  const auth = useAuthStore()
6
7
  const { t } = useI18n()
@@ -320,9 +321,8 @@ const noSignInMethod = computed(
320
321
  size="lg"
321
322
  class="w-full"
322
323
  />
323
- <UInput
324
+ <SecretInput
324
325
  v-model="password"
325
- type="password"
326
326
  required
327
327
  :placeholder="t('auth.login.passwordPlaceholder')"
328
328
  icon="i-lucide-lock"
@@ -395,9 +395,8 @@ const noSignInMethod = computed(
395
395
  {{ PROVIDER_LABELS[p] }}
396
396
  </UButton>
397
397
  </div>
398
- <UInput
398
+ <SecretInput
399
399
  v-model="remotePatToken"
400
- type="password"
401
400
  required
402
401
  :placeholder="
403
402
  t('auth.login.patPlaceholder', { provider: PROVIDER_LABELS[remotePatProvider] })
@@ -1,5 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, ref } from 'vue'
3
+ import SecretInput from '~/components/common/SecretInput.vue'
3
4
 
4
5
  // Standalone full-screen reset form reached from the emailed link
5
6
  // (`/reset-password?token=…`). It is a public route (see AuthGate), so a recipient who
@@ -76,18 +77,16 @@ function goToLogin() {
76
77
  </template>
77
78
 
78
79
  <form v-else class="space-y-3" @submit.prevent="submit">
79
- <UInput
80
+ <SecretInput
80
81
  v-model="password"
81
- type="password"
82
82
  required
83
83
  :placeholder="t('auth.resetPassword.newPasswordPlaceholder')"
84
84
  icon="i-lucide-lock"
85
85
  size="lg"
86
86
  class="w-full"
87
87
  />
88
- <UInput
88
+ <SecretInput
89
89
  v-model="confirm"
90
- type="password"
91
90
  required
92
91
  :placeholder="t('auth.resetPassword.confirmPasswordPlaceholder')"
93
92
  icon="i-lucide-lock"
@@ -6,7 +6,7 @@ import EpicNode from './nodes/EpicNode.vue'
6
6
  import TaskDependencyEdges from './TaskDependencyEdges.vue'
7
7
  import DependencyConnectOverlay from './DependencyConnectOverlay.vue'
8
8
  import { readDndPayload, blockIdFromEvent } from '~/utils/dnd'
9
- import { BOARD_FLOW_ID } from '~/composables/useBoardFlow'
9
+ import { BOARD_FLOW_ID, BOARD_MIN_ZOOM, BOARD_MAX_ZOOM } from '~/composables/useBoardFlow'
10
10
  import { useTaskExpansion } from '~/composables/useTaskExpansion'
11
11
  import { useBlockDrag } from '~/composables/useBlockDrag'
12
12
  import { useFrameStacking } from '~/composables/useFrameStacking'
@@ -102,9 +102,20 @@ function onNodeClick({ node }: NodeMouseEvent) {
102
102
  ui.select(node.id)
103
103
  }
104
104
 
105
- function onNodeDoubleClick({ node }: NodeMouseEvent) {
106
- // Frames are containers: double-click expands to reveal their tasks.
107
- ui.toggleFrame(node.id)
105
+ function onNodeDoubleClick({ event, node }: NodeMouseEvent) {
106
+ // Task cards live *inside* their frame node, so Vue Flow reports the frame here even
107
+ // when the double-click landed on a task. Resolve the real target from the DOM: a
108
+ // double-click on a task opens its focus view (the same "open this task" gesture as the
109
+ // card's review action), while a double-click on frame chrome centres the camera on the
110
+ // frame and zooms it in. Epics aren't containers, so their double-click stays a no-op.
111
+ const targetId = blockIdFromEvent(event)
112
+ const target = targetId ? board.getBlock(targetId) : undefined
113
+ if (target?.level === 'task') {
114
+ ui.select(target.id)
115
+ ui.focus(target.id)
116
+ return
117
+ }
118
+ if (node.type === 'block') void focusFrame(node.id)
108
119
  }
109
120
 
110
121
  function onPaneClick() {
@@ -147,7 +158,17 @@ async function onDrop(event: DragEvent) {
147
158
  const blockId = blockIdFromEvent(event)
148
159
  const target = blockId ? board.getBlock(blockId) : undefined
149
160
  const pipeline = pipelines.getPipeline(payload.pipelineId)
150
- if (!target || !pipeline) return
161
+ // Unknown pipeline id is an internal glitch (nothing the user can act on); a drop
162
+ // onto blank canvas / a non-block, though, needs the same "aim at a task" nudge the
163
+ // wrong-level path gives — otherwise the drop just vanishes (UX-07).
164
+ if (!pipeline) return
165
+ if (!target) {
166
+ toast.add({
167
+ title: t('board.canvas.dropOntoTaskTitle'),
168
+ description: t('board.canvas.dropOntoTaskBody'),
169
+ })
170
+ return
171
+ }
151
172
  if (target.level !== 'task') {
152
173
  toast.add({
153
174
  title: t('board.canvas.dropOntoTaskTitle'),
@@ -179,8 +200,8 @@ async function onDrop(event: DragEvent) {
179
200
  <VueFlow
180
201
  :id="BOARD_FLOW_ID"
181
202
  :nodes="nodes"
182
- :min-zoom="0.2"
183
- :max-zoom="3"
203
+ :min-zoom="BOARD_MIN_ZOOM"
204
+ :max-zoom="BOARD_MAX_ZOOM"
184
205
  :default-viewport="{ x: 40, y: 20, zoom: 0.85 }"
185
206
  :pan-on-drag="panOnDrag"
186
207
  :elevate-nodes-on-select="false"
@@ -0,0 +1,49 @@
1
+ <script setup lang="ts">
2
+ // Shared single-line secret/password input with a reveal (eye) toggle (UX-19/UX-20).
3
+ // Masks the value by default (`type="password"`) and lets the user verify a pasted token via
4
+ // the trailing eye button — without one, a mistyped/expired key looks identical to a good one,
5
+ // the leading cause of invalid-credential retries. Replaces both the bare `type="password"`
6
+ // UInputs (UX-19) and the fully-plaintext secret `UTextarea`s (UX-20), which rendered live
7
+ // vendor keys in cleartext (shoulder-surf / screen-share leakage).
8
+ //
9
+ // When `secret` is false it degrades to a plain text input with no toggle, so descriptor-driven
10
+ // fields whose secrecy is data-dependent (`field.secret ? … : …`) can bind it directly.
11
+ // All other UInput props/listeners (icon, size, placeholder, disabled, autofocus, class, …)
12
+ // pass straight through via `$attrs`; `secret` is a declared prop so it strips off first.
13
+ // `$attrs` is bound BEFORE `type` so the mask/reveal control stays authoritative — a caller
14
+ // that (out of old habit) also passes `type="password"` can't clobber the toggle.
15
+ // Mirrors the shape of `common/CopyButton.vue` / `common/IconButton.vue`.
16
+ const props = withDefaults(
17
+ defineProps<{
18
+ /** Whether the field holds a secret (masked + reveal toggle). When false, a plain text input. */
19
+ secret?: boolean
20
+ }>(),
21
+ { secret: true },
22
+ )
23
+
24
+ const model = defineModel<string>()
25
+ const { t } = useI18n()
26
+ const revealed = ref(false)
27
+ const toggle = () => {
28
+ revealed.value = !revealed.value
29
+ }
30
+ defineOptions({ inheritAttrs: false })
31
+ </script>
32
+
33
+ <template>
34
+ <UInput v-if="!props.secret" v-model="model" v-bind="$attrs" type="text" />
35
+ <UInput v-else v-model="model" v-bind="$attrs" :type="revealed ? 'text' : 'password'">
36
+ <template #trailing>
37
+ <UButton
38
+ color="neutral"
39
+ variant="link"
40
+ size="xs"
41
+ :icon="revealed ? 'i-lucide-eye-off' : 'i-lucide-eye'"
42
+ :title="revealed ? t('common.hide') : t('common.reveal')"
43
+ :aria-label="revealed ? t('common.hide') : t('common.reveal')"
44
+ :aria-pressed="revealed"
45
+ @click.stop="toggle"
46
+ />
47
+ </template>
48
+ </UInput>
49
+ </template>
@@ -5,6 +5,7 @@
5
5
  // are write-only — the backend never returns them, so on reload we show
6
6
  // "Connected" with empty fields.
7
7
  import IntegrationBackTitle from '~/components/layout/IntegrationBackTitle.vue'
8
+ import SecretInput from '~/components/common/SecretInput.vue'
8
9
 
9
10
  const { t } = useI18n()
10
11
  const ui = useUiStore()
@@ -103,9 +104,9 @@ async function disconnect() {
103
104
  :label="field.label"
104
105
  :help="field.help"
105
106
  >
106
- <UInput
107
+ <SecretInput
107
108
  v-model="values[field.key]"
108
- :type="field.secret ? 'password' : 'text'"
109
+ :secret="!!field.secret"
109
110
  :placeholder="field.placeholder"
110
111
  class="w-full"
111
112
  />
@@ -1,6 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
3
3
  import type { ContentStorageBackend, ContentStorageConfig } from '~/types/accountSettings'
4
+ import SecretInput from '~/components/common/SecretInput.vue'
4
5
 
5
6
  // Deployment integration secrets for an account (admin only): the Slack app OAuth
6
7
  // credentials, the container web-search upstream keys, and the binary-artifact (screenshot)
@@ -379,9 +380,8 @@ async function clearWeb() {
379
380
  :placeholder="t('layout.accountDeployment.slack.clientId')"
380
381
  size="sm"
381
382
  />
382
- <UInput
383
+ <SecretInput
383
384
  v-model="slack.clientSecret"
384
- type="password"
385
385
  :placeholder="t('layout.accountDeployment.slack.clientSecret')"
386
386
  size="sm"
387
387
  />
@@ -441,9 +441,8 @@ async function clearWeb() {
441
441
  :placeholder="t('layout.accountDeployment.linear.clientId')"
442
442
  size="sm"
443
443
  />
444
- <UInput
444
+ <SecretInput
445
445
  v-model="linear.clientSecret"
446
- type="password"
447
446
  :placeholder="t('layout.accountDeployment.linear.clientSecret')"
448
447
  size="sm"
449
448
  />
@@ -494,9 +493,8 @@ async function clearWeb() {
494
493
  {{ t('layout.accountDeployment.web.description') }}
495
494
  </p>
496
495
  <div class="grid grid-cols-1 gap-2 sm:grid-cols-3">
497
- <UInput
496
+ <SecretInput
498
497
  v-model="web.braveApiKey"
499
- type="password"
500
498
  :placeholder="t('layout.accountDeployment.web.braveKey')"
501
499
  size="sm"
502
500
  />
@@ -505,9 +503,8 @@ async function clearWeb() {
505
503
  :placeholder="t('layout.accountDeployment.web.searxngUrl')"
506
504
  size="sm"
507
505
  />
508
- <UInput
506
+ <SecretInput
509
507
  v-model="web.searxngApiKey"
510
- type="password"
511
508
  :placeholder="t('layout.accountDeployment.web.searxngKey')"
512
509
  size="sm"
513
510
  />
@@ -624,15 +621,13 @@ async function clearWeb() {
624
621
  </UBadge>
625
622
  </div>
626
623
  <div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
627
- <UInput
624
+ <SecretInput
628
625
  v-model="cs.accessKeyId"
629
- type="password"
630
626
  :placeholder="t('layout.accountDeployment.contentStorage.accessKeyId')"
631
627
  size="sm"
632
628
  />
633
- <UInput
629
+ <SecretInput
634
630
  v-model="cs.secretAccessKey"
635
- type="password"
636
631
  :placeholder="t('layout.accountDeployment.contentStorage.secretAccessKey')"
637
632
  size="sm"
638
633
  />
@@ -4,6 +4,7 @@ import { apiErrorEnvelope } from '~/composables/api/errors'
4
4
  import type { AccountRole } from '~/types/domain'
5
5
  import type { InvitationStatus } from '@cat-factory/contracts'
6
6
  import AccountDeploymentSettings from '~/components/layout/AccountDeploymentSettings.vue'
7
+ import SecretInput from '~/components/common/SecretInput.vue'
7
8
 
8
9
  // Team settings for an org account: the member roster (with combinable admin /
9
10
  // developer / product roles), pending email invitations, and the per-account
@@ -300,9 +301,8 @@ async function disconnectEmail() {
300
301
  :placeholder="t('layout.accountTeam.email.fromPlaceholder')"
301
302
  class="w-full"
302
303
  />
303
- <UInput
304
+ <SecretInput
304
305
  v-model="emailApiKey"
305
- type="password"
306
306
  :placeholder="t('layout.accountTeam.email.apiKeyPlaceholder')"
307
307
  class="w-full"
308
308
  />
@@ -1,6 +1,7 @@
1
1
  <script setup lang="ts">
2
- import { useBoardFlow } from '~/composables/useBoardFlow'
2
+ import { useBoardFlow, BOARD_MIN_ZOOM, BOARD_MAX_ZOOM } from '~/composables/useBoardFlow'
3
3
  import NotificationsInbox from '~/components/layout/NotificationsInbox.vue'
4
+ import IconButton from '~/components/common/IconButton.vue'
4
5
 
5
6
  const ui = useUiStore()
6
7
  const board = useBoardStore()
@@ -10,7 +11,7 @@ const workspaceSettings = useWorkspaceSettingsStore()
10
11
  const services = useServicesStore()
11
12
  const toast = useToast()
12
13
  const { t, n } = useI18n()
13
- const { fitView, zoomIn, zoomOut } = useBoardFlow()
14
+ const { fitView, zoomIn, zoomOut, resetZoom } = useBoardFlow()
14
15
 
15
16
  async function mountService(serviceId: string, title: string) {
16
17
  try {
@@ -44,6 +45,10 @@ const mountableItems = computed(() =>
44
45
  )
45
46
 
46
47
  const zoomPct = computed(() => Math.round(ui.zoom * 100))
48
+ // Disable the zoom buttons once the camera hits a clamp (Vue Flow pins the zoom exactly
49
+ // at the limit, so a small epsilon guards against float drift). UX-16.
50
+ const atMinZoom = computed(() => ui.zoom <= BOARD_MIN_ZOOM + 0.001)
51
+ const atMaxZoom = computed(() => ui.zoom >= BOARD_MAX_ZOOM - 0.001)
47
52
  // Exhaustive (tier-2) map from level-of-detail → its label key, so adding an LOD
48
53
  // without a label fails the typecheck rather than leaking a raw key.
49
54
  const LOD_LABEL_KEYS = {
@@ -99,11 +104,13 @@ const decisionItems = computed(() =>
99
104
  class="absolute left-1/2 top-3 z-20 flex max-w-[calc(100vw-1rem)] -translate-x-1/2 items-center gap-1 overflow-x-auto rounded-full border border-slate-700 bg-slate-900/90 px-2 py-1.5 shadow-xl backdrop-blur"
100
105
  >
101
106
  <!-- zoom controls -->
102
- <UButton
107
+ <IconButton
108
+ :label="t('board.toolbar.zoomOut')"
103
109
  icon="i-lucide-zoom-out"
104
110
  color="neutral"
105
111
  variant="ghost"
106
112
  size="sm"
113
+ :disabled="atMinZoom"
107
114
  data-testid="board-zoom-out"
108
115
  @click="
109
116
  () => {
@@ -111,16 +118,28 @@ const decisionItems = computed(() =>
111
118
  }
112
119
  "
113
120
  />
114
- <!-- The zoom %/LOD readout is the first thing to drop on narrow viewports. -->
115
- <div class="hidden w-20 text-center text-xs tabular-nums text-slate-300 sm:block">
121
+ <!-- Click the readout to snap back to 100%. Always visible (only the LOD sub-label
122
+ drops on narrow viewports) so the zoom level is never a mystery. -->
123
+ <button
124
+ type="button"
125
+ class="w-16 rounded text-center text-xs tabular-nums text-slate-300 hover:bg-slate-800 focus-visible:ring-2 focus-visible:ring-slate-400/60 sm:w-20"
126
+ :title="t('board.toolbar.resetZoom')"
127
+ :aria-label="t('board.toolbar.resetZoom')"
128
+ data-testid="board-zoom-reset"
129
+ @click="resetZoom()"
130
+ >
116
131
  {{ zoomPct }}%
117
- <div class="text-[9px] uppercase tracking-wide text-slate-500">{{ lodLabel }}</div>
118
- </div>
119
- <UButton
132
+ <span class="hidden text-[9px] uppercase tracking-wide text-slate-500 sm:block">{{
133
+ lodLabel
134
+ }}</span>
135
+ </button>
136
+ <IconButton
137
+ :label="t('board.toolbar.zoomIn')"
120
138
  icon="i-lucide-zoom-in"
121
139
  color="neutral"
122
140
  variant="ghost"
123
141
  size="sm"
142
+ :disabled="atMaxZoom"
124
143
  data-testid="board-zoom-in"
125
144
  @click="
126
145
  () => {
@@ -128,7 +147,8 @@ const decisionItems = computed(() =>
128
147
  }
129
148
  "
130
149
  />
131
- <UButton
150
+ <IconButton
151
+ :label="t('board.toolbar.fitView')"
132
152
  icon="i-lucide-maximize"
133
153
  color="neutral"
134
154
  variant="ghost"
@@ -16,6 +16,7 @@
16
16
  // account); admin-only, enforced server-side. Surfaced from account/team settings.
17
17
  import { computed, ref, watch } from 'vue'
18
18
  import type { ApiKey, ApiKeyProvider } from '~/types/domain'
19
+ import SecretInput from '~/components/common/SecretInput.vue'
19
20
 
20
21
  const props = withDefaults(defineProps<{ accountId?: string; category?: 'direct' | 'proxy' }>(), {
21
22
  category: 'direct',
@@ -323,12 +324,11 @@ async function remove(k: ApiKey) {
323
324
  />
324
325
  </UFormField>
325
326
  <UFormField :label="t('providers.apiKeys.keyField')">
326
- <UTextarea
327
+ <SecretInput
327
328
  v-model="key"
328
- :rows="2"
329
329
  :disabled="needsSignIn"
330
330
  :placeholder="t('providers.apiKeys.keyPlaceholder')"
331
- class="font-mono"
331
+ class="w-full font-mono"
332
332
  />
333
333
  </UFormField>
334
334
  <div class="flex justify-end">
@@ -5,6 +5,7 @@
5
5
  // credential_required. On submit it transparently retries the gated action and caches the
6
6
  // password. The copy follows the pending vendor (Claude / GLM / ChatGPT-Codex).
7
7
  import { computed, ref, watch } from 'vue'
8
+ import SecretInput from '~/components/common/SecretInput.vue'
8
9
 
9
10
  const { t } = useI18n()
10
11
  const personal = usePersonalSubscriptionsStore()
@@ -119,9 +120,8 @@ function goConnect() {
119
120
  {{ t('providers.personalCredential.passwordBody', { vendor: vendorLabel }) }}
120
121
  </p>
121
122
  <UFormField :label="t('providers.personalCredential.passwordField')">
122
- <UInput
123
+ <SecretInput
123
124
  v-model="password"
124
- type="password"
125
125
  autofocus
126
126
  :placeholder="t('providers.personalCredential.passwordPlaceholder')"
127
127
  @keydown.enter="submit()"
@@ -6,6 +6,7 @@
6
6
  // such a run (cached locally so it's usually transparent). Recurring schedules can't use them.
7
7
  import { computed, onMounted, ref } from 'vue'
8
8
  import type { SubscriptionVendor } from '~/types/domain'
9
+ import SecretInput from '~/components/common/SecretInput.vue'
9
10
 
10
11
  const personal = usePersonalSubscriptionsStore()
11
12
  const auth = useAuthStore()
@@ -233,19 +234,17 @@ async function disconnect(v: SubscriptionVendor) {
233
234
  />
234
235
  </UFormField>
235
236
  <UFormField :label="selectedMeta.tokenLabel">
236
- <UTextarea
237
+ <SecretInput
237
238
  v-model="token"
238
- :rows="2"
239
239
  :disabled="needsSignIn"
240
240
  :placeholder="selectedMeta.tokenPlaceholder"
241
- class="font-mono"
241
+ class="w-full font-mono"
242
242
  />
243
243
  </UFormField>
244
244
  <div class="flex flex-wrap gap-3">
245
245
  <UFormField :label="t('personalSubscriptions.passwordField')" class="flex-1">
246
- <UInput
246
+ <SecretInput
247
247
  v-model="password"
248
- type="password"
249
248
  :disabled="needsSignIn"
250
249
  :placeholder="t('personalSubscriptions.passwordPlaceholder')"
251
250
  />
@@ -8,6 +8,7 @@
8
8
  import { computed, ref, watch } from 'vue'
9
9
  import type { SubscriptionVendor } from '~/types/domain'
10
10
  import IntegrationBackTitle from '~/components/layout/IntegrationBackTitle.vue'
11
+ import SecretInput from '~/components/common/SecretInput.vue'
11
12
 
12
13
  const { t, n } = useI18n()
13
14
  const ui = useUiStore()
@@ -215,11 +216,10 @@ function vendorLabel(v: SubscriptionVendor): string {
215
216
  />
216
217
  </UFormField>
217
218
  <UFormField :label="t('providers.vendorCredentials.tokenField')">
218
- <UTextarea
219
+ <SecretInput
219
220
  v-model="token"
220
- :rows="3"
221
221
  :placeholder="tokenPlaceholder"
222
- class="font-mono"
222
+ class="w-full font-mono"
223
223
  />
224
224
  </UFormField>
225
225
  <div class="flex justify-end">
@@ -8,6 +8,7 @@
8
8
  // single-connection backend, which still carries the manifest source inline).
9
9
  import { computed, reactive, ref, watch } from 'vue'
10
10
  import { KUBERNETES_ENV_TOKEN_SECRET_KEY } from '@cat-factory/contracts'
11
+ import SecretInput from '~/components/common/SecretInput.vue'
11
12
  import type {
12
13
  EnvironmentHandlerView,
13
14
  InfraEngine,
@@ -351,10 +352,9 @@ async function copyAutoSetupCommand() {
351
352
  {{ t('settings.infrastructure.kubernetesEngine.tokenSaved') }}
352
353
  </span>
353
354
  </template>
354
- <UInput
355
+ <SecretInput
355
356
  v-model="apiToken"
356
- type="password"
357
- class="font-mono"
357
+ class="w-full font-mono"
358
358
  autocomplete="off"
359
359
  :placeholder="
360
360
  tokenStored
@@ -10,6 +10,7 @@
10
10
  // driven (see docs/initiatives/descriptor-driven-infra-forms.md).
11
11
  import { computed, reactive, ref, watch } from 'vue'
12
12
  import { KUBERNETES_ENV_TOKEN_SECRET_KEY } from '@cat-factory/contracts'
13
+ import SecretInput from '~/components/common/SecretInput.vue'
13
14
  import type { ProviderConnection } from '~/types/providerConnections'
14
15
 
15
16
  const props = defineProps<{
@@ -253,7 +254,7 @@ function optional(label: string): string {
253
254
  :label="t('settings.providerConnection.kubernetesEnv.apiToken')"
254
255
  :help="t('settings.providerConnection.kubernetesEnv.apiTokenHelp')"
255
256
  >
256
- <UInput v-model="apiToken" type="password" class="font-mono" />
257
+ <SecretInput v-model="apiToken" class="w-full font-mono" />
257
258
  </UFormField>
258
259
 
259
260
  <!-- Manifest source: where the per-PR resources are read from. -->
@@ -8,6 +8,7 @@
8
8
  import { computed, ref, watch } from 'vue'
9
9
  import { LOCAL_RUNNER_DEFAULTS, LOCAL_RUNNER_LABELS, type LocalRunner } from '~/types/localModels'
10
10
  import IntegrationBackTitle from '~/components/layout/IntegrationBackTitle.vue'
11
+ import SecretInput from '~/components/common/SecretInput.vue'
11
12
 
12
13
  const { t } = useI18n()
13
14
  const ui = useUiStore()
@@ -276,9 +277,8 @@ async function remove(p: LocalRunner) {
276
277
  </UFormField>
277
278
 
278
279
  <UFormField :label="t('settings.localModelEndpoints.apiKeyOptional')">
279
- <UInput
280
+ <SecretInput
280
281
  v-model="apiKey"
281
- type="password"
282
282
  class="font-mono"
283
283
  :placeholder="
284
284
  existing?.hasApiKey
@@ -7,6 +7,7 @@
7
7
  import { computed, reactive, ref, watch } from 'vue'
8
8
  import type { ObservabilityProviderKind } from '~/types/releaseHealth'
9
9
  import IntegrationBackTitle from '~/components/layout/IntegrationBackTitle.vue'
10
+ import SecretInput from '~/components/common/SecretInput.vue'
10
11
 
11
12
  const { t } = useI18n()
12
13
  const ui = useUiStore()
@@ -194,17 +195,11 @@ const connectedLabel = computed(() => {
194
195
  <UInput v-model="datadog.site" placeholder="datadoghq.com" class="w-full" />
195
196
  </UFormField>
196
197
  <UFormField :label="t('settings.observabilityConnection.datadog.apiKey')">
197
- <UInput
198
- v-model="datadog.apiKey"
199
- type="password"
200
- placeholder="DD-API-KEY"
201
- class="w-full"
202
- />
198
+ <SecretInput v-model="datadog.apiKey" placeholder="DD-API-KEY" class="w-full" />
203
199
  </UFormField>
204
200
  <UFormField :label="t('settings.observabilityConnection.datadog.appKey')">
205
- <UInput
201
+ <SecretInput
206
202
  v-model="datadog.appKey"
207
- type="password"
208
203
  placeholder="DD-APPLICATION-KEY"
209
204
  class="w-full"
210
205
  />
@@ -254,7 +249,7 @@ const connectedLabel = computed(() => {
254
249
  </p>
255
250
 
256
251
  <UFormField :label="t('settings.observabilityConnection.incident.pagerDutyToken')">
257
- <UInput v-model="pagerDuty.apiToken" type="password" class="w-full" />
252
+ <SecretInput v-model="pagerDuty.apiToken" class="w-full" />
258
253
  </UFormField>
259
254
  <UFormField :label="t('settings.observabilityConnection.incident.pagerDutyFromEmail')">
260
255
  <UInput
@@ -265,7 +260,7 @@ const connectedLabel = computed(() => {
265
260
  />
266
261
  </UFormField>
267
262
  <UFormField :label="t('settings.observabilityConnection.incident.incidentIoKey')">
268
- <UInput v-model="incidentIo.apiKey" type="password" class="w-full" />
263
+ <SecretInput v-model="incidentIo.apiKey" class="w-full" />
269
264
  </UFormField>
270
265
 
271
266
  <div class="flex gap-2">
@@ -10,6 +10,7 @@
10
10
  import { computed, ref, watch } from 'vue'
11
11
  import type { OpenRouterModelMeta } from '~/types/openrouter'
12
12
  import IntegrationBackTitle from '~/components/layout/IntegrationBackTitle.vue'
13
+ import SecretInput from '~/components/common/SecretInput.vue'
13
14
 
14
15
  const { t } = useI18n()
15
16
  const ui = useUiStore()
@@ -276,11 +277,10 @@ function manageKeys() {
276
277
  </UFormField>
277
278
  </div>
278
279
  <UFormField :label="t('settings.openRouterCatalog.apiKey')">
279
- <UTextarea
280
+ <SecretInput
280
281
  v-model="keyValue"
281
- :rows="2"
282
282
  placeholder="paste your OpenRouter key (sk-or-…)"
283
- class="font-mono"
283
+ class="w-full font-mono"
284
284
  />
285
285
  </UFormField>
286
286
  <div class="flex justify-end">
@@ -7,6 +7,7 @@
7
7
  import { computed, reactive, ref, watch } from 'vue'
8
8
  import type { PackageRegistryVendor } from '~/types/packageRegistries'
9
9
  import IntegrationBackTitle from '~/components/layout/IntegrationBackTitle.vue'
10
+ import SecretInput from '~/components/common/SecretInput.vue'
10
11
 
11
12
  const { t } = useI18n()
12
13
  const ui = useUiStore()
@@ -199,12 +200,7 @@ async function removeEntry(entryId: string) {
199
200
  </UFormField>
200
201
 
201
202
  <UFormField :label="t('settings.packageRegistries.add.token')">
202
- <UInput
203
- v-model="form.token"
204
- type="password"
205
- class="w-full"
206
- data-testid="package-registry-token"
207
- />
203
+ <SecretInput v-model="form.token" class="w-full" data-testid="package-registry-token" />
208
204
  </UFormField>
209
205
 
210
206
  <UButton
@@ -18,6 +18,7 @@ import { computed, ref, watch } from 'vue'
18
18
  import * as v from 'valibot'
19
19
  import { environmentManifestSchema, runnerPoolManifestSchema } from '@cat-factory/contracts'
20
20
  import type { ProviderConnectionKind } from '~/types/providerConnections'
21
+ import SecretInput from '~/components/common/SecretInput.vue'
21
22
 
22
23
  const props = defineProps<{
23
24
  kind: ProviderConnectionKind
@@ -255,9 +256,8 @@ function onSave() {
255
256
  </p>
256
257
  </template>
257
258
  <UFormField v-for="key in secretKeys" :key="key" :label="key">
258
- <UInput
259
+ <SecretInput
259
260
  v-model="secrets[key]"
260
- type="password"
261
261
  class="w-full font-mono"
262
262
  autocomplete="off"
263
263
  :data-testid="`manifest-editor-secret-${key}`"
@@ -7,6 +7,7 @@
7
7
  import { computed, ref, watch } from 'vue'
8
8
  import type { ProviderConfigField, UserSecretKind } from '~/types/userSecrets'
9
9
  import IntegrationBackTitle from '~/components/layout/IntegrationBackTitle.vue'
10
+ import SecretInput from '~/components/common/SecretInput.vue'
10
11
 
11
12
  const { t } = useI18n()
12
13
  const ui = useUiStore()
@@ -207,9 +208,9 @@ async function remove() {
207
208
  "
208
209
  :help="field.help"
209
210
  >
210
- <UInput
211
+ <SecretInput
211
212
  v-model="values[field.key]"
212
- :type="field.secret ? 'password' : 'text'"
213
+ :secret="!!field.secret"
213
214
  class="font-mono"
214
215
  :placeholder="field.placeholder"
215
216
  />
@@ -8,6 +8,7 @@ import { computed, reactive, ref, watch } from 'vue'
8
8
  import type { NotificationType } from '~/types/notifications'
9
9
  import type { SlackMemberMappingEntry, SlackMemberRole, SlackRoute } from '~/types/slack'
10
10
  import IntegrationBackTitle from '~/components/layout/IntegrationBackTitle.vue'
11
+ import SecretInput from '~/components/common/SecretInput.vue'
11
12
 
12
13
  const ui = useUiStore()
13
14
  const slack = useSlackStore()
@@ -188,13 +189,7 @@ async function saveMapping() {
188
189
  {{ t('slack.connect.orPasteToken') }}
189
190
  </span>
190
191
  <div class="flex gap-2">
191
- <UInput
192
- v-model="tokenInput"
193
- size="sm"
194
- class="flex-1"
195
- type="password"
196
- placeholder="xoxb-…"
197
- />
192
+ <SecretInput v-model="tokenInput" size="sm" class="flex-1" placeholder="xoxb-…" />
198
193
  <UButton
199
194
  color="primary"
200
195
  variant="soft"
@@ -4,8 +4,15 @@ import { useVueFlow } from '@vue-flow/core'
4
4
  * useVueFlow() from anywhere (e.g. the toolbar) accesses the same instance. */
5
5
  export const BOARD_FLOW_ID = 'board'
6
6
 
7
+ /** Camera zoom clamps — the single source of truth shared by the canvas (which passes
8
+ * them to <VueFlow>) and the toolbar (which disables its zoom buttons at the limits). */
9
+ export const BOARD_MIN_ZOOM = 0.2
10
+ export const BOARD_MAX_ZOOM = 3
11
+
7
12
  /** Camera controls for the main board, usable outside the canvas component. */
8
13
  export function useBoardFlow() {
9
- const { fitView, zoomIn, zoomOut, viewport } = useVueFlow(BOARD_FLOW_ID)
10
- return { fitView, zoomIn, zoomOut, viewport }
14
+ const { fitView, zoomIn, zoomOut, zoomTo, viewport } = useVueFlow(BOARD_FLOW_ID)
15
+ /** Snap the camera back to 100% zoom (keeps the current centre). */
16
+ const resetZoom = () => zoomTo(1, { duration: 250 })
17
+ return { fitView, zoomIn, zoomOut, zoomTo, resetZoom, viewport }
11
18
  }
package/app/utils/dnd.ts CHANGED
@@ -22,8 +22,9 @@ export function readDndPayload(event: DragEvent): DndPayload | null {
22
22
  }
23
23
  }
24
24
 
25
- /** Walk up from the drop target to find the block it landed on, if any. */
26
- export function blockIdFromEvent(event: DragEvent): string | null {
25
+ /** Walk up from an event's target to find the block it landed on, if any. Works for any
26
+ * DOM event (drop, double-click, …) only `event.target` is read. */
27
+ export function blockIdFromEvent(event: Event): string | null {
27
28
  const el = (event.target as HTMLElement | null)?.closest('[data-block-id]')
28
29
  return el?.getAttribute('data-block-id') ?? null
29
30
  }
@@ -21,6 +21,8 @@
21
21
  "retry": "Retry",
22
22
  "copy": "Copy",
23
23
  "copied": "Copied to clipboard",
24
+ "reveal": "Reveal",
25
+ "hide": "Hide",
24
26
  "delete": "Delete",
25
27
  "remove": "Remove",
26
28
  "confirm": {
@@ -124,7 +126,11 @@
124
126
  "spendTitle": "Token spend this month",
125
127
  "spendLimitReached": "Spend limit reached — runs paused",
126
128
  "serviceAdded": "Added {title}",
127
- "serviceAddFailed": "Could not add service"
129
+ "serviceAddFailed": "Could not add service",
130
+ "zoomOut": "Zoom out",
131
+ "zoomIn": "Zoom in",
132
+ "fitView": "Fit board to content",
133
+ "resetZoom": "Reset zoom to 100%"
128
134
  },
129
135
  "canvas": {
130
136
  "emptyTitle": "Your board is empty",
@@ -40,6 +40,8 @@
40
40
  "reconfigureHint": "Puedes volver a configurarlo más tarde."
41
41
  },
42
42
  "copied": "Copiado al portapapeles",
43
+ "reveal": "Mostrar",
44
+ "hide": "Ocultar",
43
45
  "disconnect": "Desconectar",
44
46
  "copyFailed": "No se pudo copiar al portapapeles",
45
47
  "revoke": "Revocar",
@@ -106,7 +108,11 @@
106
108
  "spendTitle": "Gasto de tokens este mes",
107
109
  "spendLimitReached": "Límite de gasto alcanzado: ejecuciones en pausa",
108
110
  "serviceAdded": "Se añadió {title}",
109
- "serviceAddFailed": "No se pudo añadir el servicio"
111
+ "serviceAddFailed": "No se pudo añadir el servicio",
112
+ "zoomOut": "Alejar",
113
+ "zoomIn": "Acercar",
114
+ "fitView": "Ajustar al contenido",
115
+ "resetZoom": "Restablecer zoom al 100%"
110
116
  },
111
117
  "canvas": {
112
118
  "emptyTitle": "Tu tablero está vacío",
@@ -40,6 +40,8 @@
40
40
  "reconfigureHint": "Vous pourrez le reconfigurer plus tard."
41
41
  },
42
42
  "copied": "Copié dans le presse-papiers",
43
+ "reveal": "Afficher",
44
+ "hide": "Masquer",
43
45
  "disconnect": "Déconnecter",
44
46
  "copyFailed": "Échec de la copie dans le presse-papiers",
45
47
  "revoke": "Révoquer",
@@ -106,7 +108,11 @@
106
108
  "spendTitle": "Dépense de tokens ce mois-ci",
107
109
  "spendLimitReached": "Limite de dépense atteinte : exécutions en pause",
108
110
  "serviceAdded": "{title} ajouté",
109
- "serviceAddFailed": "Impossible d’ajouter le service"
111
+ "serviceAddFailed": "Impossible d’ajouter le service",
112
+ "zoomOut": "Dézoomer",
113
+ "zoomIn": "Zoomer",
114
+ "fitView": "Ajuster au contenu",
115
+ "resetZoom": "Réinitialiser le zoom à 100 %"
110
116
  },
111
117
  "canvas": {
112
118
  "emptyTitle": "Votre tableau est vide",
@@ -40,6 +40,8 @@
40
40
  "reconfigureHint": "אפשר להגדיר זאת שוב מאוחר יותר."
41
41
  },
42
42
  "copied": "הועתק ללוח",
43
+ "reveal": "הצג",
44
+ "hide": "הסתר",
43
45
  "disconnect": "נתק",
44
46
  "copyFailed": "לא ניתן להעתיק ללוח",
45
47
  "revoke": "לבטל",
@@ -106,7 +108,11 @@
106
108
  "spendTitle": "צריכת טוקנים החודש",
107
109
  "spendLimitReached": "הוגעה למגבלת הצריכה — הריצות הושהו",
108
110
  "serviceAdded": "{title} נוסף",
109
- "serviceAddFailed": "לא ניתן היה להוסיף את השירות"
111
+ "serviceAddFailed": "לא ניתן היה להוסיף את השירות",
112
+ "zoomOut": "הקטנה",
113
+ "zoomIn": "הגדלה",
114
+ "fitView": "התאמה לתוכן",
115
+ "resetZoom": "איפוס התקריב ל-100%"
110
116
  },
111
117
  "canvas": {
112
118
  "emptyTitle": "הלוח שלך ריק",
@@ -40,6 +40,8 @@
40
40
  "reconfigureHint": "後で再設定できます。"
41
41
  },
42
42
  "copied": "クリップボードにコピーしました",
43
+ "reveal": "表示",
44
+ "hide": "非表示",
43
45
  "disconnect": "切断",
44
46
  "copyFailed": "クリップボードにコピーできませんでした",
45
47
  "revoke": "取り消す",
@@ -106,7 +108,11 @@
106
108
  "spendTitle": "今月のトークン消費量",
107
109
  "spendLimitReached": "消費上限に達しました。実行を一時停止しています",
108
110
  "serviceAdded": "{title} を追加しました",
109
- "serviceAddFailed": "サービスを追加できませんでした"
111
+ "serviceAddFailed": "サービスを追加できませんでした",
112
+ "zoomOut": "縮小",
113
+ "zoomIn": "拡大",
114
+ "fitView": "内容に合わせる",
115
+ "resetZoom": "ズームを100%にリセット"
110
116
  },
111
117
  "canvas": {
112
118
  "emptyTitle": "ボードが空です",
@@ -40,6 +40,8 @@
40
40
  "reconfigureHint": "Możesz skonfigurować to ponownie później."
41
41
  },
42
42
  "copied": "Skopiowano do schowka",
43
+ "reveal": "Pokaż",
44
+ "hide": "Ukryj",
43
45
  "disconnect": "Rozłącz",
44
46
  "copyFailed": "Nie udało się skopiować do schowka",
45
47
  "revoke": "Unieważnij",
@@ -106,7 +108,11 @@
106
108
  "spendTitle": "Wydatki na tokeny w tym miesiącu",
107
109
  "spendLimitReached": "Osiągnięto limit wydatków: uruchomienia wstrzymane",
108
110
  "serviceAdded": "Dodano {title}",
109
- "serviceAddFailed": "Nie udało się dodać usługi"
111
+ "serviceAddFailed": "Nie udało się dodać usługi",
112
+ "zoomOut": "Pomniejsz",
113
+ "zoomIn": "Powiększ",
114
+ "fitView": "Dopasuj do zawartości",
115
+ "resetZoom": "Zresetuj powiększenie do 100%"
110
116
  },
111
117
  "canvas": {
112
118
  "emptyTitle": "Twoja tablica jest pusta",
@@ -40,6 +40,8 @@
40
40
  "reconfigureHint": "Bunu daha sonra yeniden yapılandırabilirsiniz."
41
41
  },
42
42
  "copied": "Panoya kopyalandı",
43
+ "reveal": "Göster",
44
+ "hide": "Gizle",
43
45
  "disconnect": "Bağlantıyı kes",
44
46
  "copyFailed": "Panoya kopyalanamadı",
45
47
  "revoke": "İptal et",
@@ -106,7 +108,11 @@
106
108
  "spendTitle": "Bu ayki token harcaması",
107
109
  "spendLimitReached": "Harcama limitine ulaşıldı, çalıştırmalar duraklatıldı",
108
110
  "serviceAdded": "{title} eklendi",
109
- "serviceAddFailed": "Servis eklenemedi"
111
+ "serviceAddFailed": "Servis eklenemedi",
112
+ "zoomOut": "Uzaklaştır",
113
+ "zoomIn": "Yakınlaştır",
114
+ "fitView": "İçeriğe sığdır",
115
+ "resetZoom": "Yakınlaştırmayı %100'e sıfırla"
110
116
  },
111
117
  "canvas": {
112
118
  "emptyTitle": "Panonuz boş",
@@ -40,6 +40,8 @@
40
40
  "reconfigureHint": "Ви зможете налаштувати це знову пізніше."
41
41
  },
42
42
  "copied": "Скопійовано в буфер обміну",
43
+ "reveal": "Показати",
44
+ "hide": "Приховати",
43
45
  "disconnect": "Від'єднати",
44
46
  "copyFailed": "Не вдалося скопіювати до буфера обміну",
45
47
  "revoke": "Відкликати",
@@ -106,7 +108,11 @@
106
108
  "spendTitle": "Витрати на токени цього місяця",
107
109
  "spendLimitReached": "Досягнуто ліміт витрат: запуски призупинено",
108
110
  "serviceAdded": "Додано {title}",
109
- "serviceAddFailed": "Не вдалося додати сервіс"
111
+ "serviceAddFailed": "Не вдалося додати сервіс",
112
+ "zoomOut": "Зменшити",
113
+ "zoomIn": "Збільшити",
114
+ "fitView": "Вписати в область",
115
+ "resetZoom": "Скинути масштаб до 100%"
110
116
  },
111
117
  "canvas": {
112
118
  "emptyTitle": "Ваша дошка порожня",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.96.3",
3
+ "version": "0.96.5",
4
4
  "description": "Reusable Nuxt layer for the Agent Architecture Board SPA (components, stores, composables, pages). Consume it from a thin deployment app via `extends: ['@cat-factory/app']` and point it at your backend with NUXT_PUBLIC_API_BASE. See deploy/frontend for an example.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -34,7 +34,7 @@
34
34
  "valibot": "^1.4.2",
35
35
  "vue": "3.5.39",
36
36
  "wretch": "^3.0.9",
37
- "@cat-factory/contracts": "0.106.0"
37
+ "@cat-factory/contracts": "0.107.0"
38
38
  },
39
39
  "devDependencies": {
40
40
  "@toad-contracts/testing": "0.3.2",