@cat-factory/app 0.46.12 → 0.47.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 (31) hide show
  1. package/app/components/auth/AuthGate.vue +1 -1
  2. package/app/components/auth/LoginScreen.vue +131 -1
  3. package/app/components/layout/AccountDeploymentSettings.vue +232 -5
  4. package/app/components/providers/AiPresetMismatchDialog.vue +26 -13
  5. package/app/components/providers/AiProviderOnboardingModal.vue +18 -15
  6. package/app/components/providers/ApiKeysSection.vue +105 -63
  7. package/app/components/providers/PersonalCredentialModal.vue +35 -29
  8. package/app/components/providers/PersonalSubscriptionSection.vue +17 -2
  9. package/app/components/providers/SignInRequiredNotice.vue +19 -0
  10. package/app/components/providers/VendorCredentialsModal.vue +82 -39
  11. package/app/components/settings/AccountSettingsPanel.vue +9 -6
  12. package/app/components/settings/IssueTrackerPanel.vue +114 -53
  13. package/app/components/settings/LocalModeSettingsPanel.vue +60 -28
  14. package/app/components/settings/LocalModelEndpointsPanel.vue +72 -27
  15. package/app/components/settings/MergeThresholdsPanel.vue +93 -45
  16. package/app/components/settings/ModelConfigurationPanel.vue +62 -36
  17. package/app/components/settings/ObservabilityConnectionPanel.vue +65 -35
  18. package/app/components/settings/OpenRouterCatalogPanel.vue +70 -40
  19. package/app/components/settings/ProviderConnectionPanel.vue +115 -61
  20. package/app/components/settings/ServiceFragmentDefaultsPanel.vue +9 -12
  21. package/app/components/settings/UserSecretsSection.vue +37 -18
  22. package/app/components/settings/WorkspaceSettingsPanel.vue +114 -62
  23. package/app/composables/api/auth.ts +7 -0
  24. package/app/stores/auth.ts +25 -1
  25. package/app/types/accountSettings.ts +4 -0
  26. package/i18n/locales/en.json +663 -0
  27. package/i18n/locales/es.json +651 -0
  28. package/i18n/locales/fr.json +651 -0
  29. package/i18n/locales/pl.json +651 -0
  30. package/i18n/locales/uk.json +651 -0
  31. package/package.json +2 -2
@@ -21,14 +21,21 @@ const props = withDefaults(defineProps<{ accountId?: string; category?: 'direct'
21
21
  category: 'direct',
22
22
  })
23
23
 
24
+ const { t, n } = useI18n()
24
25
  const workspace = useWorkspaceStore()
25
26
  const keys = useApiKeysStore()
26
27
  const models = useModelsStore()
28
+ const auth = useAuthStore()
27
29
  const toast = useToast()
28
30
 
29
31
  /** Account-wide mode (single account scope) vs the default workspace/user toggle. */
30
32
  const isAccount = computed(() => !!props.accountId)
31
33
 
