@cat-factory/app 0.46.12 → 0.47.0

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 (27) 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/ApiKeysSection.vue +27 -3
  5. package/app/components/providers/PersonalSubscriptionSection.vue +17 -2
  6. package/app/components/providers/SignInRequiredNotice.vue +19 -0
  7. package/app/components/settings/AccountSettingsPanel.vue +9 -6
  8. package/app/components/settings/IssueTrackerPanel.vue +114 -53
  9. package/app/components/settings/LocalModeSettingsPanel.vue +60 -28
  10. package/app/components/settings/LocalModelEndpointsPanel.vue +72 -27
  11. package/app/components/settings/MergeThresholdsPanel.vue +93 -45
  12. package/app/components/settings/ModelConfigurationPanel.vue +62 -36
  13. package/app/components/settings/ObservabilityConnectionPanel.vue +65 -35
  14. package/app/components/settings/OpenRouterCatalogPanel.vue +70 -40
  15. package/app/components/settings/ProviderConnectionPanel.vue +115 -61
  16. package/app/components/settings/ServiceFragmentDefaultsPanel.vue +9 -12
  17. package/app/components/settings/UserSecretsSection.vue +37 -18
  18. package/app/components/settings/WorkspaceSettingsPanel.vue +114 -62
  19. package/app/composables/api/auth.ts +7 -0
  20. package/app/stores/auth.ts +25 -1
  21. package/app/types/accountSettings.ts +4 -0
  22. package/i18n/locales/en.json +496 -0
  23. package/i18n/locales/es.json +490 -0
  24. package/i18n/locales/fr.json +490 -0
  25. package/i18n/locales/pl.json +490 -0
  26. package/i18n/locales/uk.json +490 -0
  27. package/package.json +2 -2
@@ -27,7 +27,7 @@ onMounted(() => auth.bootstrap())
27
27
 
28
28
  <slot v-else-if="isPublicRoute" />
29
29
 
30
- <LoginScreen v-else-if="auth.required && !auth.user" />
30
+ <LoginScreen v-else-if="auth.needsLogin" />
31
31
 
32
32
  <slot v-else />
33
33
  </template>
@@ -1,10 +1,74 @@
1
1
  <script setup lang="ts">
2
- import { computed, ref } from 'vue'
2
+ import { computed, ref, watch } from 'vue'
3
3
  import { apiErrorEnvelope } from '~/composables/api/errors'
4
4
 
5
5
  const auth = useAuthStore()
6
6
  const { t } = useI18n()
7
7
 
