@cat-factory/app 0.149.0 → 0.150.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.
@@ -210,6 +210,23 @@ async function add() {
210
210
  }
211
211
  }
212
212
 
213
+ /** Route an update to the scope the key lives in (account / workspace / user). */
214
+ async function updateKey(k: ApiKey, patch: { enabled?: boolean; isDefault?: boolean }) {
215
+ try {
216
+ if (k.scope === 'account') await keys.updateAccountKey(k.id, patch)
217
+ else if (k.scope === 'workspace') await keys.updateWorkspaceKey(k.id, patch)
218
+ else await keys.updateUserKey(k.id, patch)
219
+ // Enabling/disabling changes provider selectability in the picker — refresh it.
220
+ if (workspace.workspaceId) await models.refresh(workspace.workspaceId)
221
+ } catch (e) {
222
+ toast.add({
223
+ title: t('providers.apiKeys.toast.updateFailed'),
224
+ description: e instanceof Error ? e.message : String(e),
225
+ color: 'error',
226
+ })
227
+ }
228
+ }
229
+
213
230
  async function remove(k: ApiKey) {
214
231
  const noun = t('providers.apiKeys.keyNoun')
215
232
  if (!(await confirmAction('remove', noun))) return
@@ -352,10 +369,14 @@ async function remove(k: ApiKey) {
352
369
  v-for="k in connected"
353
370
  :key="k.id"
354
371
  class="flex items-center justify-between rounded-md border border-slate-700 bg-slate-900/50 px-3 py-2 text-sm"
372
+ :class="{ 'opacity-55': !k.enabled }"
355
373
  >
356
374
  <div>
357
375
  <span class="font-medium text-slate-200">{{ k.label }}</span>
358
376
  <span class="ms-2 text-xs text-slate-500">{{ providerLabel(k.provider) }}</span>
377
+ <UBadge v-if="!k.enabled" color="neutral" variant="subtle" size="sm" class="ms-2">
378
+ {{ t('providers.apiKeys.disabledBadge') }}
379
+ </UBadge>
359
380
  <div class="text-[11px] tabular-nums text-slate-500">
360
381
  {{
361
382
  t(
@@ -366,13 +387,32 @@ async function remove(k: ApiKey) {
366
387
  }}
367
388
  </div>
368
389
  </div>
369
- <UButton
370
- icon="i-lucide-trash-2"
371
- color="error"
372
- variant="ghost"
373
- size="xs"
374
- @click="remove(k)"
375
- />
390
+ <div class="flex items-center gap-2">
391
+ <UButton
392
+ :icon="k.isDefault ? 'i-lucide-star' : 'i-lucide-star-off'"
393
+ :color="k.isDefault ? 'primary' : 'neutral'"
394
+ :variant="k.isDefault ? 'subtle' : 'ghost'"
395
+ size="xs"
396
+ @click="updateKey(k, { isDefault: !k.isDefault })"
397
+ >
398
+ {{
399
+ k.isDefault ? t('providers.apiKeys.defaultBadge') : t('providers.apiKeys.pinDefault')
400
+ }}
401
+ </UButton>
402
+ <USwitch
403
+ :model-value="k.enabled"
404
+ size="sm"
405
+ :aria-label="t('providers.apiKeys.enableToggle')"
406
+ @update:model-value="(v: boolean) => updateKey(k, { enabled: v })"
407
+ />
408
+ <UButton
409
+ icon="i-lucide-trash-2"
410
+ color="error"
411
+ variant="ghost"
412
+ size="xs"
413
+ @click="remove(k)"
414
+ />
415
+ </div>
376
416
  </div>
377
417
  </div>
378
418
  </div>
@@ -141,6 +141,30 @@ async function add() {
141
141
  }
142
142
  }
143
143
 
144
+ async function toggleEnabled(cred: { id: string }, enabled: boolean) {
145
+ try {
146
+ await creds.update(cred.id, { enabled })
147
+ } catch (e) {
148
+ toast.add({
149
+ title: t('providers.vendorCredentials.toast.updateFailed'),
150
+ description: e instanceof Error ? e.message : String(e),
151
+ color: 'error',
152
+ })
153
+ }
154
+ }
155
+
156
+ async function toggleDefault(cred: { id: string; isDefault: boolean }) {
157
+ try {
158
+ await creds.update(cred.id, { isDefault: !cred.isDefault })
159
+ } catch (e) {
160
+ toast.add({
161
+ title: t('providers.vendorCredentials.toast.updateFailed'),
162
+ description: e instanceof Error ? e.message : String(e),
163
+ color: 'error',
164
+ })
165
+ }
166
+ }
167
+
144
168
  async function remove(cred: { id: string; label: string }) {
145
169
  const ok = await confirm({
146
170
  title: t('providers.vendorCredentials.confirmRemove.title'),
@@ -245,10 +269,14 @@ function vendorLabel(v: SubscriptionVendor): string {
245
269
  v-for="c in creds.credentials"
246
270
  :key="c.id"
247
271
  class="flex items-center justify-between rounded-md border border-slate-700 bg-slate-900/50 px-3 py-2 text-sm"
272
+ :class="{ 'opacity-55': !c.enabled }"
248
273
  >
249
274
  <div>
250
275
  <span class="font-medium text-slate-200">{{ c.label }}</span>
251
276
  <span class="ms-2 text-xs text-slate-500">{{ vendorLabel(c.vendor) }}</span>
277
+ <UBadge v-if="!c.enabled" color="neutral" variant="subtle" size="sm" class="ms-2">
278
+ {{ t('providers.vendorCredentials.disabledBadge') }}
279
+ </UBadge>
252
280
  <div class="text-[11px] tabular-nums text-slate-500">
253
281
  {{
254
282
  t(
@@ -262,13 +290,34 @@ function vendorLabel(v: SubscriptionVendor): string {
262
290
  }}
263
291
  </div>
264
292
  </div>
265
- <UButton
266
- icon="i-lucide-trash-2"
267
- color="error"
268
- variant="ghost"
269
- size="xs"
270
- @click="remove(c)"
271
- />
293
+ <div class="flex items-center gap-2">
294
+ <UButton
295
+ :icon="c.isDefault ? 'i-lucide-star' : 'i-lucide-star-off'"
296
+ :color="c.isDefault ? 'primary' : 'neutral'"
297
+ :variant="c.isDefault ? 'subtle' : 'ghost'"
298
+ size="xs"
299
+ @click="toggleDefault(c)"
300
+ >
301
+ {{
302
+ c.isDefault
303
+ ? t('providers.vendorCredentials.defaultBadge')
304
+ : t('providers.vendorCredentials.pinDefault')
305
+ }}
306
+ </UButton>
307
+ <USwitch
308
+ :model-value="c.enabled"
309
+ size="sm"
310
+ :aria-label="t('providers.vendorCredentials.enableToggle')"
311
+ @update:model-value="(v: boolean) => toggleEnabled(c, v)"
312
+ />
313
+ <UButton
314
+ icon="i-lucide-trash-2"
315
+ color="error"
316
+ variant="ghost"
317
+ size="xs"
318
+ @click="remove(c)"
319
+ />
320
+ </div>
272
321
  </div>
273
322
  </div>
274
323
  </div>
@@ -77,6 +77,44 @@ const schemeItems = computed(() => [
77
77
  { label: 'http', value: 'http' },
78
78
  ])
79
79
 
80
+ // A stored non-secret value is `unknown`; coerce it to the string the form field holds,
81
+ // defaulting a missing/wrong-typed value to '' (the same `typeof … === 'string' ? … : ''`
82
+ // each field used inline before).
83
+ function readString(v: unknown): string {
84
+ return typeof v === 'string' ? v : ''
85
+ }
86
+
87
+ // Prefill the manifest-source fields from the stored discriminated config.
88
+ function applyManifestSource(k: Record<string, unknown>): void {
89
+ const src = k.manifestSource as Record<string, unknown> | undefined
90
+ if (src?.type === 'separate') {
91
+ form.manifestSourceType = 'separate'
92
+ form.manifestRepo = readString(src.repo)
93
+ form.manifestRef = readString(src.ref)
94
+ form.manifestPath = readString(src.path)
95
+ } else if (src?.type === 'colocated') {
96
+ form.manifestSourceType = 'colocated'
97
+ form.manifestPath = readString(src.path)
98
+ }
99
+ }
100
+
101
+ // Prefill the URL-derivation fields (+ scheme) from the stored discriminated config.
102
+ function applyUrl(k: Record<string, unknown>): void {
103
+ const url = k.url as Record<string, unknown> | undefined
104
+ if (url?.source === 'ingressTemplate') {
105
+ form.urlSource = 'ingressTemplate'
106
+ form.hostTemplate = readString(url.hostTemplate)
107
+ } else if (url?.source === 'ingressStatus') {
108
+ form.urlSource = 'ingressStatus'
109
+ form.ingressName = readString(url.ingressName)
110
+ } else if (url?.source === 'serviceStatus') {
111
+ form.urlSource = 'serviceStatus'
112
+ form.serviceName = readString(url.serviceName)
113
+ form.servicePort = typeof url.port === 'number' ? String(url.port) : ''
114
+ }
115
+ form.urlScheme = url && (url.scheme === 'http' || url.scheme === 'https') ? url.scheme : 'default'
116
+ }
117
+
80
118
  // A registered k8s env connection exposes its non-secret config, so prefill every
81
119
  // non-secret field from it (never the token — secrets are write-only and re-entered on
82
120
  // update). This lets an edit change one field without re-typing the whole form.
@@ -89,36 +127,14 @@ watch(
89
127
  ? (c.config as { kubernetes: Record<string, unknown> }).kubernetes
90
128
  : undefined
91
129
  if (!k) return
92
- form.label = typeof k.label === 'string' ? k.label : ''
93
- form.apiServerUrl = typeof k.apiServerUrl === 'string' ? k.apiServerUrl : ''
94
- form.caCertPem = typeof k.caCertPem === 'string' ? k.caCertPem : ''
130
+ form.label = readString(k.label)
131
+ form.apiServerUrl = readString(k.apiServerUrl)
132
+ form.caCertPem = readString(k.caCertPem)
95
133
  form.insecureSkipTlsVerify = k.insecureSkipTlsVerify === true
96
- form.namespaceTemplate = typeof k.namespaceTemplate === 'string' ? k.namespaceTemplate : ''
97
- form.imageTemplate = typeof k.imageTemplate === 'string' ? k.imageTemplate : ''
98
- const src = k.manifestSource as Record<string, unknown> | undefined
99
- if (src?.type === 'separate') {
100
- form.manifestSourceType = 'separate'
101
- form.manifestRepo = typeof src.repo === 'string' ? src.repo : ''
102
- form.manifestRef = typeof src.ref === 'string' ? src.ref : ''
103
- form.manifestPath = typeof src.path === 'string' ? src.path : ''
104
- } else if (src?.type === 'colocated') {
105
- form.manifestSourceType = 'colocated'
106
- form.manifestPath = typeof src.path === 'string' ? src.path : ''
107
- }
108
- const url = k.url as Record<string, unknown> | undefined
109
- if (url?.source === 'ingressTemplate') {
110
- form.urlSource = 'ingressTemplate'
111
- form.hostTemplate = typeof url.hostTemplate === 'string' ? url.hostTemplate : ''
112
- } else if (url?.source === 'ingressStatus') {
113
- form.urlSource = 'ingressStatus'
114
- form.ingressName = typeof url.ingressName === 'string' ? url.ingressName : ''
115
- } else if (url?.source === 'serviceStatus') {
116
- form.urlSource = 'serviceStatus'
117
- form.serviceName = typeof url.serviceName === 'string' ? url.serviceName : ''
118
- form.servicePort = typeof url.port === 'number' ? String(url.port) : ''
119
- }
120
- form.urlScheme =
121
- url && (url.scheme === 'http' || url.scheme === 'https') ? url.scheme : 'default'
134
+ form.namespaceTemplate = readString(k.namespaceTemplate)
135
+ form.imageTemplate = readString(k.imageTemplate)
136
+ applyManifestSource(k)
137
+ applyUrl(k)
122
138
  },
123
139
  { immediate: true },
124
140
  )
@@ -23,6 +23,10 @@ import {
23
23
  setServiceFragmentDefaultsContract,
24
24
  storePersonalSubscriptionContract,
25
25
  testLocalModelEndpointContract,
26
+ updateAccountApiKeyContract,
27
+ updateUserApiKeyContract,
28
+ updateVendorCredentialContract,
29
+ updateWorkspaceApiKeyContract,
26
30
  upsertLocalModelEndpointContract,
27
31
  upsertOpenRouterCatalogContract,
28
32
  } from '@cat-factory/contracts'
@@ -30,6 +34,8 @@ import type {
30
34
  AddApiKeyInput,
31
35
  StorePersonalSubscriptionInput,
32
36
  SubscriptionVendor,
37
+ UpdateApiKeyInput,
38
+ UpdateVendorCredentialInput,
33
39
  } from '~/types/domain'
34
40
  import type {
35
41
  LocalRunner,
@@ -60,16 +66,26 @@ export function modelsApi({ send, ws }: ApiContext) {
60
66
  send(listWorkspaceApiKeysContract, { pathPrefix: ws(workspaceId) }),
61
67
  addWorkspaceApiKey: (workspaceId: string, body: AddApiKeyInput) =>
62
68
  send(addWorkspaceApiKeyContract, { pathPrefix: ws(workspaceId), body }),
69
+ updateWorkspaceApiKey: (workspaceId: string, id: string, body: UpdateApiKeyInput) =>
70
+ send(updateWorkspaceApiKeyContract, {
71
+ pathPrefix: ws(workspaceId),
72
+ pathParams: { id },
73
+ body,
74
+ }),
63
75
  removeWorkspaceApiKey: (workspaceId: string, id: string) =>
64
76
  send(removeWorkspaceApiKeyContract, { pathPrefix: ws(workspaceId), pathParams: { id } }),
65
77
  listMyApiKeys: () => send(listUserApiKeysContract, {}),
66
78
  addMyApiKey: (body: AddApiKeyInput) => send(addUserApiKeyContract, { body }),
79
+ updateMyApiKey: (id: string, body: UpdateApiKeyInput) =>
80
+ send(updateUserApiKeyContract, { pathParams: { id }, body }),
67
81
  removeMyApiKey: (id: string) => send(removeUserApiKeyContract, { pathParams: { id } }),
68
82
  // Account-scoped keys (shared by every workspace in the account); admin-only.
69
83
  listAccountApiKeys: (accountId: string) =>
70
84
  send(listAccountApiKeysContract, { pathParams: { accountId } }),
71
85
  addAccountApiKey: (accountId: string, body: AddApiKeyInput) =>
72
86
  send(addAccountApiKeyContract, { pathParams: { accountId }, body }),
87
+ updateAccountApiKey: (accountId: string, id: string, body: UpdateApiKeyInput) =>
88
+ send(updateAccountApiKeyContract, { pathParams: { accountId, id }, body }),
73
89
  removeAccountApiKey: (accountId: string, id: string) =>
74
90
  send(removeAccountApiKeyContract, { pathParams: { accountId, id } }),
75
91
 
@@ -80,6 +96,12 @@ export function modelsApi({ send, ws }: ApiContext) {
80
96
  workspaceId: string,
81
97
  body: { vendor: SubscriptionVendor; label: string; token: string },
82
98
  ) => send(addVendorCredentialContract, { pathPrefix: ws(workspaceId), body }),
99
+ updateVendorCredential: (workspaceId: string, id: string, body: UpdateVendorCredentialInput) =>
100
+ send(updateVendorCredentialContract, {
101
+ pathPrefix: ws(workspaceId),
102
+ pathParams: { id },
103
+ body,
104
+ }),
83
105
  removeVendorCredential: (workspaceId: string, id: string) =>
84
106
  send(removeVendorCredentialContract, { pathPrefix: ws(workspaceId), pathParams: { id } }),
85
107