34
+ // "My keys" (user scope) are stored per-user, so they need a signed-in user. Block just
35
+ // that scope when there's none (a deployment without sign-in); workspace/account keys are
36
+ // unaffected. The scope toggle stays enabled so the user can switch back to a shared scope.
37
+ const needsSignIn = computed(() => !isAccount.value && scope.value === 'user' && !auth.user)
38
+
32
39
  interface ProviderMeta {
33
40
  value: ApiKeyProvider
34
41
  label: string
@@ -43,84 +50,89 @@ interface ProviderMeta {
43
50
  caches?: boolean
44
51
  }
45
52
 
53
+ // Provider metadata. Labels + step instructions resolve through i18n (reactive to the
54
+ // locale), so each `t(...)` uses a literal key (kept tier-1 typed-key checkable); only the
55
+ // `value`/`url`/`caches` differentiators stay inline.
46
56
  /** Direct vendors: the key reaches that one vendor's own endpoint. */
47
- const DIRECT_PROVIDERS: ProviderMeta[] = [
57
+ const DIRECT_PROVIDERS = computed<ProviderMeta[]>(() => [
48
58
  {
49
59
  value: 'openai',
50
- label: 'OpenAI',
60
+ label: t('providers.apiKeys.providers.openai.label'),
51
61
  url: 'https://platform.openai.com/api-keys',
52
62
  steps: [
53
- 'Open platform.openai.com → API keys and create a new secret key.',
54
- 'Copy the key (starts with sk-…); it is shown only once.',
63
+ t('providers.apiKeys.providers.openai.step1'),
64
+ t('providers.apiKeys.providers.openai.step2'),
55
65
  ],
56
66
  caches: true,
57
67
  },
58
68
  {
59
69
  value: 'anthropic',
60
- label: 'Anthropic (Claude API)',
70
+ label: t('providers.apiKeys.providers.anthropic.label'),
61
71
  url: 'https://console.anthropic.com/settings/keys',
62
72
  steps: [
63
- 'Open console.anthropic.com → Settings → API Keys and create a key.',
64
- 'Copy the key (starts with sk-ant-…).',
73
+ t('providers.apiKeys.providers.anthropic.step1'),
74
+ t('providers.apiKeys.providers.anthropic.step2'),
65
75
  ],
66
76
  caches: true,
67
77
  },
68
78
  {
69
79
  value: 'qwen',
70
- label: 'Qwen (Alibaba DashScope)',
80
+ label: t('providers.apiKeys.providers.qwen.label'),
71
81
  url: 'https://dashscope.console.aliyun.com/apiKey',
72
82
  steps: [
73
- 'Open the DashScope console (international) → API-KEY and create a key.',
74
- 'Copy the key; it authenticates the OpenAI-compatible Qwen endpoint.',
83
+ t('providers.apiKeys.providers.qwen.step1'),
84
+ t('providers.apiKeys.providers.qwen.step2'),
75
85
  ],
76
86
  caches: true,
77
87
  },
78
88
  {
79
89
  value: 'deepseek',
80
- label: 'DeepSeek',
90
+ label: t('providers.apiKeys.providers.deepseek.label'),
81
91
  url: 'https://platform.deepseek.com/api_keys',
82
92
  steps: [
83
- 'Open platform.deepseek.com → API keys and create a key.',
84
- 'Copy the key (starts with sk-…).',
93
+ t('providers.apiKeys.providers.deepseek.step1'),
94
+ t('providers.apiKeys.providers.deepseek.step2'),
85
95
  ],
86
96
  caches: true,
87
97
  },
88
98
  {
89
99
  value: 'moonshot',
90
- label: 'Moonshot (Kimi API)',
100
+ label: t('providers.apiKeys.providers.moonshot.label'),
91
101
  url: 'https://platform.moonshot.ai/console/api-keys',
92
102
  steps: [
93
- 'Open platform.moonshot.ai → API Keys and create a key.',
94
- 'Copy the key; it authenticates the OpenAI-compatible Moonshot endpoint.',
103
+ t('providers.apiKeys.providers.moonshot.step1'),
104
+ t('providers.apiKeys.providers.moonshot.step2'),
95
105
  ],
96
106
  },
97
- ]
107
+ ])
98
108
 
99
109
  /** Proxies / gateways: one key fronts many vendors. These are intermediaries, not vendors. */
100
- const PROXY_PROVIDERS: ProviderMeta[] = [
110
+ const PROXY_PROVIDERS = computed<ProviderMeta[]>(() => [
101
111
  {
102
112
  value: 'openrouter',
103
- label: 'OpenRouter',
113
+ label: t('providers.apiKeys.providers.openrouter.label'),
104
114
  url: 'https://openrouter.ai/keys',
105
115
  steps: [
106
- 'Open openrouter.ai → Keys and create an API key.',
107
- 'Copy the key (starts with sk-or-…); it reaches 300+ models through one gateway.',
116
+ t('providers.apiKeys.providers.openrouter.step1'),
117
+ t('providers.apiKeys.providers.openrouter.step2'),
108
118
  ],
109
119
  },
110
120
  {
111
121
  value: 'litellm',
112
- label: 'LiteLLM (self-hosted gateway)',
122
+ label: t('providers.apiKeys.providers.litellm.label'),
113
123
  url: 'https://docs.litellm.ai/docs/proxy/virtual_keys',
114
124
  steps: [
115
- 'Generate a virtual key on your LiteLLM gateway (or use its master key).',
116
- "The gateway's base URL is set by your deployment operator (LITELLM_BASE_URL), not here.",
125
+ t('providers.apiKeys.providers.litellm.step1'),
126
+ t('providers.apiKeys.providers.litellm.step2'),
117
127
  ],
118
128
  },
119
- ]
129
+ ])
120
130
 
121
131
  /** Providers for the requested category; labels everywhere fall back to the full set. */
122
- const PROVIDERS = computed(() => (props.category === 'proxy' ? PROXY_PROVIDERS : DIRECT_PROVIDERS))
123
- const ALL_PROVIDERS = [...DIRECT_PROVIDERS, ...PROXY_PROVIDERS]
132
+ const PROVIDERS = computed(() =>
133
+ props.category === 'proxy' ? PROXY_PROVIDERS.value : DIRECT_PROVIDERS.value,
134
+ )
135
+ const ALL_PROVIDERS = computed(() => [...DIRECT_PROVIDERS.value, ...PROXY_PROVIDERS.value])
124
136
 
125
137
  const scope = ref<'workspace' | 'user'>('workspace')
126
138
  const provider = ref<ApiKeyProvider>(props.category === 'proxy' ? 'openrouter' : 'openai')
@@ -160,7 +172,7 @@ const connected = computed<ApiKey[]>(() => {
160
172
  })
161
173
 
162
174
  function providerLabel(p: ApiKeyProvider): string {
163
- return ALL_PROVIDERS.find((x) => x.value === p)?.label ?? p
175
+ return ALL_PROVIDERS.value.find((x) => x.value === p)?.label ?? p
164
176
  }
165
177
 
166
178
  async function add() {
@@ -169,7 +181,8 @@ async function add() {
169
181
  try {
170
182
  const input = {
171
183
  provider: provider.value,
172
- label: label.value.trim() || `${provider.value} key`,
184
+ label:
185
+ label.value.trim() || t('providers.apiKeys.defaultLabel', { provider: provider.value }),
173
186
  key: key.value.trim(),
174
187
  }
175
188
  if (isAccount.value) await keys.addAccountKey(input)
@@ -179,10 +192,14 @@ async function add() {
179
192
  label.value = ''
180
193
  // The picker's selectability depends on configured keys — refresh it.
181
194
  if (workspace.workspaceId) await models.refresh(workspace.workspaceId)
182
- toast.add({ title: 'API key connected', icon: 'i-lucide-check', color: 'success' })
195
+ toast.add({
196
+ title: t('providers.apiKeys.toast.connected'),
197
+ icon: 'i-lucide-check',
198
+ color: 'success',
199
+ })
183
200
  } catch (e) {
184
201
  toast.add({
185
- title: 'Could not connect key',
202
+ title: t('providers.apiKeys.toast.connectFailed'),
186
203
  description: e instanceof Error ? e.message : String(e),
187
204
  color: 'error',
188
205
  })
@@ -199,7 +216,7 @@ async function remove(k: ApiKey) {
199
216
  if (workspace.workspaceId) await models.refresh(workspace.workspaceId)
200
217
  } catch (e) {
201
218
  toast.add({
202
- title: 'Could not remove key',
219
+ title: t('providers.apiKeys.toast.removeFailed'),
203
220
  description: e instanceof Error ? e.message : String(e),
204
221
  color: 'error',
205
222
  })
@@ -211,57 +228,63 @@ async function remove(k: ApiKey) {
211
228
  <div class="space-y-4">
212
229
  <div>
213
230
  <h4 class="text-xs font-semibold uppercase tracking-wide text-slate-500">
214
- {{ category === 'proxy' ? 'Proxy / gateway API keys' : 'Direct provider API keys' }}
231
+ {{
232
+ category === 'proxy'
233
+ ? t('providers.apiKeys.proxyHeading')
234
+ : t('providers.apiKeys.directHeading')
235
+ }}
215
236
  </h4>
216
237
  <template v-if="category === 'proxy'">
217
238
  <p v-if="isAccount" class="mt-1 text-sm text-slate-400">
218
- Connect a <strong>proxy</strong> key (OpenRouter, LiteLLM) shared by
219
- <strong>every workspace</strong> in this account. A proxy is an intermediary that fronts
220
- many vendors behind a single key. Keys are stored encrypted, pooled, and rotated by usage.
239
+ {{ t('providers.apiKeys.proxyAccountIntro') }}
221
240
  </p>
222
241
  <p v-else class="mt-1 text-sm text-slate-400">
223
- Connect a <strong>proxy</strong> key (OpenRouter, LiteLLM). A proxy is an intermediary
224
- that reaches many vendors' models through a single gateway, rather than one vendor
225
- directly. Keys are stored encrypted, pooled, and rotated by usage. Scope a key to this
226
- <strong>workspace</strong> (shared with the team) or to <strong>you</strong> (your own
227
- pool, usable anywhere).
242
+ {{ t('providers.apiKeys.proxyIntro') }}
228
243
  </p>
229
244
  </template>
230
245
  <template v-else>
231
246
  <p v-if="isAccount" class="mt-1 text-sm text-slate-400">
232
- Connect a vendor API key shared by <strong>every workspace</strong> in this account. Keys
233
- are stored encrypted, pooled, and rotated by usage. Account keys are admin-managed.
247
+ {{ t('providers.apiKeys.directAccountIntro') }}
234
248
  </p>
235
249
  <p v-else class="mt-1 text-sm text-slate-400">
236
- Connect a vendor API key so models run directly on that provider. Keys are stored
237
- encrypted, pooled, and rotated by usage. Scope a key to this
238
- <strong>workspace</strong> (shared with the team) or to <strong>you</strong> (your own
239
- pool, usable anywhere).
250
+ {{ t('providers.apiKeys.directIntro') }}
240
251
  </p>
241
252
  </template>
242
253
  </div>
243
254
 
244
255
  <!-- scope + provider -->
245
256
  <div class="flex flex-wrap items-end gap-3">
246
- <UFormField v-if="!isAccount" label="Scope">
257
+ <UFormField v-if="!isAccount" :label="t('providers.apiKeys.scopeField')">
247
258
  <USelect
248
259
  v-model="scope"
249
260
  :items="[
250
- { label: 'This workspace', value: 'workspace' },
251
- { label: 'My keys (only me)', value: 'user' },
261
+ { label: t('providers.apiKeys.scopeWorkspace'), value: 'workspace' },
262
+ { label: t('providers.apiKeys.scopeUser'), value: 'user' },
252
263
  ]"
253
264
  class="w-48"
254
265
  />
255
266
  </UFormField>
256
- <UFormField :label="category === 'proxy' ? 'Proxy' : 'Provider'">
267
+ <UFormField
268
+ :label="
269
+ category === 'proxy'
270
+ ? t('providers.apiKeys.proxyField')
271
+ : t('providers.apiKeys.providerField')
272
+ "
273
+ >
257
274
  <USelect
258
275
  v-model="provider"
259
276
  :items="PROVIDERS.map((p) => ({ label: p.label, value: p.value }))"
277
+ :disabled="needsSignIn"
260
278
  class="w-64"
261
279
  />
262
280
  </UFormField>
263
281
  </div>
264
282
 
283
+ <ProvidersSignInRequiredNotice
284
+ v-if="needsSignIn"
285
+ :message="t('auth.signInRequired.userApiKeys')"
286
+ />
287
+
265
288
  <!-- where to get the key -->
266
289
  <ol
267
290
  class="list-decimal space-y-1.5 rounded-lg border border-slate-700 bg-slate-900/60 p-4 pl-8 text-sm text-slate-300"
@@ -274,7 +297,7 @@ async function remove(k: ApiKey) {
274
297
  rel="noopener noreferrer"
275
298
  class="text-primary-400 underline"
276
299
  >
277
- Open {{ selected.label }} keys ↗
300
+ {{ t('providers.apiKeys.openKeys', { provider: selected.label }) }}
278
301
  </a>
279
302
  </li>
280
303
  </ol>
@@ -283,21 +306,35 @@ async function remove(k: ApiKey) {
283
306
  the caching flavour, so long agentic runs stop re-billing the whole prompt. -->
284
307
  <p v-if="selected.caches" class="flex items-center gap-1.5 text-[12px] text-emerald-400/90">
285
308
  <UIcon name="i-lucide-zap" class="h-3.5 w-3.5 shrink-0" />
286
- Enables prompt caching for {{ selected.label }} models — a long multi-turn run reuses its
287
- cached prompt prefix instead of re-sending it every turn.
309
+ {{ t('providers.apiKeys.cachingNote', { provider: selected.label }) }}
288
310
  </p>
289
311
 
290
312
  <!-- add form -->
291
313
  <div class="space-y-2">
292
- <UFormField label="Label (optional)">
293
- <UInput v-model="label" placeholder="e.g. team key" />
314
+ <UFormField :label="t('providers.apiKeys.labelField')">
315
+ <UInput
316
+ v-model="label"
317
+ :disabled="needsSignIn"
318
+ :placeholder="t('providers.apiKeys.labelPlaceholder')"
319
+ />
294
320
  </UFormField>
295
- <UFormField label="API key">
296
- <UTextarea v-model="key" :rows="2" placeholder="paste the API key" class="font-mono" />
321
+ <UFormField :label="t('providers.apiKeys.keyField')">
322
+ <UTextarea
323
+ v-model="key"
324
+ :rows="2"
325
+ :disabled="needsSignIn"
326
+ :placeholder="t('providers.apiKeys.keyPlaceholder')"
327
+ class="font-mono"
328
+ />
297
329
  </UFormField>
298
330
  <div class="flex justify-end">
299
- <UButton :loading="busy" :disabled="!key.trim()" icon="i-lucide-plus" @click="add()">
300
- Connect
331
+ <UButton
332
+ :loading="busy"
333
+ :disabled="needsSignIn || !key.trim()"
334
+ icon="i-lucide-plus"
335
+ @click="add()"
336
+ >
337
+ {{ t('providers.apiKeys.connect') }}
301
338
  </UButton>
302
339
  </div>
303
340
  </div>
@@ -305,7 +342,7 @@ async function remove(k: ApiKey) {
305
342
  <!-- connected keys for the selected scope -->
306
343
  <div v-if="connected.length" class="space-y-2">
307
344
  <h5 class="text-xs font-semibold uppercase tracking-wide text-slate-500">
308
- Connected ({{ connected.length }})
345
+ {{ t('providers.apiKeys.connected', { count: connected.length }) }}
309
346
  </h5>
310
347
  <div
311
348
  v-for="k in connected"
@@ -316,8 +353,13 @@ async function remove(k: ApiKey) {
316
353
  <span class="font-medium text-slate-200">{{ k.label }}</span>
317
354
  <span class="ml-2 text-xs text-slate-500">{{ providerLabel(k.provider) }}</span>
318
355
  <div class="text-[11px] tabular-nums text-slate-500">
319
- {{ (k.inputTokens + k.outputTokens).toLocaleString() }} tok this window ·
320
- {{ k.requestCount }} call{{ k.requestCount === 1 ? '' : 's' }}
356
+ {{
357
+ t(
358
+ 'providers.apiKeys.usage',
359
+ { tokens: n(k.inputTokens + k.outputTokens, 'decimal'), count: k.requestCount },
360
+ k.requestCount,
361
+ )
362
+ }}
321
363
  </div>
322
364
  </div>
323
365
  <UButton
@@ -5,8 +5,8 @@
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 type { SubscriptionVendor } from '~/types/domain'
9
8
 
9
+ const { t } = useI18n()
10
10
  const personal = usePersonalSubscriptionsStore()
11
11
  const ui = useUiStore()
12
12
  const toast = useToast()
@@ -29,17 +29,18 @@ const needsConnect = computed(
29
29
  pending.value?.reason === 'no_subscription' || pending.value?.reason === 'subscription_expired',
30
30
  )
31
31
 
32
- const VENDOR_LABELS: Partial<Record<SubscriptionVendor, string>> = {
33
- claude: 'Claude',
34
- glm: 'GLM (Z.ai)',
35
- codex: 'ChatGPT (Codex)',
36
- }
37
-
38
32
  /** Display label for the pending vendor (falls back to the raw vendor string). */
39
33
  const vendorLabel = computed(() => {
40
- const v = pending.value?.vendor
41
- if (!v) return 'subscription'
42
- return VENDOR_LABELS[v] ?? v
34
+ switch (pending.value?.vendor) {
35
+ case 'claude':
36
+ return t('providers.personalCredential.vendors.claude')
37
+ case 'glm':
38
+ return t('providers.personalCredential.vendors.glm')
39
+ case 'codex':
40
+ return t('providers.personalCredential.vendors.codex')
41
+ default:
42
+ return pending.value?.vendor ?? t('providers.personalCredential.fallbackVendor')
43
+ }
43
44
  })
44
45
 
45
46
  watch(open, (isOpen) => {
@@ -49,13 +50,13 @@ watch(open, (isOpen) => {
49
50
  const title = computed(() => {
50
51
  switch (pending.value?.reason) {
51
52
  case 'wrong_password':
52
- return 'Incorrect personal password'
53
+ return t('providers.personalCredential.titleWrongPassword')
53
54
  case 'no_subscription':
54
- return `Connect your ${vendorLabel.value} subscription`
55
+ return t('providers.personalCredential.titleNoSubscription', { vendor: vendorLabel.value })
55
56
  case 'subscription_expired':
56
- return `Your ${vendorLabel.value} subscription expired`
57
+ return t('providers.personalCredential.titleExpired', { vendor: vendorLabel.value })
57
58
  default:
58
- return 'Enter your personal password'
59
+ return t('providers.personalCredential.titleDefault')
59
60
  }
60
61
  })
61
62
 
@@ -64,11 +65,15 @@ async function submit() {
64
65
  busy.value = true
65
66
  try {
66
67
  await pending.value.retry(password.value)
67
- toast.add({ title: 'Run started', icon: 'i-lucide-check', color: 'success' })
68
+ toast.add({
69
+ title: t('providers.personalCredential.toast.started'),
70
+ icon: 'i-lucide-check',
71
+ color: 'success',
72
+ })
68
73
  } catch (e) {
69
74
  // A fresh 428 (e.g. still-wrong password) re-arms `pending`, keeping the modal open.
70
75
  toast.add({
71
- title: 'Could not start the run',
76
+ title: t('providers.personalCredential.toast.startFailed'),
72
77
  description: e instanceof Error ? e.message : String(e),
73
78
  color: 'error',
74
79
  })
@@ -89,36 +94,37 @@ function goConnect() {
89
94
  <div class="space-y-4">
90
95
  <template v-if="needsConnect">
91
96
  <p class="text-sm text-slate-400">
92
- This task uses a {{ vendorLabel }} model, which runs on <strong>your own</strong>
93
- {{ vendorLabel }} subscription. Connect (or renew) it first, then start the run again.
97
+ {{ t('providers.personalCredential.connectBody', { vendor: vendorLabel }) }}
94
98
  </p>
95
99
  <div class="flex justify-end gap-2">
96
- <UButton color="neutral" variant="ghost" @click="open = false">Cancel</UButton>
97
- <UButton icon="i-lucide-shield-check" @click="goConnect()"
98
- >Connect subscription</UButton
99
- >
100
+ <UButton color="neutral" variant="ghost" @click="open = false">
101
+ {{ t('providers.personalCredential.cancel') }}
102
+ </UButton>
103
+ <UButton icon="i-lucide-shield-check" @click="goConnect()">
104
+ {{ t('providers.personalCredential.connectCta') }}
105
+ </UButton>
100
106
  </div>
101
107
  </template>
102
108
 
103
109
  <template v-else>
104
110
  <p class="text-sm text-slate-400">
105
- Enter the personal password that protects your {{ vendorLabel }} subscription. It
106
- unlocks your credential for this run only and is cached in this browser so you won’t be
107
- asked again for a while.
111
+ {{ t('providers.personalCredential.passwordBody', { vendor: vendorLabel }) }}
108
112
  </p>
109
- <UFormField label="Personal password">
113
+ <UFormField :label="t('providers.personalCredential.passwordField')">
110
114
  <UInput
111
115
  v-model="password"
112
116
  type="password"
113
117
  autofocus
114
- placeholder="your personal password"
118
+ :placeholder="t('providers.personalCredential.passwordPlaceholder')"
115
119
  @keydown.enter="submit()"
116
120
  />
117
121
  </UFormField>
118
122
  <div class="flex justify-end gap-2">
119
- <UButton color="neutral" variant="ghost" @click="open = false">Cancel</UButton>
123
+ <UButton color="neutral" variant="ghost" @click="open = false">
124
+ {{ t('providers.personalCredential.cancel') }}
125
+ </UButton>
120
126
  <UButton :loading="busy" :disabled="password.length < 6" @click="submit()">
121
- Unlock &amp; run
127
+ {{ t('providers.personalCredential.submit') }}
122
128
  </UButton>
123
129
  </div>
124
130
  </template>
@@ -8,9 +8,15 @@ import { computed, onMounted, ref } from 'vue'
8
8
  import type { SubscriptionVendor } from '~/types/domain'
9
9
 
10
10
  const personal = usePersonalSubscriptionsStore()
11
+ const auth = useAuthStore()
11
12
  const toast = useToast()
12
13
  const { t, d } = useI18n()
13
14
 
15
+ // Personal subscriptions are stored per-user, so they need a signed-in user. When there
16
+ // isn't one (a deployment running without sign-in), block the form so the user doesn't
17
+ // enter a token + password that can't be saved.
18
+ const needsSignIn = computed(() => !auth.user)
19
+
14
20
  /**
15
21
  * Per-vendor metadata driving the connect form + connected-row labels. Reactive to the
16
22
  * locale (rebuilds on switch). The token placeholders for `claude`/`codex` are literal
@@ -150,6 +156,11 @@ async function disconnect(v: SubscriptionVendor) {
150
156
  <p class="mt-1 text-sm text-slate-400">{{ t('personalSubscriptions.intro') }}</p>
151
157
  </div>
152
158
 
159
+ <ProvidersSignInRequiredNotice
160
+ v-if="needsSignIn"
161
+ :message="t('auth.signInRequired.personalSubscriptions')"
162
+ />
163
+
153
164
  <!-- connected subscriptions -->
154
165
  <div
155
166
  v-for="sub in personal.subscriptions"
@@ -182,6 +193,7 @@ async function disconnect(v: SubscriptionVendor) {
182
193
  <USelect
183
194
  v-model="vendor"
184
195
  :items="PERSONAL_VENDORS.map((m) => ({ label: m.label, value: m.value }))"
196
+ :disabled="needsSignIn"
185
197
  class="w-64"
186
198
  />
187
199
  </UFormField>
@@ -197,6 +209,7 @@ async function disconnect(v: SubscriptionVendor) {
197
209
  <UFormField :label="t('personalSubscriptions.labelField')">
198
210
  <UInput
199
211
  v-model="label"
212
+ :disabled="needsSignIn"
200
213
  :placeholder="t('personalSubscriptions.labelPlaceholder', { vendor: selectedMeta.label })"
201
214
  />
202
215
  </UFormField>
@@ -204,6 +217,7 @@ async function disconnect(v: SubscriptionVendor) {
204
217
  <UTextarea
205
218
  v-model="token"
206
219
  :rows="2"
220
+ :disabled="needsSignIn"
207
221
  :placeholder="selectedMeta.tokenPlaceholder"
208
222
  class="font-mono"
209
223
  />
@@ -213,17 +227,18 @@ async function disconnect(v: SubscriptionVendor) {
213
227
  <UInput
214
228
  v-model="password"
215
229
  type="password"
230
+ :disabled="needsSignIn"
216
231
  :placeholder="t('personalSubscriptions.passwordPlaceholder')"
217
232
  />
218
233
  </UFormField>
219
234
  <UFormField :label="t('personalSubscriptions.renewsField')">
220
- <UInput v-model="expiresOn" type="date" />
235
+ <UInput v-model="expiresOn" type="date" :disabled="needsSignIn" />
221
236
  </UFormField>
222
237
  </div>
223
238
  <div class="flex justify-end">
224
239
  <UButton
225
240
  :loading="busy"
226
- :disabled="!token.trim() || password.length < 6"
241
+ :disabled="needsSignIn || !token.trim() || password.length < 6"
227
242
  icon="i-lucide-shield-check"
228
243
  @click="connect()"
229
244
  >
@@ -0,0 +1,19 @@
1
+ <script setup lang="ts">
2
+ // A guard banner for a per-USER credential form (personal subscriptions, your own API
3
+ // keys) when there is no signed-in user to store it against. On auth-enabled deployments
4
+ // and in local mode the app forces a login before these forms are reachable, so this only
5
+ // surfaces on a deployment running WITHOUT sign-in (auth fully disabled, non-local) — where
6
+ // storing would 401. It blocks the inputs and explains why, instead of letting the user
7
+ // type a token that can't be saved.
8
+ defineProps<{ message: string }>()
9
+ </script>
10
+
11
+ <template>
12
+ <div
13
+ class="flex items-start gap-2.5 rounded-lg border border-amber-500/40 bg-amber-500/10 p-3 text-sm text-amber-200"
14
+ role="alert"
15
+ >
16
+ <UIcon name="i-lucide-lock" class="mt-0.5 h-4 w-4 shrink-0" />
17
+ <p>{{ message }}</p>
18
+ </div>
19
+ </template>