8
+ // Local-mode source-control PAT login. GitHub/GitLab are brand names (kept verbatim across
9
+ // locales), as are the token-settings URLs, so they're inline constants rather than catalog
10
+ // keys — same convention as the provider descriptors in ApiKeysSection. The actual link
11
+ // prefers the server's scopes-preselected deep link (`patLogin.setupUrls`); these are the
12
+ // fallback when it's absent.
13
+ type PatProvider = 'github' | 'gitlab'
14
+ const PROVIDER_LABELS: Record<PatProvider, string> = { github: 'GitHub', gitlab: 'GitLab' }
15
+ const PROVIDER_ICONS: Record<PatProvider, string> = {
16
+ github: 'i-lucide-github',
17
+ gitlab: 'i-lucide-gitlab',
18
+ }
19
+ // Fallback token-creation pages, used only if the server didn't advertise a deep link.
20
+ const PROVIDER_TOKEN_URLS: Record<PatProvider, string> = {
21
+ github: 'https://github.com/settings/tokens/new',
22
+ gitlab: 'https://gitlab.com/-/user_settings/personal_access_tokens',
23
+ }
24
+
25
+ const patLoginCfg = computed(() => auth.localMode?.patLogin)
26
+ const configuredProviders = computed<PatProvider[]>(
27
+ () => (patLoginCfg.value?.configured ?? []) as PatProvider[],
28
+ )
29
+ const availableProviders = computed<PatProvider[]>(
30
+ () => (patLoginCfg.value?.available ?? []) as PatProvider[],
31
+ )
32
+ const showLocalLogin = computed(() => availableProviders.value.length > 0)
33
+
34
+ const patProvider = ref<PatProvider>('github')
35
+ const patToken = ref('')
36
+ const patBusy = ref(false)
37
+ const patError = ref<string | null>(null)
38
+
39
+ // Keep the picker on an actually-available provider.
40
+ watch(
41
+ availableProviders,
42
+ (list) => {
43
+ if (list.length && !list.includes(patProvider.value)) patProvider.value = list[0]!
44
+ },
45
+ { immediate: true },
46
+ )
47
+
48
+ const patProviderItems = computed(() =>
49
+ availableProviders.value.map((p) => ({ label: PROVIDER_LABELS[p], value: p })),
50
+ )
51
+
52
+ // Prefer the server's scopes-preselected deep link (it owns the per-provider scopes);
53
+ // fall back to the plain token page if it wasn't advertised.
54
+ const tokenCreateUrl = computed(
55
+ () => patLoginCfg.value?.setupUrls?.[patProvider.value] ?? PROVIDER_TOKEN_URLS[patProvider.value],
56
+ )
57
+
58
+ /** One-click (configured PAT) or pasted-token sign-in; reloads so the app boots signed in. */
59
+ async function submitPat(provider: PatProvider, token?: string) {
60
+ patError.value = null
61
+ patBusy.value = true
62
+ try {
63
+ await auth.patLogin(token ? { provider, token } : { provider })
64
+ if (typeof window !== 'undefined') window.location.assign(window.location.pathname)
65
+ } catch (e) {
66
+ patError.value = apiErrorEnvelope(e)?.message ?? t('auth.localMode.failed')
67
+ } finally {
68
+ patBusy.value = false
69
+ }
70
+ }
71
+
8
72
  // An invite token may ride in on the URL (?invite=…) — it flows through the OAuth
9
73
  // redirect and the password signup so a brand-new user can join the org on first login.
