@cat-factory/app 0.18.1 → 0.19.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,7 @@ const documents = useDocumentsStore()
14
14
  const tasks = useTasksStore()
15
15
  const tracker = useTrackerStore()
16
16
  const releaseHealth = useReleaseHealthStore()
17
+ const userSecrets = useUserSecretsStore()
17
18
 
18
19
  // The selected filing tracker, as a badge label ("GitHub Issues" / "Jira").
19
20
  const trackerLabel = computed(() => {
@@ -27,7 +28,10 @@ const trackerLabel = computed(() => {
27
28
  watch(
28
29
  () => ui.integrationsOpen,
29
30
  (isOpen) => {
30
- if (isOpen) void releaseHealth.ensureLoaded().catch(() => {})
31
+ if (isOpen) {
32
+ void releaseHealth.ensureLoaded().catch(() => {})
33
+ void userSecrets.load().catch(() => {})
34
+ }
31
35
  },
32
36
  )
33
37
 
@@ -75,6 +79,20 @@ const groups = computed<IntegrationGroup[]>(() => {
75
79
  onClick: () => go(ui.openGitHub),
76
80
  })
77
81
  }
82
+ // Per-user GitHub PAT — works on every runtime (used for runs you initiate). Always
83
+ // offered; the badge reflects whether the signed-in user has stored one.
84
+ {
85
+ const pat = userSecrets.statusFor('github_pat')
86
+ code.push({
87
+ key: 'github-pat',
88
+ icon: 'i-lucide-key-round',
89
+ label: 'My GitHub token',
90
+ description: 'A personal access token used for runs you start (pushes, PRs, CI, merge).',
91
+ status: pat ? 'Connected' : undefined,
92
+ connected: !!pat,
93
+ onClick: () => go(ui.openUserSecrets),
94
+ })
95
+ }
78
96
  if (code.length) out.push({ title: 'Source control', items: code })
79
97
 
80
98
  // --- Communication ---------------------------------------------------------
@@ -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.19.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",