@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
@@ -8,6 +8,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
10
 
11
+ const { t } = useI18n()
11
12
  const ui = useUiStore()
12
13
  const store = useUserSecretsStore()
13
14
  const toast = useToast()
@@ -102,12 +103,14 @@ async function save() {
102
103
  values.value[secretField.value!.key] = ''
103
104
  testResult.value = null
104
105
  toast.add({
105
- title: `${descriptor.value?.label ?? 'Secret'} saved`,
106
+ title: t('settings.userSecrets.toast.saved', {
107
+ label: descriptor.value?.label ?? t('settings.userSecrets.secretFallback'),
108
+ }),
106
109
  icon: 'i-lucide-check',
107
110
  color: 'success',
108
111
  })
109
112
  } catch (e) {
110
- notifyError('Could not save secret', e)
113
+ notifyError(t('settings.userSecrets.toast.saveFailed'), e)
111
114
  } finally {
112
115
  busy.value = false
113
116
  }
@@ -118,9 +121,9 @@ async function remove() {
118
121
  try {
119
122
  await store.remove(kind.value)
120
123
  resetDraft()
121
- toast.add({ title: 'Secret removed', icon: 'i-lucide-check' })
124
+ toast.add({ title: t('settings.userSecrets.toast.removed'), icon: 'i-lucide-check' })
122
125
  } catch (e) {
123
- notifyError('Could not remove secret', e)
126
+ notifyError(t('settings.userSecrets.toast.removeFailed'), e)
124
127
  } finally {
125
128
  busy.value = false
126
129
  }
@@ -128,17 +131,25 @@ async function remove() {
128
131
  </script>
129
132
 
130
133
  <template>
131
- <UModal v-model:open="open" title="My GitHub token" :ui="{ content: 'max-w-xl' }">
134
+ <UModal
135
+ v-model:open="open"
136
+ :title="t('settings.userSecrets.title')"
137
+ :ui="{ content: 'max-w-xl' }"
138
+ >
132
139
  <template #title>
133
- <IntegrationBackTitle title="My GitHub token" @back="back" />
140
+ <IntegrationBackTitle :title="t('settings.userSecrets.title')" @back="back" />
134
141
  </template>
135
142
  <template #body>
136
143
  <div class="space-y-4">
137
144
  <p class="text-xs text-slate-400">
138
- Store a personal access token used for the runs <strong>you</strong> start — pushes, pull
139
- requests, the CI gate and merges are attributed to your GitHub access. Stored
140
- <span class="text-slate-300">just for you</span>; the token is write-only and never shown
141
- again.
145
+ <i18n-t keypath="settings.userSecrets.intro" tag="span" scope="global">
146
+ <template #you>
147
+ <strong>{{ t('settings.userSecrets.introYou') }}</strong>
148
+ </template>
149
+ <template #scope>
150
+ <span class="text-slate-300">{{ t('settings.userSecrets.introScope') }}</span>
151
+ </template>
152
+ </i18n-t>
142
153
  </p>
143
154
 
144
155
  <div
@@ -147,7 +158,9 @@ async function remove() {
147
158
  >
148
159
  <div>
149
160
  <span class="font-medium text-slate-200">{{ status.label }}</span>
150
- <div class="text-[11px] text-emerald-400">Connected · token stored</div>
161
+ <div class="text-[11px] text-emerald-400">
162
+ {{ t('settings.userSecrets.connectedStored') }}
163
+ </div>
151
164
  </div>
152
165
  <UButton
153
166
  icon="i-lucide-trash-2"
@@ -164,17 +177,23 @@ async function remove() {
164
177
  class="rounded-lg border border-dashed border-slate-700 p-3 space-y-3"
165
178
  >
166
179
  <p class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
167
- {{ status ? 'Replace token' : 'Add a token' }}
180
+ {{
181
+ status ? t('settings.userSecrets.replaceToken') : t('settings.userSecrets.addToken')
182
+ }}
168
183
  </p>
169
184
 
170
- <UFormField label="Label (optional)">
185
+ <UFormField :label="t('settings.userSecrets.labelField')">
171
186
  <UInput v-model="labelDraft" :placeholder="descriptor.label" />
172
187
  </UFormField>
173
188
 
174
189
  <UFormField
175
190
  v-for="field in descriptor.configFields"
176
191
  :key="field.key"
177
- :label="field.label + (field.required ? '' : ' (optional)')"
192
+ :label="
193
+ field.required
194
+ ? field.label
195
+ : t('settings.userSecrets.optionalField', { label: field.label })
196
+ "
178
197
  :help="field.help"
179
198
  >
180
199
  <UInput
@@ -195,13 +214,13 @@ async function remove() {
195
214
  :disabled="!buildPayload()"
196
215
  @click="test()"
197
216
  >
198
- Test connection
217
+ {{ t('settings.userSecrets.testConnection') }}
199
218
  </UButton>
200
219
  <span v-if="testResult && testResult.ok" class="text-xs text-emerald-400">
201
- {{ testResult.message ?? 'Token valid' }}
220
+ {{ testResult.message ?? t('settings.userSecrets.tokenValid') }}
202
221
  </span>
203
222
  <span v-else-if="testResult" class="text-xs text-rose-400">
204
- {{ testResult.message ?? 'Token rejected' }}
223
+ {{ testResult.message ?? t('settings.userSecrets.tokenRejected') }}
205
224
  </span>
206
225
  </div>
207
226
 
@@ -213,7 +232,7 @@ async function remove() {
213
232
  :disabled="!buildPayload()"
214
233
  @click="save()"
215
234
  >
216
- {{ status ? 'Save' : 'Add token' }}
235
+ {{ status ? t('common.save') : t('settings.userSecrets.addToken') }}
217
236
  </UButton>
218
237
  </div>
219
238
  </div>
@@ -14,6 +14,7 @@ import IssueTrackerPanel from '~/components/settings/IssueTrackerPanel.vue'
14
14
  import ServiceFragmentDefaultsPanel from '~/components/settings/ServiceFragmentDefaultsPanel.vue'
15
15
  import IntegrationBackTitle from '~/components/layout/IntegrationBackTitle.vue'
16
16
 
17
+ const { t, te } = useI18n()
17
18
  const ui = useUiStore()
18
19
  const store = useWorkspaceSettingsStore()
19
20
  const toast = useToast()
@@ -31,35 +32,63 @@ const activeTab = computed({
31
32
  set: (v: string) => ui.setWorkspaceSettingsTab(v),
32
33
  })
33
34
 
34
- const tabs = [
35
+ const tabs = computed(() => [
35
36
  {
36
37
  value: 'workspace',
37
- label: 'Workspace',
38
+ label: t('settings.workspaceSettings.tabs.workspace'),
38
39
  icon: 'i-lucide-sliders-horizontal',
39
40
  slot: 'workspace',
40
41
  },
41
- { value: 'budget', label: 'Budget', icon: 'i-lucide-wallet', slot: 'budget' },
42
- { value: 'merge', label: 'Merge thresholds', icon: 'i-lucide-git-merge', slot: 'merge' },
42
+ {
43
+ value: 'budget',
44
+ label: t('settings.workspaceSettings.tabs.budget'),
45
+ icon: 'i-lucide-wallet',
46
+ slot: 'budget',
47
+ },
48
+ {
49
+ value: 'merge',
50
+ label: t('settings.workspaceSettings.tabs.merge'),
51
+ icon: 'i-lucide-git-merge',
52
+ slot: 'merge',
53
+ },
43
54
  {
44
55
  value: 'tracker',
45
- label: 'Issue tracker',
56
+ label: t('settings.workspaceSettings.tabs.tracker'),
46
57
  icon: 'i-lucide-list-checks',
47
58
  slot: 'tracker',
48
59
  },
49
60
  {
50
61
  value: 'fragments',
51
- label: 'Service best practices',
62
+ label: t('settings.workspaceSettings.tabs.fragments'),
52
63
  icon: 'i-lucide-book-open-check',
53
64
  slot: 'fragments',
54
65
  },
55
- ]
66
+ ])
56
67
 
57
68
  const TASK_TYPES: CreateTaskType[] = ['feature', 'bug', 'document', 'spike']
58
- const MODES: { value: TaskLimitMode; label: string }[] = [
59
- { value: 'off', label: 'No limit' },
60
- { value: 'shared', label: 'Shared across all types' },
61
- { value: 'per_type', label: 'Per task type' },
62
- ]
69
+
70
+ // Per-task-type label for the "Max {type} tasks" inputs. An exhaustive Record keyed off
71
+ // the CreateTaskType union (a missing member fails the typecheck); each value is a LITERAL
72
+ // catalog key so the typed-message-keys check sees it. Leaf keys mirror the enum verbatim.
73
+ const TASK_TYPE_KEYS: Record<CreateTaskType, string> = {
74
+ feature: 'settings.workspaceSettings.taskTypes.feature',
75
+ bug: 'settings.workspaceSettings.taskTypes.bug',
76
+ document: 'settings.workspaceSettings.taskTypes.document',
77
+ spike: 'settings.workspaceSettings.taskTypes.spike',
78
+ }
79
+
80
+ const MODES = computed<{ value: TaskLimitMode; label: string }[]>(() => [
81
+ { value: 'off', label: t('settings.workspaceSettings.taskLimit.modes.off') },
82
+ { value: 'shared', label: t('settings.workspaceSettings.taskLimit.modes.shared') },
83
+ { value: 'per_type', label: t('settings.workspaceSettings.taskLimit.modes.per_type') },
84
+ ])
85
+
86
+ /** The localized "Max {type} tasks" label for a per-type running-task limit input. */
87
+ function maxTaskTypeLabel(type: CreateTaskType): string {
88
+ const key = TASK_TYPE_KEYS[type]
89
+ const typeLabel = te(key) ? t(key) : type
90
+ return t('settings.workspaceSettings.taskLimit.maxPerType', { type: typeLabel })
91
+ }
63
92
 
64
93
  // Local editable copy, kept in sync with the store's settings.
65
94
  const draft = reactive({
@@ -116,10 +145,14 @@ async function save() {
116
145
  artifactRetentionDays: draft.artifactRetentionDays,
117
146
  kaizenEnabled: draft.kaizenEnabled,
118
147
  })
119
- toast.add({ title: 'Settings saved', icon: 'i-lucide-check', color: 'success' })
148
+ toast.add({
149
+ title: t('settings.workspaceSettings.toast.saved'),
150
+ icon: 'i-lucide-check',
151
+ color: 'success',
152
+ })
120
153
  } catch (e) {
121
154
  toast.add({
122
- title: 'Could not save settings',
155
+ title: t('settings.workspaceSettings.toast.saveFailed'),
123
156
  description: e instanceof Error ? e.message : String(e),
124
157
  icon: 'i-lucide-triangle-alert',
125
158
  color: 'error',
@@ -142,10 +175,14 @@ async function saveBudget() {
142
175
  spendCurrency: draft.spendCurrency.trim() ? draft.spendCurrency.trim().toUpperCase() : null,
143
176
  spendMonthlyLimit: monthlyLimit,
144
177
  })
145
- toast.add({ title: 'Budget saved', icon: 'i-lucide-check', color: 'success' })
178
+ toast.add({
179
+ title: t('settings.workspaceSettings.toast.budgetSaved'),
180
+ icon: 'i-lucide-check',
181
+ color: 'success',
182
+ })
146
183
  } catch (e) {
147
184
  toast.add({
148
- title: 'Could not save budget',
185
+ title: t('settings.workspaceSettings.toast.budgetSaveFailed'),
149
186
  description: e instanceof Error ? e.message : String(e),
150
187
  icon: 'i-lucide-triangle-alert',
151
188
  color: 'error',
@@ -157,9 +194,13 @@ async function saveBudget() {
157
194
  </script>
158
195
 
159
196
  <template>
160
- <UModal v-model:open="open" title="Workspace settings" :ui="{ content: 'max-w-3xl' }">
197
+ <UModal
198
+ v-model:open="open"
199
+ :title="t('settings.workspaceSettings.title')"
200
+ :ui="{ content: 'max-w-3xl' }"
201
+ >
161
202
  <template #title>
162
- <IntegrationBackTitle title="Workspace settings" @back="back" />
203
+ <IntegrationBackTitle :title="t('settings.workspaceSettings.title')" @back="back" />
163
204
  </template>
164
205
  <template #body>
165
206
  <UTabs
@@ -173,15 +214,21 @@ async function saveBudget() {
173
214
  <div class="space-y-6">
174
215
  <!-- Run-timing escalation -->
175
216
  <section class="space-y-2">
176
- <h3 class="text-sm font-semibold text-slate-200">Waiting for a human</h3>
217
+ <h3 class="text-sm font-semibold text-slate-200">
218
+ {{ t('settings.workspaceSettings.waiting.heading') }}
219
+ </h3>
177
220
  <p class="text-[11px] text-slate-400">
178
- A run parked on a human decision (a review, an approval, a merge) waits as long as
179
- it needs — it is never cancelled. After this many minutes its notification turns red
180
- and is flagged <span class="text-error-400">Overdue</span> in the inbox.
221
+ <i18n-t keypath="settings.workspaceSettings.waiting.body" tag="span" scope="global">
222
+ <template #overdue>
223
+ <span class="text-error-400">{{
224
+ t('settings.workspaceSettings.waiting.overdue')
225
+ }}</span>
226
+ </template>
227
+ </i18n-t>
181
228
  </p>
182
229
  <label class="block w-48">
183
230
  <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
184
- Escalate after (minutes)
231
+ {{ t('settings.workspaceSettings.waiting.escalateAfter') }}
185
232
  </span>
186
233
  <UInput
187
234
  v-model.number="draft.waitingEscalationMinutes"
@@ -194,15 +241,16 @@ async function saveBudget() {
194
241
 
195
242
  <!-- Per-service running-task limit -->
196
243
  <section class="space-y-2">
197
- <h3 class="text-sm font-semibold text-slate-200">Running tasks per service</h3>
244
+ <h3 class="text-sm font-semibold text-slate-200">
245
+ {{ t('settings.workspaceSettings.taskLimit.heading') }}
246
+ </h3>
198
247
  <p class="text-[11px] text-slate-400">
199
- Cap how many tasks may run at once under one service. Starting a task over the limit
200
- is refused with a clear message until a running task finishes.
248
+ {{ t('settings.workspaceSettings.taskLimit.body') }}
201
249
  </p>
202
250
  <label class="block w-64">
203
- <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500"
204
- >Mode</span
205
- >
251
+ <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">{{
252
+ t('settings.workspaceSettings.taskLimit.mode')
253
+ }}</span>
206
254
  <USelect
207
255
  v-model="draft.taskLimitMode"
208
256
  :items="MODES"
@@ -214,48 +262,53 @@ async function saveBudget() {
214
262
 
215
263
  <label v-if="draft.taskLimitMode === 'shared'" class="block w-48">
216
264
  <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
217
- Max running tasks
265
+ {{ t('settings.workspaceSettings.taskLimit.maxRunning') }}
218
266
  </span>
219
267
  <UInput v-model.number="draft.taskLimitShared" type="number" :min="1" size="sm" />
220
268
  </label>
221
269
 
222
270
  <div v-else-if="draft.taskLimitMode === 'per_type'" class="grid grid-cols-2 gap-3">
223
- <label v-for="t in TASK_TYPES" :key="t" class="block">
271
+ <label v-for="taskType in TASK_TYPES" :key="taskType" class="block">
224
272
  <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
225
- Max {{ t }} tasks
273
+ {{ maxTaskTypeLabel(taskType) }}
226
274
  </span>
227
- <UInput v-model.number="draft.perType[t]" type="number" :min="1" size="sm" />
275
+ <UInput
276
+ v-model.number="draft.perType[taskType]"
277
+ type="number"
278
+ :min="1"
279
+ size="sm"
280
+ />
228
281
  </label>
229
282
  </div>
230
283
  </section>
231
284
 
232
285
  <!-- Agent observability -->
233
286
  <section class="space-y-2">
234
- <h3 class="text-sm font-semibold text-slate-200">Agent observability</h3>
287
+ <h3 class="text-sm font-semibold text-slate-200">
288
+ {{ t('settings.workspaceSettings.observability.heading') }}
289
+ </h3>
235
290
  <p class="text-[11px] text-slate-400">
236
- Store the complete context provided to each agent — the composed prompts, the
237
- best-practice fragments folded in, and the full content of the files injected into
238
- its container — so it can be inspected later in the observability view. The bodies
239
- are kept for the same window as the per-call LLM telemetry. Turn off to stop storing
240
- it (existing snapshots are pruned by retention).
291
+ {{ t('settings.workspaceSettings.observability.body') }}
241
292
  </p>
242
293
  <label class="flex items-center gap-2">
243
294
  <USwitch v-model="draft.storeAgentContext" size="sm" />
244
- <span class="text-sm text-slate-200">Store full agent context</span>
295
+ <span class="text-sm text-slate-200">{{
296
+ t('settings.workspaceSettings.observability.toggle')
297
+ }}</span>
245
298
  </label>
246
299
  </section>
247
300
 
248
301
  <!-- Visual-confirmation artifact retention -->
249
302
  <section class="space-y-2">
250
- <h3 class="text-sm font-semibold text-slate-200">Screenshot retention</h3>
303
+ <h3 class="text-sm font-semibold text-slate-200">
304
+ {{ t('settings.workspaceSettings.retention.heading') }}
305
+ </h3>
251
306
  <p class="text-[11px] text-slate-400">
252
- How long to keep the UI tester’s captured screenshots and the reference design
253
- images they’re reviewed against (the visual-confirmation gate). A daily cleanup job
254
- deletes both the image bytes and their metadata once they age past this window.
307
+ {{ t('settings.workspaceSettings.retention.body') }}
255
308
  </p>
256
309
  <label class="block w-48">
257
310
  <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
258
- Retention (days)
311
+ {{ t('settings.workspaceSettings.retention.days') }}
259
312
  </span>
260
313
  <UInput
261
314
  v-model.number="draft.artifactRetentionDays"
@@ -269,18 +322,17 @@ async function saveBudget() {
269
322
 
270
323
  <!-- Kaizen agent -->
271
324
  <section class="space-y-2">
272
- <h3 class="text-sm font-semibold text-slate-200">Kaizen agent</h3>
325
+ <h3 class="text-sm font-semibold text-slate-200">
326
+ {{ t('settings.workspaceSettings.kaizen.heading') }}
327
+ </h3>
273
328
  <p class="text-[11px] text-slate-400">
274
- After each run completes, the Kaizen agent grades how every agent step went — smooth
275
- and efficient vs confused and chaotic — and recommends prompt/model improvements. A
276
- prompt + agent + model combination that grades highly with no recommendations five
277
- times in a row is marked verified and is no longer graded. Grading runs in the
278
- background and is shown inside run details and the Kaizen screen. Set the grader's
279
- model in Model Configuration (the “Kaizen” agent).
329
+ {{ t('settings.workspaceSettings.kaizen.body') }}
280
330
  </p>
281
331
  <label class="flex items-center gap-2">
282
332
  <USwitch v-model="draft.kaizenEnabled" size="sm" />
283
- <span class="text-sm text-slate-200">Grade agent runs with Kaizen</span>
333
+ <span class="text-sm text-slate-200">{{
334
+ t('settings.workspaceSettings.kaizen.toggle')
335
+ }}</span>
284
336
  </label>
285
337
  </section>
286
338
 
@@ -292,7 +344,7 @@ async function saveBudget() {
292
344
  :loading="saving"
293
345
  @click="save"
294
346
  >
295
- Save
347
+ {{ t('common.save') }}
296
348
  </UButton>
297
349
  </div>
298
350
  </div>
@@ -302,28 +354,28 @@ async function saveBudget() {
302
354
  <template #budget>
303
355
  <div class="space-y-6">
304
356
  <section class="space-y-2">
305
- <h3 class="text-sm font-semibold text-slate-200">Monthly spend budget</h3>
357
+ <h3 class="text-sm font-semibold text-slate-200">
358
+ {{ t('settings.workspaceSettings.budget.heading') }}
359
+ </h3>
306
360
  <p class="text-[11px] text-slate-400">
307
- Token usage is metered per LLM call, priced, and gated by this budget — when
308
- reached, runs in this workspace pause and the board shows a warning. Leave blank to
309
- inherit the built-in default (~100&nbsp;EUR/month).
361
+ {{ t('settings.workspaceSettings.budget.body') }}
310
362
  </p>
311
363
  <div class="grid grid-cols-2 gap-3">
312
364
  <label class="block">
313
365
  <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
314
- Monthly limit
366
+ {{ t('settings.workspaceSettings.budget.monthlyLimit') }}
315
367
  </span>
316
368
  <UInput
317
369
  v-model="draft.spendMonthlyLimit"
318
370
  type="number"
319
371
  :min="0"
320
- placeholder="Default"
372
+ :placeholder="t('settings.workspaceSettings.budget.defaultPlaceholder')"
321
373
  size="sm"
322
374
  />
323
375
  </label>
324
376
  <label class="block">
325
377
  <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
326
- Currency (ISO 4217)
378
+ {{ t('settings.workspaceSettings.budget.currency') }}
327
379
  </span>
328
380
  <UInput
329
381
  v-model="draft.spendCurrency"
@@ -344,7 +396,7 @@ async function saveBudget() {
344
396
  :loading="savingBudget"
345
397
  @click="saveBudget"
346
398
  >
347
- Save budget
399
+ {{ t('settings.workspaceSettings.budget.save') }}
348
400
  </UButton>
349
401
  </div>
350
402
  </div>
@@ -5,10 +5,12 @@ import {
5
5
  logoutContract,
6
6
  meContract,
7
7
  passwordLoginContract,
8
+ patLoginContract,
8
9
  peekInvitationContract,
9
10
  resetPasswordContract,
10
11
  signupContract,
11
12
  } from '@cat-factory/contracts'
13
+ import type { VcsProviderWire } from '@cat-factory/contracts'
12
14
  import type { ApiContext } from './context'
13
15
 
14
16
  /** Auth/session endpoints + the events-WebSocket ticket mint. */
@@ -27,6 +29,11 @@ export function authApi({ http, send, ws }: ApiContext) {
27
29
  passwordLogin: (body: { email: string; password: string }) =>
28
30
  send(passwordLoginContract, { pathPrefix: '/auth', body }),
29
31
 
32
+ // Local mode only: log in as the account a source-control PAT belongs to. `token`
33
+ // omitted ⇒ use the server-configured PAT (one-click); present ⇒ a pasted token.
34
+ patLogin: (body: { provider: VcsProviderWire; token?: string }) =>
35
+ send(patLoginContract, { pathPrefix: '/auth', body }),
36
+
30
37
  // Request a reset link. Always succeeds (204) regardless of whether the email is
31
38
  // registered, so the response can't be used to enumerate accounts.
32
39
  forgotPassword: (body: { email: string }) =>
@@ -37,6 +37,15 @@ export const useAuthStore = defineStore(
37
37
  /** May the app render? True when auth is off, or on with a known user. */
38
38
  const isAuthenticated = computed(() => !required.value || user.value !== null)
39
39
 
40
+ /**
41
+ * Whether the SPA must show the login screen before the board. Auth-enabled deployments
42
+ * gate on a user as before; local mode ALSO gates (even though its API stays dev-open),
43
+ * because anonymous local use can't store per-user credentials — see the login flow.
44
+ */
45
+ const needsLogin = computed(
46
+ () => (required.value || localMode.value?.enabled === true) && user.value === null,
47
+ )
48
+
40
49
  /** Pull a token handed back in the post-login URL fragment (#token=…). */
41
50
  function consumeRedirectToken() {
42
51
  if (typeof window === 'undefined') return
@@ -62,7 +71,11 @@ export const useAuthStore = defineStore(
62
71
  return
63
72
  }
64
73
 
65
- if (required.value && token.value) {
74
+ // Resolve a stored session into a user whenever sign-in applies — auth-enabled
75
+ // deployments, OR local mode (which mints real sessions via PAT/password even though
76
+ // its API otherwise runs dev-open). Without this, a local session wouldn't survive a
77
+ // reload.
78
+ if ((required.value || localMode.value?.enabled === true) && token.value) {
66
79
  try {
67
80
  user.value = (await api.getMe()).user
68
81
  } catch {
@@ -133,6 +146,15 @@ export const useAuthStore = defineStore(
133
146
  applySession(await api.passwordLogin(body))
134
147
  }
135
148
 
149
+ /**
150
+ * Local mode: sign in as the account a source-control PAT belongs to. `token` omitted
151
+ * uses the server-configured PAT (one-click); otherwise a pasted token. Resolves to the
152
+ * SAME canonical user as GitHub OAuth would (keyed on the provider's numeric id).
153
+ */
154
+ async function patLogin(body: { provider: 'github' | 'gitlab'; token?: string }) {
155
+ applySession(await api.patLogin(body))
156
+ }
157
+
136
158
  /** Request a password-reset link by email (always resolves; never reveals existence). */
137
159
  async function forgotPassword(email: string) {
138
160
  await api.forgotPassword({ email })
@@ -164,11 +186,13 @@ export const useAuthStore = defineStore(
164
186
  localMode,
165
187
  ready,
166
188
  isAuthenticated,
189
+ needsLogin,
167
190
  bootstrap,
168
191
  login,
169
192
  loginWithGoogle,
170
193
  signup,
171
194
  passwordLogin,
195
+ patLogin,
172
196
  forgotPassword,
173
197
  resetPassword,
174
198
  logout,
@@ -11,4 +11,8 @@ export type {
11
11
  AccountSettingsSummary,
12
12
  AccountSettingsView,
13
13
  UpdateAccountSettingsInput,
14
+ ContentStorageBackend,
15
+ ContentStorageConfig,
16
+ ContentStorageSummary,
17
+ ContentStorageCapability,
14
18
  } from '@cat-factory/contracts'