10
74
  const invite = computed(() => {
@@ -90,6 +154,72 @@ const showOAuthDivider = computed(
90
154
  </p>
91
155
  </div>
92
156
 
157
+ <!-- Local mode: sign in with a source-control PAT (no OAuth round-trip needed) -->
158
+ <div v-if="showLocalLogin && mode !== 'forgot'" class="space-y-3">
159
+ <!-- One-click: a PAT is already configured server-side -->
160
+ <UButton
161
+ v-for="p in configuredProviders"
162
+ :key="p"
163
+ block
164
+ size="lg"
165
+ color="primary"
166
+ :icon="PROVIDER_ICONS[p]"
167
+ :loading="patBusy"
168
+ @click="submitPat(p)"
169
+ >
170
+ {{ t('auth.localMode.continueWith', { provider: PROVIDER_LABELS[p] }) }}
171
+ </UButton>
172
+
173
+ <!-- Enter a PAT inline -->
174
+ <form class="space-y-2" @submit.prevent="submitPat(patProvider, patToken.trim())">
175
+ <p class="text-xs font-medium text-slate-400">{{ t('auth.localMode.enterPatTitle') }}</p>
176
+ <USelect
177
+ v-if="patProviderItems.length > 1"
178
+ v-model="patProvider"
179
+ :items="patProviderItems"
180
+ size="lg"
181
+ class="w-full"
182
+ />
183
+ <UTextarea
184
+ v-model="patToken"
185
+ :rows="2"
186
+ :placeholder="
187
+ t('auth.localMode.tokenPlaceholder', { provider: PROVIDER_LABELS[patProvider] })
188
+ "
189
+ class="w-full font-mono"
190
+ />
191
+ <div class="flex items-center justify-between gap-2">
192
+ <a
193
+ :href="tokenCreateUrl"
194
+ target="_blank"
195
+ rel="noopener noreferrer"
196
+ class="text-xs text-indigo-400 hover:underline"
197
+ >
198
+ {{ t('auth.localMode.createToken', { provider: PROVIDER_LABELS[patProvider] }) }}
199
+ </a>
200
+ <UButton
201
+ size="lg"
202
+ color="neutral"
203
+ variant="subtle"
204
+ type="submit"
205
+ :loading="patBusy"
206
+ :disabled="!patToken.trim()"
207
+ >
208
+ {{ t('auth.localMode.submit') }}
209
+ </UButton>
210
+ </div>
211
+ </form>
212
+ <p v-if="patError" class="text-sm text-rose-400">{{ patError }}</p>
213
+ </div>
214
+
215
+ <div
216
+ v-if="showLocalLogin && auth.providers.password && mode !== 'forgot'"
217
+ class="my-4 flex items-center gap-3 text-xs text-slate-500"
218
+ >
219
+ <span class="h-px flex-1 bg-slate-800" /> {{ t('auth.localMode.orDivider') }}
220
+ <span class="h-px flex-1 bg-slate-800" />
221
+ </div>
222
+
93
223
  <!-- OAuth providers -->
94
224
  <div v-if="mode !== 'forgot'" class="space-y-2">
95
225
  <UButton
@@ -1,12 +1,13 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, onMounted, reactive, ref } from 'vue'
3
+ import type { ContentStorageBackend, ContentStorageConfig } from '~/types/accountSettings'
3
4
 
4
5
  // Deployment integration secrets for an account (admin only): the Slack app OAuth
5
- // credentials and the container web-search upstream keys both moved out of env into
6
- // the per-account settings store, sealed at rest. Secrets are write-only: the panel only
7
- // ever shows whether each integration is configured (the `summary`), never the values;
8
- // blank inputs leave a configured secret unchanged. Hidden when the settings store isn't
9
- // wired (no ENCRYPTION_KEY).
6
+ // credentials, the container web-search upstream keys, and the binary-artifact (screenshot)
7
+ // content-storage backend all moved out of env into the per-account settings store, sealed
8
+ // at rest. Secrets are write-only: the panel only ever shows whether each integration is
9
+ // configured (the `summary`), never the values; blank inputs leave a configured secret
10
+ // unchanged. Hidden when the settings store isn't wired (no ENCRYPTION_KEY).
10
11
  const props = defineProps<{ accountId: string }>()
11
12
 
12
13
  const store = useAccountSettingsStore()
@@ -20,9 +21,54 @@ const savingWeb = ref(false)
20
21
 
21
22
  const summary = computed(() => store.view?.summary ?? null)
22
23
 
24
+ // ---- Content storage (binary artifacts / screenshots) --------------------
25
+ // Exhaustive enum→key map (drift guard tier 2): every backend resolves to a static literal
26
+ // `t()` key, so adding a backend without a label fails the typecheck on this Record.
27
+ const contentBackendLabels = computed<Record<ContentStorageBackend, string>>(() => ({
28
+ off: t('layout.accountDeployment.contentStorage.backends.off'),
29
+ fs: t('layout.accountDeployment.contentStorage.backends.fs'),
30
+ s3: t('layout.accountDeployment.contentStorage.backends.s3'),
31
+ r2: t('layout.accountDeployment.contentStorage.backends.r2'),
32
+ db: t('layout.accountDeployment.contentStorage.backends.db'),
33
+ }))
34
+ const storageCapability = computed(() => store.view?.contentStorageCapability ?? null)
35
+ const storageSummary = computed(() => summary.value?.contentStorage ?? null)
36
+ const backendItems = computed(() =>
37
+ (storageCapability.value?.supportedBackends ?? []).map((b) => ({
38
+ label: contentBackendLabels.value[b],
39
+ value: b,
40
+ })),
41
+ )
42
+ const csBackend = ref<ContentStorageBackend>('off')
43
+ const cs = reactive({
44
+ basePath: '',
45
+ region: '',
46
+ bucket: '',
47
+ prefix: '',
48
+ endpoint: '',
49
+ forcePathStyle: false,
50
+ accessKeyId: '',
51
+ secretAccessKey: '',
52
+ })
53
+ const savingStorage = ref(false)
54
+
55
+ function hydrateStorage() {
56
+ const cfg = store.view?.config?.contentStorage
57
+ csBackend.value = cfg?.backend ?? storageCapability.value?.defaultBackend ?? 'off'
58
+ cs.basePath = cfg?.fs?.basePath ?? ''
59
+ cs.region = cfg?.s3?.region ?? ''
60
+ cs.bucket = cfg?.s3?.bucket ?? ''
61
+ cs.prefix = cfg?.s3?.prefix ?? ''
62
+ cs.endpoint = cfg?.s3?.endpoint ?? ''
63
+ cs.forcePathStyle = cfg?.s3?.forcePathStyle ?? false
64
+ cs.accessKeyId = ''
65
+ cs.secretAccessKey = ''
66
+ }
67
+
23
68
  onMounted(async () => {
24
69
  try {
25
70
  await store.load(props.accountId)
71
+ hydrateStorage()
26
72
  } catch (e) {
27
73
  toast.add({
28
74
  title: t('layout.accountDeployment.loadFailed'),
@@ -33,6 +79,72 @@ onMounted(async () => {
33
79
  }
34
80
  })
35
81
 
82
+ async function saveStorage() {
83
+ const backend = csBackend.value
84
+ const config: ContentStorageConfig = { backend }
85
+ if (backend === 'fs' && cs.basePath.trim()) {
86
+ config.fs = { basePath: cs.basePath.trim() }
87
+ }
88
+ if (backend === 's3') {
89
+ if (!cs.region.trim() || !cs.bucket.trim()) {
90
+ toast.add({
91
+ title: t('layout.accountDeployment.contentStorage.regionBucketValidation'),
92
+ color: 'error',
93
+ })
94
+ return
95
+ }
96
+ config.s3 = {
97
+ region: cs.region.trim(),
98
+ bucket: cs.bucket.trim(),
99
+ ...(cs.prefix.trim() ? { prefix: cs.prefix.trim() } : {}),
100
+ ...(cs.endpoint.trim() ? { endpoint: cs.endpoint.trim() } : {}),
101
+ ...(cs.forcePathStyle ? { forcePathStyle: true } : {}),
102
+ }
103
+ }
104
+ const input: Parameters<typeof store.save>[1] = { config: { contentStorage: config } }
105
+ if (backend === 's3') {
106
+ const id = cs.accessKeyId.trim()
107
+ const key = cs.secretAccessKey.trim()
108
+ if (id && key) {
109
+ input.secrets = { s3: { accessKeyId: id, secretAccessKey: key } }
110
+ } else if (id || key) {
111
+ toast.add({
112
+ title: t('layout.accountDeployment.contentStorage.bothKeysValidation'),
113
+ color: 'error',
114
+ })
115
+ return
116
+ } else if (!storageSummary.value?.s3CredentialsConfigured) {
117
+ toast.add({
118
+ title: t('layout.accountDeployment.contentStorage.keysValidation'),
119
+ color: 'error',
120
+ })
121
+ return
122
+ }
123
+ // else: keys already stored and none re-entered → leave them unchanged.
124
+ } else {
125
+ // Switching off S3: drop any stored S3 credentials.
126
+ input.secrets = { s3: null }
127
+ }
128
+ savingStorage.value = true
129
+ try {
130
+ await store.save(props.accountId, input)
131
+ hydrateStorage()
132
+ toast.add({
133
+ title: t('layout.accountDeployment.contentStorage.saved'),
134
+ icon: 'i-lucide-check',
135
+ color: 'success',
136
+ })
137
+ } catch (e) {
138
+ toast.add({
139
+ title: t('layout.accountDeployment.contentStorage.saveFailed'),
140
+ description: e instanceof Error ? e.message : String(e),
141
+ color: 'error',
142
+ })
143
+ } finally {
144
+ savingStorage.value = false
145
+ }
146
+ }
147
+
36
148
  async function saveSlack() {
37
149
  if (!slack.clientId.trim() || !slack.clientSecret.trim() || !slack.redirectUrl.trim()) {
38
150
  toast.add({ title: t('layout.accountDeployment.slack.validation'), color: 'error' })
@@ -275,5 +387,120 @@ async function clearWeb() {
275
387
  </UButton>
276
388
  </div>
277
389
  </section>
390
+
391
+ <!-- Content storage (binary artifacts / screenshots) -->
392
+ <section v-if="storageCapability" class="space-y-2 border-t border-slate-800 pt-6">
393
+ <div class="flex items-center gap-2">
394
+ <h4 class="text-sm font-semibold text-slate-200">
395
+ {{ t('layout.accountDeployment.contentStorage.title') }}
396
+ </h4>
397
+ <UBadge
398
+ :color="
399
+ storageSummary?.backend && storageSummary.backend !== 'off' ? 'success' : 'neutral'
400
+ "
401
+ variant="subtle"
402
+ size="xs"
403
+ >
404
+ {{
405
+ storageSummary?.backend
406
+ ? contentBackendLabels[storageSummary.backend]
407
+ : t('layout.accountDeployment.contentStorage.default', {
408
+ backend: contentBackendLabels[storageCapability.defaultBackend],
409
+ })
410
+ }}
411
+ </UBadge>
412
+ </div>
413
+ <p class="text-[11px] text-slate-400">
414
+ {{ t('layout.accountDeployment.contentStorage.description') }}
415
+ </p>
416
+ <div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
417
+ <USelect v-model="csBackend" :items="backendItems" value-key="value" size="sm" />
418
+ </div>
419
+
420
+ <!-- Filesystem -->
421
+ <div v-if="csBackend === 'fs'" class="grid grid-cols-1 gap-2">
422
+ <UInput
423
+ v-model="cs.basePath"
424
+ :placeholder="t('layout.accountDeployment.contentStorage.basePath')"
425
+ size="sm"
426
+ />
427
+ </div>
428
+
429
+ <!-- S3 / S3-compatible -->
430
+ <template v-if="csBackend === 's3'">
431
+ <div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
432
+ <UInput
433
+ v-model="cs.region"
434
+ :placeholder="t('layout.accountDeployment.contentStorage.region')"
435
+ size="sm"
436
+ />
437
+ <UInput
438
+ v-model="cs.bucket"
439
+ :placeholder="t('layout.accountDeployment.contentStorage.bucket')"
440
+ size="sm"
441
+ />
442
+ <UInput
443
+ v-model="cs.prefix"
444
+ :placeholder="t('layout.accountDeployment.contentStorage.prefix')"
445
+ size="sm"
446
+ />
447
+ <UInput
448
+ v-model="cs.endpoint"
449
+ :placeholder="t('layout.accountDeployment.contentStorage.endpoint')"
450
+ size="sm"
451
+ />
452
+ </div>
453
+ <UCheckbox
454
+ v-model="cs.forcePathStyle"
455
+ :label="t('layout.accountDeployment.contentStorage.forcePathStyle')"
456
+ size="sm"
457
+ />
458
+ <div class="flex items-center gap-2">
459
+ <span class="text-[11px] text-slate-400">
460
+ {{ t('layout.accountDeployment.contentStorage.accessKeys') }}
461
+ </span>
462
+ <UBadge
463
+ :color="storageSummary?.s3CredentialsConfigured ? 'success' : 'neutral'"
464
+ variant="subtle"
465
+ size="xs"
466
+ >
467
+ {{
468
+ storageSummary?.s3CredentialsConfigured
469
+ ? t('layout.accountDeployment.configured')
470
+ : t('layout.accountDeployment.notSet')
471
+ }}
472
+ </UBadge>
473
+ </div>
474
+ <div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
475
+ <UInput
476
+ v-model="cs.accessKeyId"
477
+ type="password"
478
+ :placeholder="t('layout.accountDeployment.contentStorage.accessKeyId')"
479
+ size="sm"
480
+ />
481
+ <UInput
482
+ v-model="cs.secretAccessKey"
483
+ type="password"
484
+ :placeholder="t('layout.accountDeployment.contentStorage.secretAccessKey')"
485
+ size="sm"
486
+ />
487
+ </div>
488
+ <p class="text-[11px] text-slate-400">
489
+ {{ t('layout.accountDeployment.contentStorage.keysHint') }}
490
+ </p>
491
+ </template>
492
+
493
+ <div class="flex gap-2">
494
+ <UButton
495
+ color="primary"
496
+ size="xs"
497
+ icon="i-lucide-save"
498
+ :loading="savingStorage"
499
+ @click="saveStorage"
500
+ >
501
+ {{ t('common.save') }}
502
+ </UButton>
503
+ </div>
504
+ </section>
278
505
  </div>
279
506
  </template>
@@ -24,11 +24,18 @@ const props = withDefaults(defineProps<{ accountId?: string; category?: 'direct'
24
24
  const workspace = useWorkspaceStore()
25
25
  const keys = useApiKeysStore()
26
26
  const models = useModelsStore()
27
+ const auth = useAuthStore()
27
28
  const toast = useToast()
29
+ const { t } = useI18n()
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
@@ -257,11 +264,17 @@ async function remove(k: ApiKey) {
257
264
  <USelect
258
265
  v-model="provider"
259
266
  :items="PROVIDERS.map((p) => ({ label: p.label, value: p.value }))"
267
+ :disabled="needsSignIn"
260
268
  class="w-64"
261
269
  />
262
270
  </UFormField>
263
271
  </div>
264
272
 
273
+ <ProvidersSignInRequiredNotice
274
+ v-if="needsSignIn"
275
+ :message="t('auth.signInRequired.userApiKeys')"
276
+ />
277
+
265
278
  <!-- where to get the key -->
266
279
  <ol
267
280
  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"
@@ -290,13 +303,24 @@ async function remove(k: ApiKey) {
290
303
  <!-- add form -->
291
304
  <div class="space-y-2">
292
305
  <UFormField label="Label (optional)">
293
- <UInput v-model="label" placeholder="e.g. team key" />
306
+ <UInput v-model="label" :disabled="needsSignIn" placeholder="e.g. team key" />
294
307
  </UFormField>
295
308
  <UFormField label="API key">
296
- <UTextarea v-model="key" :rows="2" placeholder="paste the API key" class="font-mono" />
309
+ <UTextarea
310
+ v-model="key"
311
+ :rows="2"
312
+ :disabled="needsSignIn"
313
+ placeholder="paste the API key"
314
+ class="font-mono"
315
+ />
297
316
  </UFormField>
298
317
  <div class="flex justify-end">
299
- <UButton :loading="busy" :disabled="!key.trim()" icon="i-lucide-plus" @click="add()">
318
+ <UButton
319
+ :loading="busy"
320
+ :disabled="needsSignIn || !key.trim()"
321
+ icon="i-lucide-plus"
322
+ @click="add()"
323
+ >
300
324
  Connect
301
325
  </UButton>
302
326
  </div>
@@ -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>
@@ -8,6 +8,7 @@
8
8
  import AccountTeamSettings from '~/components/layout/AccountTeamSettings.vue'
9
9
  import AccountFragmentSettings from '~/components/layout/AccountFragmentSettings.vue'
10
10
 
11
+ const { t } = useI18n()
11
12
  const ui = useUiStore()
12
13
  const accounts = useAccountsStore()
13
14
 
@@ -23,21 +24,23 @@ const activeTab = computed({
23
24
  set: (v: string) => ui.setAccountSettingsTab(v),
24
25
  })
25
26
 
26
- const tabs = [
27
- { value: 'team', label: 'Team & access', icon: 'i-lucide-users', slot: 'team' },
27
+ const tabs = computed(() => [
28
+ { value: 'team', label: t('settings.account.tabs.team'), icon: 'i-lucide-users', slot: 'team' },
28
29
  {
29
30
  value: 'fragments',
30
- label: 'Context fragments',
31
+ label: t('settings.account.tabs.fragments'),
31
32
  icon: 'i-lucide-book-marked',
32
33
  slot: 'fragments',
33
34
  },
34
- ]
35
+ ])
35
36
  </script>
36
37
 
37
38
  <template>
38
- <UModal v-model:open="open" title="Account settings" :ui="{ content: 'max-w-3xl' }">
39
+ <UModal v-model:open="open" :title="t('settings.account.title')" :ui="{ content: 'max-w-3xl' }">
39
40
  <template #body>
40
- <p v-if="!accounts.activeAccountId" class="text-sm text-slate-400">No account selected.</p>
41
+ <p v-if="!accounts.activeAccountId" class="text-sm text-slate-400">
42
+ {{ t('settings.account.noAccount') }}
43
+ </p>
41
44
  <UTabs
42
45
  v-else
43
46
  v-model="activeTab"