@cat-factory/app 0.18.1 → 0.20.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.
@@ -14,6 +14,9 @@ const documents = useDocumentsStore()
14
14
  const tasks = useTasksStore()
15
15
  const tracker = useTrackerStore()
16
16
  const releaseHealth = useReleaseHealthStore()
17
+ const userSecrets = useUserSecretsStore()
18
+ const apiKeys = useApiKeysStore()
19
+ const workspace = useWorkspaceStore()
17
20
 
18
21
  // The selected filing tracker, as a badge label ("GitHub Issues" / "Jira").
19
22
  const trackerLabel = computed(() => {
@@ -27,7 +30,12 @@ const trackerLabel = computed(() => {
27
30
  watch(
28
31
  () => ui.integrationsOpen,
29
32
  (isOpen) => {
30
- if (isOpen) void releaseHealth.ensureLoaded().catch(() => {})
33
+ if (isOpen) {
34
+ void releaseHealth.ensureLoaded().catch(() => {})
35
+ void userSecrets.load().catch(() => {})
36
+ // Drives the OpenRouter row's "Key connected" badge.
37
+ if (workspace.workspaceId) void apiKeys.load(workspace.workspaceId).catch(() => {})
38
+ }
31
39
  },
32
40
  )
33
41
 
@@ -62,6 +70,38 @@ function go(fn: () => void) {
62
70
  const groups = computed<IntegrationGroup[]>(() => {
63
71
  const out: IntegrationGroup[] = []
64
72
 
73
+ // --- Models & providers ----------------------------------------------------
74
+ // Top of the hub: an OpenRouter key is the fastest path to 300+ models, so it leads.
75
+ const openRouterKeyConnected = apiKeys.configuredProviders.has('openrouter')
76
+ out.push({
77
+ title: 'Models & providers',
78
+ items: [
79
+ {
80
+ key: 'openrouter',
81
+ icon: 'i-lucide-waypoints',
82
+ label: 'OpenRouter',
83
+ description: 'One gateway to 300+ models — add your key and enable models in one place.',
84
+ status: openRouterKeyConnected ? 'Key connected' : undefined,
85
+ connected: openRouterKeyConnected,
86
+ onClick: () => go(ui.openOpenRouter),
87
+ },
88
+ {
89
+ key: 'vendors',
90
+ icon: 'i-lucide-key-round',
91
+ label: 'Vendors & keys',
92
+ description: 'LLM vendor subscriptions and provider API keys.',
93
+ onClick: () => go(ui.openVendorCredentials),
94
+ },
95
+ {
96
+ key: 'local-runners',
97
+ icon: 'i-lucide-server',
98
+ label: 'My local runners',
99
+ description: 'Your own-machine model runners (Ollama, LM Studio, vLLM…).',
100
+ onClick: () => go(ui.openLocalModels),
101
+ },
102
+ ],
103
+ })
104
+
65
105
  // --- Source control --------------------------------------------------------
66
106
  const code: IntegrationItem[] = []
67
107
  if (github.available) {
@@ -75,6 +115,20 @@ const groups = computed<IntegrationGroup[]>(() => {
75
115
  onClick: () => go(ui.openGitHub),
76
116
  })
77
117
  }
118
+ // Per-user GitHub PAT — works on every runtime (used for runs you initiate). Always
119
+ // offered; the badge reflects whether the signed-in user has stored one.
120
+ {
121
+ const pat = userSecrets.statusFor('github_pat')
122
+ code.push({
123
+ key: 'github-pat',
124
+ icon: 'i-lucide-key-round',
125
+ label: 'My GitHub token',
126
+ description: 'A personal access token used for runs you start (pushes, PRs, CI, merge).',
127
+ status: pat ? 'Connected' : undefined,
128
+ connected: !!pat,
129
+ onClick: () => go(ui.openUserSecrets),
130
+ })
131
+ }
78
132
  if (code.length) out.push({ title: 'Source control', items: code })
79
133
 
80
134
  // --- Communication ---------------------------------------------------------
@@ -170,34 +224,6 @@ const groups = computed<IntegrationGroup[]>(() => {
170
224
  })
171
225
  }
172
226
 
173
- // --- Models & providers ----------------------------------------------------
174
- out.push({
175
- title: 'Models & providers',
176
- items: [
177
- {
178
- key: 'vendors',
179
- icon: 'i-lucide-key-round',
180
- label: 'Vendors & keys',
181
- description: 'LLM vendor subscriptions and provider API keys.',
182
- onClick: () => go(ui.openVendorCredentials),
183
- },
184
- {
185
- key: 'local-runners',
186
- icon: 'i-lucide-server',
187
- label: 'My local runners',
188
- description: 'Your own-machine model runners (Ollama, LM Studio, vLLM…).',
189
- onClick: () => go(ui.openLocalModels),
190
- },
191
- {
192
- key: 'openrouter',
193
- icon: 'i-lucide-waypoints',
194
- label: 'OpenRouter models',
195
- description: 'Browse and enable models from the OpenRouter gateway.',
196
- onClick: () => go(ui.openOpenRouter),
197
- },
198
- ],
199
- })
200
-
201
227
  return out
202
228
  })
203
229
  </script>
@@ -1,15 +1,20 @@
1
1
  <script setup lang="ts">
2
- // Workspace settings: "OpenRouter models" — browse OpenRouter's 300+ gateway models and
3
- // enable a subset for this workspace. OpenRouter is reached via the workspace's API-key pool
4
- // (connect an OpenRouter key first under "Provider keys"); "Refresh" probes its live catalog
5
- // server-side, then tick the models to enable. Enabled models — with their context window and
6
- // price appear automatically in the model picker and meter against the spend budget.
2
+ // "OpenRouter" — the one-stop OpenRouter setup panel. OpenRouter is a single gateway to
3
+ // 300+ models reached via the workspace's API-key pool, so this panel owns the whole flow:
4
+ // 1. Connect an OpenRouter key inline (no need to detour through Vendors & keys).
5
+ // 2. The live catalog auto-refreshes as soon as a key exists.
6
+ // 3. Tick models (or one-click "Enable recommended") and Save.
7
+ // Enabled models — with their context window and price — appear in the model picker and
8
+ // meter against the spend budget. The Vendors & keys → Proxies tab remains a valid second
9
+ // entry point for the key; this panel just makes OpenRouter self-sufficient.
7
10
  import { computed, ref, watch } from 'vue'
8
11
  import type { OpenRouterModelMeta } from '~/types/openrouter'
9
12
 
10
13
  const ui = useUiStore()
11
14
  const workspace = useWorkspaceStore()
12
15
  const store = useOpenRouterStore()
16
+ const apiKeys = useApiKeysStore()
17
+ const models = useModelsStore()
13
18
  const toast = useToast()
14
19
 
15
20
  const open = computed({
@@ -17,16 +22,39 @@ const open = computed({
17
22
  set: (v: boolean) => (v ? ui.openOpenRouter() : ui.closeOpenRouter()),
18
23
  })
19
24
 
25
+ // Popular slugs offered by "Enable recommended" — these mirror the curated `openrouter`
26
+ // refs in the backend MODEL_CATALOG. Only the ones present in the live browse list are
27
+ // ticked, so a recommendation never enables a slug OpenRouter doesn't actually serve.
28
+ const RECOMMENDED_SLUGS = [
29
+ 'anthropic/claude-opus-4.8',
30
+ 'openai/gpt-5.5',
31
+ 'google/gemini-3-pro',
32
+ 'deepseek/deepseek-chat',
33
+ ]
34
+
35
+ // Whether the workspace/user has an OpenRouter key connected at any reachable scope.
36
+ const keyConnected = computed(() => apiKeys.configuredProviders.has('openrouter'))
37
+
20
38
  // The enabled slugs the user has ticked (seeded from the persisted catalog on open).
21
39
  const selected = ref<Set<string>>(new Set())
22
40
  const filter = ref('')
23
41
  const busy = ref(false)
24
42
 
25
- // Load the persisted catalog whenever the panel opens; seed the tick selection from it.
43
+ // Inline key-entry form state (shown until an OpenRouter key is connected).
44
+ const keyScope = ref<'workspace' | 'user'>('workspace')
45
+ const keyLabel = ref('')
46
+ const keyValue = ref('')
47
+ const connectingKey = ref(false)
48
+
49
+ // Load key state + persisted catalog whenever the panel opens; seed the tick selection,
50
+ // then auto-refresh the live catalog if a key is already connected (no extra click).
26
51
  watch(open, (isOpen) => {
27
52
  if (!isOpen || !workspace.workspaceId) return
28
- void store.load(workspace.workspaceId).then(() => {
53
+ const ws = workspace.workspaceId
54
+ void apiKeys.load(ws).catch(() => {})
55
+ void store.load(ws).then(() => {
29
56
  selected.value = new Set(store.enabled.map((m) => m.id))
57
+ if (keyConnected.value && store.browse.length === 0) void refresh()
30
58
  })
31
59
  })
32
60
 
@@ -45,6 +73,12 @@ const visible = computed(() => {
45
73
 
46
74
  const selectedCount = computed(() => selected.value.size)
47
75
 
76
+ // Recommended slugs that are actually available in the current browse/enabled list.
77
+ const recommendedAvailable = computed(() => {
78
+ const ids = new Set(source.value.map((m) => m.id))
79
+ return RECOMMENDED_SLUGS.filter((slug) => ids.has(slug))
80
+ })
81
+
48
82
  function contextLabel(tokens: number | undefined): string {
49
83
  if (!tokens) return ''
50
84
  return tokens >= 1000 ? `${Math.round(tokens / 1000)}K ctx` : `${tokens} ctx`
@@ -61,13 +95,47 @@ function toggle(id: string, on: boolean) {
61
95
  selected.value = next
62
96
  }
63
97
 
98
+ function enableRecommended() {
99
+ const next = new Set(selected.value)
100
+ for (const slug of recommendedAvailable.value) next.add(slug)
101
+ selected.value = next
102
+ }
103
+
104
+ async function connectKey() {
105
+ if (!keyValue.value.trim() || !workspace.workspaceId) return
106
+ connectingKey.value = true
107
+ try {
108
+ const input = {
109
+ provider: 'openrouter' as const,
110
+ label: keyLabel.value.trim() || 'openrouter key',
111
+ key: keyValue.value.trim(),
112
+ }
113
+ if (keyScope.value === 'workspace') await apiKeys.addWorkspaceKey(input)
114
+ else await apiKeys.addUserKey(input)
115
+ keyValue.value = ''
116
+ keyLabel.value = ''
117
+ toast.add({ title: 'OpenRouter key connected', icon: 'i-lucide-check', color: 'success' })
118
+ // Now that a key exists, load the live catalog automatically.
119
+ await refresh()
120
+ } catch (e) {
121
+ toast.add({
122
+ title: 'Could not connect key',
123
+ description: e instanceof Error ? e.message : String(e),
124
+ icon: 'i-lucide-triangle-alert',
125
+ color: 'error',
126
+ })
127
+ } finally {
128
+ connectingKey.value = false
129
+ }
130
+ }
131
+
64
132
  async function refresh() {
65
133
  if (!workspace.workspaceId) return
66
134
  const result = await store.refresh(workspace.workspaceId)
67
135
  if (!result.reachable) {
68
136
  toast.add({
69
137
  title: 'Could not reach OpenRouter',
70
- description: store.refreshError ?? 'Connect an OpenRouter key under Provider keys first.',
138
+ description: store.refreshError ?? 'Connect an OpenRouter key first.',
71
139
  icon: 'i-lucide-triangle-alert',
72
140
  color: 'error',
73
141
  })
@@ -80,10 +148,12 @@ async function save() {
80
148
  try {
81
149
  // Persist the ticked models, carrying the metadata from whichever list they came from.
82
150
  const byId = new Map(source.value.map((m) => [m.id, m]))
83
- const models = [...selected.value]
151
+ const models2 = [...selected.value]
84
152
  .map((id) => byId.get(id))
85
153
  .filter((m): m is OpenRouterModelMeta => !!m)
86
- await store.save(workspace.workspaceId, models)
154
+ await store.save(workspace.workspaceId, models2)
155
+ // Reflect newly-enabled models in the picker immediately.
156
+ await models.refresh(workspace.workspaceId)
87
157
  toast.add({ title: 'OpenRouter catalog saved', icon: 'i-lucide-check', color: 'success' })
88
158
  } catch (e) {
89
159
  toast.add({
@@ -96,80 +166,171 @@ async function save() {
96
166
  busy.value = false
97
167
  }
98
168
  }
169
+
170
+ function manageKeys() {
171
+ ui.closeOpenRouter()
172
+ ui.openVendorCredentials()
173
+ }
99
174
  </script>
100
175
 
101
176
  <template>
102
- <UModal v-model:open="open" title="OpenRouter models" :ui="{ content: 'max-w-2xl' }">
177
+ <UModal v-model:open="open" title="OpenRouter" :ui="{ content: 'max-w-2xl' }">
103
178
  <template #body>
104
179
  <div class="space-y-4">
105
180
  <p class="text-xs text-slate-400">
106
- Reach <strong>300+ models</strong> through one gateway. Connect an OpenRouter key under
107
- <span class="text-slate-300">Provider keys</span> first, then
108
- <span class="text-slate-300">Refresh</span> to browse the live catalog and enable the
109
- models you want. Enabled models appear in the model picker with their context window and
110
- price, and meter against your spend budget.
181
+ Reach <strong>300+ models</strong> through one gateway. Add your OpenRouter key below,
182
+ then enable the models you want — they appear in the model picker with their context
183
+ window and price, and meter against your spend budget.
111
184
  </p>
112
185
 
113
- <div class="flex items-center gap-2">
114
- <UButton
115
- color="neutral"
116
- variant="soft"
117
- size="sm"
118
- icon="i-lucide-refresh-cw"
119
- :loading="store.refreshing"
120
- @click="refresh()"
121
- >
122
- Refresh catalog
123
- </UButton>
124
- <UInput
125
- v-model="filter"
126
- size="sm"
127
- class="flex-1"
128
- icon="i-lucide-search"
129
- placeholder="Filter by name or slug…"
130
- />
131
- </div>
132
-
133
- <p v-if="store.refreshError" class="text-xs text-rose-400">{{ store.refreshError }}</p>
134
-
135
- <div v-if="visible.length" class="max-h-96 space-y-1 overflow-y-auto pr-1">
136
- <label
137
- v-for="m in visible"
138
- :key="m.id"
139
- class="flex items-center gap-2 rounded-md border border-slate-800 bg-slate-900/40 px-2.5 py-1.5 text-sm"
140
- >
141
- <UCheckbox
142
- :model-value="selected.has(m.id)"
143
- @update:model-value="(v: boolean | 'indeterminate') => toggle(m.id, v === true)"
186
+ <!-- Step 1: connect a key (inline) — hidden once a key is connected -->
187
+ <div
188
+ v-if="!keyConnected"
189
+ class="space-y-3 rounded-lg border border-slate-700 bg-slate-900/60 p-4"
190
+ >
191
+ <h4 class="text-xs font-semibold uppercase tracking-wide text-slate-500">
192
+ Connect your OpenRouter key
193
+ </h4>
194
+ <ol class="list-decimal space-y-1 pl-5 text-sm text-slate-300">
195
+ <li>
196
+ Open
197
+ <a
198
+ href="https://openrouter.ai/keys"
199
+ target="_blank"
200
+ rel="noopener noreferrer"
201
+ class="text-primary-400 underline"
202
+ >openrouter.ai Keys ↗</a
203
+ >
204
+ and create an API key.
205
+ </li>
206
+ <li>
207
+ Copy the key (starts with <span class="font-mono">sk-or-…</span>) and paste it below.
208
+ </li>
209
+ </ol>
210
+ <div class="flex flex-wrap items-end gap-3">
211
+ <UFormField label="Scope">
212
+ <USelect
213
+ v-model="keyScope"
214
+ :items="[
215
+ { label: 'This workspace', value: 'workspace' },
216
+ { label: 'My keys (only me)', value: 'user' },
217
+ ]"
218
+ class="w-48"
219
+ />
220
+ </UFormField>
221
+ <UFormField label="Label (optional)" class="flex-1">
222
+ <UInput v-model="keyLabel" placeholder="e.g. team key" />
223
+ </UFormField>
224
+ </div>
225
+ <UFormField label="API key">
226
+ <UTextarea
227
+ v-model="keyValue"
228
+ :rows="2"
229
+ placeholder="paste your OpenRouter key (sk-or-…)"
230
+ class="font-mono"
144
231
  />
145
- <span class="min-w-0 flex-1">
146
- <span class="block truncate text-slate-200">{{ m.name }}</span>
147
- <span class="block truncate font-mono text-[11px] text-slate-500">{{ m.id }}</span>
148
- </span>
149
- <span class="shrink-0 text-right text-[11px] text-slate-500">
150
- <span v-if="m.contextLength" class="block">{{ contextLabel(m.contextLength) }}</span>
151
- <span class="block">{{ priceLabel(m) }}</span>
152
- </span>
153
- </label>
232
+ </UFormField>
233
+ <div class="flex justify-end">
234
+ <UButton
235
+ :loading="connectingKey"
236
+ :disabled="!keyValue.trim()"
237
+ icon="i-lucide-plus"
238
+ @click="connectKey()"
239
+ >
240
+ Connect & browse
241
+ </UButton>
242
+ </div>
154
243
  </div>
155
- <p v-else class="text-xs text-slate-500">
156
- No models yet — hit <span class="text-slate-300">Refresh catalog</span> to load
157
- OpenRouter's live list.
158
- </p>
159
244
 
160
- <div class="flex items-center justify-between">
161
- <span class="text-xs text-slate-500">{{ selectedCount }} enabled</span>
162
- <UButton
163
- color="primary"
164
- variant="soft"
165
- size="sm"
166
- icon="i-lucide-save"
167
- :loading="busy"
168
- @click="save()"
169
- >
170
- Save
245
+ <!-- Key connected status -->
246
+ <div
247
+ v-else
248
+ class="flex items-center justify-between rounded-lg border border-slate-700 bg-slate-900/60 px-3 py-2 text-sm"
249
+ >
250
+ <span class="flex items-center gap-2 text-slate-300">
251
+ <UIcon name="i-lucide-check-circle" class="h-4 w-4 text-emerald-400" />
252
+ OpenRouter key connected
253
+ </span>
254
+ <UButton color="neutral" variant="ghost" size="xs" @click="manageKeys()">
255
+ Manage in Vendors & keys
171
256
  </UButton>
172
257
  </div>
258
+
259
+ <!-- Step 2: browse + enable models (only once a key exists) -->
260
+ <template v-if="keyConnected">
261
+ <div class="flex items-center gap-2">
262
+ <UButton
263
+ color="neutral"
264
+ variant="soft"
265
+ size="sm"
266
+ icon="i-lucide-refresh-cw"
267
+ :loading="store.refreshing"
268
+ @click="refresh()"
269
+ >
270
+ Refresh catalog
271
+ </UButton>
272
+ <UButton
273
+ v-if="recommendedAvailable.length"
274
+ color="primary"
275
+ variant="soft"
276
+ size="sm"
277
+ icon="i-lucide-sparkles"
278
+ @click="enableRecommended()"
279
+ >
280
+ Enable recommended
281
+ </UButton>
282
+ <UInput
283
+ v-model="filter"
284
+ size="sm"
285
+ class="flex-1"
286
+ icon="i-lucide-search"
287
+ placeholder="Filter by name or slug…"
288
+ />
289
+ </div>
290
+
291
+ <p v-if="store.refreshError" class="text-xs text-rose-400">{{ store.refreshError }}</p>
292
+
293
+ <div v-if="visible.length" class="max-h-96 space-y-1 overflow-y-auto pr-1">
294
+ <label
295
+ v-for="m in visible"
296
+ :key="m.id"
297
+ class="flex items-center gap-2 rounded-md border border-slate-800 bg-slate-900/40 px-2.5 py-1.5 text-sm"
298
+ >
299
+ <UCheckbox
300
+ :model-value="selected.has(m.id)"
301
+ @update:model-value="(v: boolean | 'indeterminate') => toggle(m.id, v === true)"
302
+ />
303
+ <span class="min-w-0 flex-1">
304
+ <span class="block truncate text-slate-200">{{ m.name }}</span>
305
+ <span class="block truncate font-mono text-[11px] text-slate-500">{{ m.id }}</span>
306
+ </span>
307
+ <span class="shrink-0 text-right text-[11px] text-slate-500">
308
+ <span v-if="m.contextLength" class="block">{{
309
+ contextLabel(m.contextLength)
310
+ }}</span>
311
+ <span class="block">{{ priceLabel(m) }}</span>
312
+ </span>
313
+ </label>
314
+ </div>
315
+ <p v-else class="text-xs text-slate-500">
316
+ No models yet — hit <span class="text-slate-300">Refresh catalog</span> to load
317
+ OpenRouter's live list.
318
+ </p>
319
+
320
+ <div class="flex items-center justify-between">
321
+ <span class="text-xs text-slate-500">{{ selectedCount }} enabled</span>
322
+ <UButton
323
+ color="primary"
324
+ variant="soft"
325
+ size="sm"
326
+ icon="i-lucide-save"
327
+ :loading="busy"
328
+ @click="save()"
329
+ >
330
+ Save
331
+ </UButton>
332
+ </div>
333
+ </template>
173
334
  </div>
174
335
  </template>
175
336
  </UModal>
@@ -0,0 +1,214 @@
1
+ <script setup lang="ts">
2
+ // Per-user settings: "My GitHub token" (and future per-user repository/provider secrets).
3
+ // A generic, descriptor-driven connect form: the backend declares each kind's fields
4
+ // (one secret + optional metadata) and whether a connection test is available; this
5
+ // renders them without hard-coding any kind. Stored PER USER (runs you initiate use YOUR
6
+ // access); the secret is write-only server-side and never shown again.
7
+ import { computed, ref, watch } from 'vue'
8
+ import type { ProviderConfigField, UserSecretKind } from '~/types/userSecrets'
9
+
10
+ const ui = useUiStore()
11
+ const store = useUserSecretsStore()
12
+ const toast = useToast()
13
+
14
+ const open = computed({
15
+ get: () => ui.userSecretsOpen,
16
+ set: (v: boolean) => (v ? ui.openUserSecrets() : ui.closeUserSecrets()),
17
+ })
18
+
19
+ // The kind being edited (only `github_pat` today; descriptors drive the rest generically).
20
+ const kind = ref<UserSecretKind>('github_pat')
21
+ const descriptor = computed(() => store.descriptorFor(kind.value))
22
+ const status = computed(() => store.statusFor(kind.value))
23
+
24
+ // Per-field draft values, keyed by field key. The secret field maps to the wire `secret`;
25
+ // all other fields map into `metadata`.
26
+ const values = ref<Record<string, string>>({})
27
+ const labelDraft = ref('')
28
+ const testResult = ref<{ ok: boolean; message?: string } | null>(null)
29
+ const testing = ref(false)
30
+ const busy = ref(false)
31
+
32
+ function resetDraft() {
33
+ values.value = {}
34
+ labelDraft.value = ''
35
+ testResult.value = null
36
+ // Prefill non-secret metadata from the stored status (secret stays blank — write-only).
37
+ const meta = status.value?.metadata
38
+ if (meta) for (const [k, v] of Object.entries(meta)) values.value[k] = v
39
+ }
40
+
41
+ watch(open, (isOpen) => {
42
+ if (isOpen) void store.load().then(resetDraft)
43
+ })
44
+ watch(kind, resetDraft)
45
+
46
+ const secretField = computed<ProviderConfigField | undefined>(() =>
47
+ descriptor.value?.configFields.find((f) => f.secret),
48
+ )
49
+ const metadataFields = computed<ProviderConfigField[]>(() =>
50
+ (descriptor.value?.configFields ?? []).filter((f) => !f.secret),
51
+ )
52
+
53
+ /** Build the wire payload: the secret field → `secret`, the rest → `metadata`. */
54
+ function buildPayload(): { secret: string; metadata?: Record<string, string> } | null {
55
+ const sf = secretField.value
56
+ if (!sf) return null
57
+ const secret = (values.value[sf.key] ?? '').trim()
58
+ if (!secret) return null
59
+ const metadata: Record<string, string> = {}
60
+ for (const f of metadataFields.value) {
61
+ const v = (values.value[f.key] ?? '').trim()
62
+ if (v) metadata[f.key] = v
63
+ }
64
+ return { secret, ...(Object.keys(metadata).length ? { metadata } : {}) }
65
+ }
66
+
67
+ function notifyError(title: string, e: unknown) {
68
+ toast.add({
69
+ title,
70
+ description: e instanceof Error ? e.message : String(e),
71
+ icon: 'i-lucide-triangle-alert',
72
+ color: 'error',
73
+ })
74
+ }
75
+
76
+ async function test() {
77
+ const payload = buildPayload()
78
+ if (!payload) return
79
+ testing.value = true
80
+ testResult.value = null
81
+ try {
82
+ testResult.value = await store.test(kind.value, payload)
83
+ } catch (e) {
84
+ testResult.value = { ok: false, message: e instanceof Error ? e.message : String(e) }
85
+ } finally {
86
+ testing.value = false
87
+ }
88
+ }
89
+
90
+ async function save() {
91
+ const payload = buildPayload()
92
+ if (!payload) return
93
+ busy.value = true
94
+ try {
95
+ await store.store(kind.value, { ...payload, label: labelDraft.value.trim() || undefined })
96
+ values.value[secretField.value!.key] = ''
97
+ testResult.value = null
98
+ toast.add({
99
+ title: `${descriptor.value?.label ?? 'Secret'} saved`,
100
+ icon: 'i-lucide-check',
101
+ color: 'success',
102
+ })
103
+ } catch (e) {
104
+ notifyError('Could not save secret', e)
105
+ } finally {
106
+ busy.value = false
107
+ }
108
+ }
109
+
110
+ async function remove() {
111
+ busy.value = true
112
+ try {
113
+ await store.remove(kind.value)
114
+ resetDraft()
115
+ toast.add({ title: 'Secret removed', icon: 'i-lucide-check' })
116
+ } catch (e) {
117
+ notifyError('Could not remove secret', e)
118
+ } finally {
119
+ busy.value = false
120
+ }
121
+ }
122
+ </script>
123
+
124
+ <template>
125
+ <UModal v-model:open="open" title="My GitHub token" :ui="{ content: 'max-w-xl' }">
126
+ <template #body>
127
+ <div class="space-y-4">
128
+ <p class="text-xs text-slate-400">
129
+ Store a personal access token used for the runs <strong>you</strong> start — pushes, pull
130
+ requests, the CI gate and merges are attributed to your GitHub access. Stored
131
+ <span class="text-slate-300">just for you</span>; the token is write-only and never shown
132
+ again.
133
+ </p>
134
+
135
+ <div
136
+ v-if="status"
137
+ class="flex items-center justify-between rounded-md border border-slate-700 bg-slate-900/50 px-3 py-2 text-sm"
138
+ >
139
+ <div>
140
+ <span class="font-medium text-slate-200">{{ status.label }}</span>
141
+ <div class="text-[11px] text-emerald-400">Connected · token stored</div>
142
+ </div>
143
+ <UButton
144
+ icon="i-lucide-trash-2"
145
+ color="error"
146
+ variant="ghost"
147
+ size="xs"
148
+ :disabled="busy"
149
+ @click="remove()"
150
+ />
151
+ </div>
152
+
153
+ <div
154
+ v-if="descriptor"
155
+ class="rounded-lg border border-dashed border-slate-700 p-3 space-y-3"
156
+ >
157
+ <p class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
158
+ {{ status ? 'Replace token' : 'Add a token' }}
159
+ </p>
160
+
161
+ <UFormField label="Label (optional)">
162
+ <UInput v-model="labelDraft" :placeholder="descriptor.label" />
163
+ </UFormField>
164
+
165
+ <UFormField
166
+ v-for="field in descriptor.configFields"
167
+ :key="field.key"
168
+ :label="field.label + (field.required ? '' : ' (optional)')"
169
+ :help="field.help"
170
+ >
171
+ <UInput
172
+ v-model="values[field.key]"
173
+ :type="field.secret ? 'password' : 'text'"
174
+ class="font-mono"
175
+ :placeholder="field.placeholder"
176
+ />
177
+ </UFormField>
178
+
179
+ <div v-if="descriptor.supportsTest" class="flex items-center gap-2">
180
+ <UButton
181
+ color="neutral"
182
+ variant="soft"
183
+ size="sm"
184
+ icon="i-lucide-plug-zap"
185
+ :loading="testing"
186
+ :disabled="!buildPayload()"
187
+ @click="test()"
188
+ >
189
+ Test connection
190
+ </UButton>
191
+ <span v-if="testResult && testResult.ok" class="text-xs text-emerald-400">
192
+ {{ testResult.message ?? 'Token valid' }}
193
+ </span>
194
+ <span v-else-if="testResult" class="text-xs text-rose-400">
195
+ {{ testResult.message ?? 'Token rejected' }}
196
+ </span>
197
+ </div>
198
+
199
+ <div class="flex justify-end">
200
+ <UButton
201
+ color="primary"
202
+ size="sm"
203
+ :loading="busy"
204
+ :disabled="!buildPayload()"
205
+ @click="save()"
206
+ >
207
+ {{ status ? 'Save' : 'Add token' }}
208
+ </UButton>
209
+ </div>
210
+ </div>
211
+ </div>
212
+ </template>
213
+ </UModal>
214
+ </template>
@@ -0,0 +1,31 @@
1
+ import type {
2
+ ConnectionTestResult,
3
+ StoreUserSecretInput,
4
+ TestUserSecretInput,
5
+ UserSecretDescriptor,
6
+ UserSecretKind,
7
+ UserSecretStatus,
8
+ } from '~/types/userSecrets'
9
+ import type { ApiContext } from './context'
10
+
11
+ // Per-USER generic secrets (a GitHub PAT today). User-scoped (no workspace); the secret
12
+ // is write-only server-side and never returned — only status metadata + a `hasSecret`
13
+ // flag. `descriptors` drive the generic connect form; `test` probes before save.
14
+ export function userSecretsApi({ http }: ApiContext) {
15
+ return {
16
+ listUserSecrets: () =>
17
+ http<{ secrets: UserSecretStatus[]; descriptors: UserSecretDescriptor[] }>('/user-secrets'),
18
+
19
+ storeUserSecret: (kind: UserSecretKind, body: StoreUserSecretInput) =>
20
+ http<UserSecretStatus>(`/user-secrets/${encodeURIComponent(kind)}`, { method: 'POST', body }),
21
+
22
+ deleteUserSecret: (kind: UserSecretKind) =>
23
+ http(`/user-secrets/${encodeURIComponent(kind)}`, { method: 'DELETE' }),
24
+
25
+ testUserSecret: (kind: UserSecretKind, body: TestUserSecretInput) =>
26
+ http<ConnectionTestResult>(`/user-secrets/${encodeURIComponent(kind)}/test`, {
27
+ method: 'POST',
28
+ body,
29
+ }),
30
+ }
31
+ }
@@ -17,6 +17,7 @@ import { reviewsApi } from './api/reviews'
17
17
  import { slackApi } from './api/slack'
18
18
  import { specApi } from './api/spec'
19
19
  import { tasksApi } from './api/tasks'
20
+ import { userSecretsApi } from './api/userSecrets'
20
21
  import { workspacesApi } from './api/workspaces'
21
22
 
22
23
  /**
@@ -87,5 +88,6 @@ export function useApi() {
87
88
  ...githubApi(ctx),
88
89
  ...slackApi(ctx),
89
90
  ...bootstrapApi(ctx),
91
+ ...userSecretsApi(ctx),
90
92
  }
91
93
  }
@@ -31,6 +31,7 @@ import WorkspaceSettingsPanel from '~/components/settings/WorkspaceSettingsPanel
31
31
  import ObservabilityConnectionPanel from '~/components/settings/ObservabilityConnectionPanel.vue'
32
32
  import ModelConfigurationPanel from '~/components/settings/ModelConfigurationPanel.vue'
33
33
  import LocalModelEndpointsPanel from '~/components/settings/LocalModelEndpointsPanel.vue'
34
+ import UserSecretsSection from '~/components/settings/UserSecretsSection.vue'
34
35
  import OpenRouterCatalogPanel from '~/components/settings/OpenRouterCatalogPanel.vue'
35
36
  import VendorCredentialsModal from '~/components/providers/VendorCredentialsModal.vue'
36
37
  import PersonalCredentialModal from '~/components/providers/PersonalCredentialModal.vue'
@@ -183,6 +184,7 @@ watch(
183
184
  <ObservabilityConnectionPanel />
184
185
  <ModelConfigurationPanel />
185
186
  <LocalModelEndpointsPanel />
187
+ <UserSecretsSection />
186
188
  <OpenRouterCatalogPanel />
187
189
  <VendorCredentialsModal />
188
190
  <PersonalCredentialModal />
package/app/stores/ui.ts CHANGED
@@ -102,6 +102,7 @@ export const useUiStore = defineStore('ui', () => {
102
102
  const vendorCredentialsOpen = ref(false)
103
103
  // Per-user settings panel: the signed-in user's own-machine local model runners.
104
104
  const localModelsOpen = ref(false)
105
+ const userSecretsOpen = ref(false)
105
106
  // Per-workspace settings panel: the OpenRouter dynamic catalog (browse/enable gateway models).
106
107
  const openRouterOpen = ref(false)
107
108
 
@@ -358,6 +359,12 @@ export const useUiStore = defineStore('ui', () => {
358
359
  function closeLocalModels() {
359
360
  localModelsOpen.value = false
360
361
  }
362
+ function openUserSecrets() {
363
+ userSecretsOpen.value = true
364
+ }
365
+ function closeUserSecrets() {
366
+ userSecretsOpen.value = false
367
+ }
361
368
  function openOpenRouter() {
362
369
  openRouterOpen.value = true
363
370
  }
@@ -451,6 +458,7 @@ export const useUiStore = defineStore('ui', () => {
451
458
  modelConfigOpen,
452
459
  vendorCredentialsOpen,
453
460
  localModelsOpen,
461
+ userSecretsOpen,
454
462
  openRouterOpen,
455
463
  aiProviderSetupOpen,
456
464
  aiPresetMismatchOpen,
@@ -512,6 +520,8 @@ export const useUiStore = defineStore('ui', () => {
512
520
  closeVendorCredentials,
513
521
  openLocalModels,
514
522
  closeLocalModels,
523
+ openUserSecrets,
524
+ closeUserSecrets,
515
525
  openOpenRouter,
516
526
  closeOpenRouter,
517
527
  openAiProviderSetup,
@@ -0,0 +1,64 @@
1
+ import { defineStore } from 'pinia'
2
+ import { ref } from 'vue'
3
+ import type {
4
+ ConnectionTestResult,
5
+ StoreUserSecretInput,
6
+ TestUserSecretInput,
7
+ UserSecretDescriptor,
8
+ UserSecretKind,
9
+ UserSecretStatus,
10
+ } from '~/types/userSecrets'
11
+
12
+ // The signed-in user's generic secrets (a GitHub PAT today). Stored PER USER (a run you
13
+ // initiate uses YOUR GitHub access), write-only server-side — this store carries only the
14
+ // status metadata + a `hasSecret` flag, plus the kind descriptors that drive the generic
15
+ // connect form. Loaded INDEPENDENTLY of the workspace snapshot, like local runners.
16
+ export const useUserSecretsStore = defineStore('userSecrets', () => {
17
+ const api = useApi()
18
+ const secrets = ref<UserSecretStatus[]>([])
19
+ const descriptors = ref<UserSecretDescriptor[]>([])
20
+ const loading = ref(false)
21
+
22
+ async function load() {
23
+ loading.value = true
24
+ try {
25
+ const { secrets: list, descriptors: descs } = await api.listUserSecrets()
26
+ secrets.value = list
27
+ descriptors.value = descs
28
+ } catch {
29
+ // Auth disabled / not signed in / feature off → nothing to surface.
30
+ secrets.value = []
31
+ descriptors.value = []
32
+ } finally {
33
+ loading.value = false
34
+ }
35
+ }
36
+
37
+ function statusFor(kind: UserSecretKind): UserSecretStatus | undefined {
38
+ return secrets.value.find((s) => s.kind === kind)
39
+ }
40
+
41
+ function descriptorFor(kind: UserSecretKind): UserSecretDescriptor | undefined {
42
+ return descriptors.value.find((d) => d.kind === kind)
43
+ }
44
+
45
+ async function store(kind: UserSecretKind, input: StoreUserSecretInput) {
46
+ const status = await api.storeUserSecret(kind, input)
47
+ secrets.value = [...secrets.value.filter((s) => s.kind !== kind), status]
48
+ return status
49
+ }
50
+
51
+ async function remove(kind: UserSecretKind) {
52
+ await api.deleteUserSecret(kind)
53
+ secrets.value = secrets.value.filter((s) => s.kind !== kind)
54
+ }
55
+
56
+ async function test(
57
+ kind: UserSecretKind,
58
+ input: TestUserSecretInput,
59
+ ): Promise<ConnectionTestResult> {
60
+ return await api.testUserSecret(kind, input)
61
+ }
62
+
63
+ return { secrets, descriptors, loading, load, statusFor, descriptorFor, store, remove, test }
64
+ })
@@ -0,0 +1,49 @@
1
+ // Frontend mirrors of the per-user secret + provider-config wire contracts
2
+ // (`@cat-factory/contracts` user-secret.ts + provider-config.ts).
3
+
4
+ export type UserSecretKind = 'github_pat'
5
+
6
+ /** One config value a kind needs, rendered as a single form field. */
7
+ export interface ProviderConfigField {
8
+ key: string
9
+ label: string
10
+ help?: string
11
+ placeholder?: string
12
+ secret?: boolean
13
+ required?: boolean
14
+ type?: 'text' | 'password' | 'select'
15
+ options?: { value: string; label: string }[]
16
+ }
17
+
18
+ /** Read-only status of one stored per-user secret — never the secret value. */
19
+ export interface UserSecretStatus {
20
+ kind: UserSecretKind
21
+ label: string
22
+ hasSecret: boolean
23
+ metadata?: Record<string, string>
24
+ connectedAt: number
25
+ }
26
+
27
+ /** A kind's self-description for the generic connect form. */
28
+ export interface UserSecretDescriptor {
29
+ kind: UserSecretKind
30
+ label: string
31
+ configFields: ProviderConfigField[]
32
+ supportsTest: boolean
33
+ }
34
+
35
+ export interface StoreUserSecretInput {
36
+ label?: string
37
+ secret: string
38
+ metadata?: Record<string, string>
39
+ }
40
+
41
+ export interface TestUserSecretInput {
42
+ secret: string
43
+ metadata?: Record<string, string>
44
+ }
45
+
46
+ export interface ConnectionTestResult {
47
+ ok: boolean
48
+ message?: string
49
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.18.1",
3
+ "version": "0.20.0",
4
4
  "description": "Reusable Nuxt layer for the Agent Architecture Board SPA (components, stores, composables, pages). Consume it from a thin deployment app via `extends: ['@cat-factory/app']` and point it at your backend with NUXT_PUBLIC_API_BASE. See deploy/frontend for an example.",
5
5
  "repository": {
6
6
  "type": "git",