@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.
- package/app/components/auth/AuthGate.vue +1 -1
- package/app/components/auth/LoginScreen.vue +131 -1
- package/app/components/layout/AccountDeploymentSettings.vue +232 -5
- package/app/components/providers/AiPresetMismatchDialog.vue +26 -13
- package/app/components/providers/AiProviderOnboardingModal.vue +18 -15
- package/app/components/providers/ApiKeysSection.vue +105 -63
- package/app/components/providers/PersonalCredentialModal.vue +35 -29
- package/app/components/providers/PersonalSubscriptionSection.vue +17 -2
- package/app/components/providers/SignInRequiredNotice.vue +19 -0
- package/app/components/providers/VendorCredentialsModal.vue +82 -39
- package/app/components/settings/AccountSettingsPanel.vue +9 -6
- package/app/components/settings/IssueTrackerPanel.vue +114 -53
- package/app/components/settings/LocalModeSettingsPanel.vue +60 -28
- package/app/components/settings/LocalModelEndpointsPanel.vue +72 -27
- package/app/components/settings/MergeThresholdsPanel.vue +93 -45
- package/app/components/settings/ModelConfigurationPanel.vue +62 -36
- package/app/components/settings/ObservabilityConnectionPanel.vue +65 -35
- package/app/components/settings/OpenRouterCatalogPanel.vue +70 -40
- package/app/components/settings/ProviderConnectionPanel.vue +115 -61
- package/app/components/settings/ServiceFragmentDefaultsPanel.vue +9 -12
- package/app/components/settings/UserSecretsSection.vue +37 -18
- package/app/components/settings/WorkspaceSettingsPanel.vue +114 -62
- package/app/composables/api/auth.ts +7 -0
- package/app/stores/auth.ts +25 -1
- package/app/types/accountSettings.ts +4 -0
- package/i18n/locales/en.json +663 -0
- package/i18n/locales/es.json +651 -0
- package/i18n/locales/fr.json +651 -0
- package/i18n/locales/pl.json +651 -0
- package/i18n/locales/uk.json +651 -0
- 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
|
|
57
|
+
const DIRECT_PROVIDERS = computed<ProviderMeta[]>(() => [
|
|
48
58
|
{
|
|
49
59
|
value: 'openai',
|
|
50
|
-
label: '
|
|
60
|
+
label: t('providers.apiKeys.providers.openai.label'),
|
|
51
61
|
url: 'https://platform.openai.com/api-keys',
|
|
52
62
|
steps: [
|
|
53
|
-
'
|
|
54
|
-
'
|
|
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: '
|
|
70
|
+
label: t('providers.apiKeys.providers.anthropic.label'),
|
|
61
71
|
url: 'https://console.anthropic.com/settings/keys',
|
|
62
72
|
steps: [
|
|
63
|
-
'
|
|
64
|
-
'
|
|
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: '
|
|
80
|
+
label: t('providers.apiKeys.providers.qwen.label'),
|
|
71
81
|
url: 'https://dashscope.console.aliyun.com/apiKey',
|
|
72
82
|
steps: [
|
|
73
|
-
'
|
|
74
|
-
'
|
|
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: '
|
|
90
|
+
label: t('providers.apiKeys.providers.deepseek.label'),
|
|
81
91
|
url: 'https://platform.deepseek.com/api_keys',
|
|
82
92
|
steps: [
|
|
83
|
-
'
|
|
84
|
-
'
|
|
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: '
|
|
100
|
+
label: t('providers.apiKeys.providers.moonshot.label'),
|
|
91
101
|
url: 'https://platform.moonshot.ai/console/api-keys',
|
|
92
102
|
steps: [
|
|
93
|
-
'
|
|
94
|
-
'
|
|
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
|
|
110
|
+
const PROXY_PROVIDERS = computed<ProviderMeta[]>(() => [
|
|
101
111
|
{
|
|
102
112
|
value: 'openrouter',
|
|
103
|
-
label: '
|
|
113
|
+
label: t('providers.apiKeys.providers.openrouter.label'),
|
|
104
114
|
url: 'https://openrouter.ai/keys',
|
|
105
115
|
steps: [
|
|
106
|
-
'
|
|
107
|
-
'
|
|
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: '
|
|
122
|
+
label: t('providers.apiKeys.providers.litellm.label'),
|
|
113
123
|
url: 'https://docs.litellm.ai/docs/proxy/virtual_keys',
|
|
114
124
|
steps: [
|
|
115
|
-
'
|
|
116
|
-
|
|
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(() =>
|
|
123
|
-
|
|
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:
|
|
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({
|
|
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: '
|
|
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: '
|
|
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
|
-
{{
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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="
|
|
257
|
+
<UFormField v-if="!isAccount" :label="t('providers.apiKeys.scopeField')">
|
|
247
258
|
<USelect
|
|
248
259
|
v-model="scope"
|
|
249
260
|
:items="[
|
|
250
|
-
{ label: '
|
|
251
|
-
{ label: '
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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="
|
|
293
|
-
<UInput
|
|
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="
|
|
296
|
-
<UTextarea
|
|
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
|
|
300
|
-
|
|
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
|
-
|
|
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
|
-
{{
|
|
320
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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 '
|
|
53
|
+
return t('providers.personalCredential.titleWrongPassword')
|
|
53
54
|
case 'no_subscription':
|
|
54
|
-
return
|
|
55
|
+
return t('providers.personalCredential.titleNoSubscription', { vendor: vendorLabel.value })
|
|
55
56
|
case 'subscription_expired':
|
|
56
|
-
return
|
|
57
|
+
return t('providers.personalCredential.titleExpired', { vendor: vendorLabel.value })
|
|
57
58
|
default:
|
|
58
|
-
return '
|
|
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({
|
|
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: '
|
|
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
|
-
|
|
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">
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
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="
|
|
113
|
+
<UFormField :label="t('providers.personalCredential.passwordField')">
|
|
110
114
|
<UInput
|
|
111
115
|
v-model="password"
|
|
112
116
|
type="password"
|
|
113
117
|
autofocus
|
|
114
|
-
placeholder="
|
|
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">
|
|
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
|
-
|
|
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>
|