@cat-factory/app 0.26.7 → 0.27.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.
@@ -0,0 +1,236 @@
1
+ <script setup lang="ts">
2
+ import { computed, onMounted, reactive, ref } from 'vue'
3
+
4
+ // 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).
10
+ const props = defineProps<{ accountId: string }>()
11
+
12
+ const store = useAccountSettingsStore()
13
+ const toast = useToast()
14
+
15
+ const slack = reactive({ clientId: '', clientSecret: '', redirectUrl: '' })
16
+ const web = reactive({ braveApiKey: '', searxngUrl: '', searxngApiKey: '' })
17
+ const savingSlack = ref(false)
18
+ const savingWeb = ref(false)
19
+
20
+ const summary = computed(() => store.view?.summary ?? null)
21
+
22
+ onMounted(async () => {
23
+ try {
24
+ await store.load(props.accountId)
25
+ } catch (e) {
26
+ toast.add({
27
+ title: 'Could not load deployment settings',
28
+ description: e instanceof Error ? e.message : String(e),
29
+ icon: 'i-lucide-triangle-alert',
30
+ color: 'error',
31
+ })
32
+ }
33
+ })
34
+
35
+ async function saveSlack() {
36
+ if (!slack.clientId.trim() || !slack.clientSecret.trim() || !slack.redirectUrl.trim()) {
37
+ toast.add({ title: 'Enter the client id, secret and redirect URL', color: 'error' })
38
+ return
39
+ }
40
+ savingSlack.value = true
41
+ try {
42
+ await store.save(props.accountId, {
43
+ secrets: {
44
+ slackOAuth: {
45
+ clientId: slack.clientId.trim(),
46
+ clientSecret: slack.clientSecret.trim(),
47
+ redirectUrl: slack.redirectUrl.trim(),
48
+ },
49
+ },
50
+ })
51
+ slack.clientId = ''
52
+ slack.clientSecret = ''
53
+ slack.redirectUrl = ''
54
+ toast.add({ title: 'Slack OAuth saved', icon: 'i-lucide-check', color: 'success' })
55
+ } catch (e) {
56
+ toast.add({
57
+ title: 'Could not save Slack OAuth',
58
+ description: e instanceof Error ? e.message : String(e),
59
+ color: 'error',
60
+ })
61
+ } finally {
62
+ savingSlack.value = false
63
+ }
64
+ }
65
+
66
+ async function clearSlack() {
67
+ savingSlack.value = true
68
+ try {
69
+ await store.save(props.accountId, { secrets: { slackOAuth: null } })
70
+ toast.add({ title: 'Slack OAuth cleared', icon: 'i-lucide-check', color: 'success' })
71
+ } catch (e) {
72
+ toast.add({
73
+ title: 'Could not clear Slack OAuth',
74
+ description: e instanceof Error ? e.message : String(e),
75
+ color: 'error',
76
+ })
77
+ } finally {
78
+ savingSlack.value = false
79
+ }
80
+ }
81
+
82
+ async function saveWeb() {
83
+ const brave = web.braveApiKey.trim()
84
+ const searxng = web.searxngUrl.trim()
85
+ if (!brave && !searxng) {
86
+ toast.add({ title: 'Enter a Brave key or a SearXNG URL', color: 'error' })
87
+ return
88
+ }
89
+ savingWeb.value = true
90
+ try {
91
+ await store.save(props.accountId, {
92
+ secrets: {
93
+ webSearch: {
94
+ ...(brave ? { braveApiKey: brave } : {}),
95
+ ...(searxng ? { searxngUrl: searxng } : {}),
96
+ ...(web.searxngApiKey.trim() ? { searxngApiKey: web.searxngApiKey.trim() } : {}),
97
+ },
98
+ },
99
+ })
100
+ web.braveApiKey = ''
101
+ web.searxngUrl = ''
102
+ web.searxngApiKey = ''
103
+ toast.add({ title: 'Web search keys saved', icon: 'i-lucide-check', color: 'success' })
104
+ } catch (e) {
105
+ toast.add({
106
+ title: 'Could not save web search keys',
107
+ description: e instanceof Error ? e.message : String(e),
108
+ color: 'error',
109
+ })
110
+ } finally {
111
+ savingWeb.value = false
112
+ }
113
+ }
114
+
115
+ async function clearWeb() {
116
+ savingWeb.value = true
117
+ try {
118
+ await store.save(props.accountId, { secrets: { webSearch: null } })
119
+ toast.add({ title: 'Web search keys cleared', icon: 'i-lucide-check', color: 'success' })
120
+ } catch (e) {
121
+ toast.add({
122
+ title: 'Could not clear web search keys',
123
+ description: e instanceof Error ? e.message : String(e),
124
+ color: 'error',
125
+ })
126
+ } finally {
127
+ savingWeb.value = false
128
+ }
129
+ }
130
+ </script>
131
+
132
+ <template>
133
+ <div v-if="store.available !== false" class="space-y-6">
134
+ <div>
135
+ <h3 class="mb-1 font-semibold text-white">Deployment integrations</h3>
136
+ <p class="text-[11px] text-slate-400">
137
+ Credentials shared by every workspace in this account, sealed at rest. Values are never
138
+ shown after saving; leave a field blank to keep the stored secret.
139
+ </p>
140
+ </div>
141
+
142
+ <!-- Slack app OAuth -->
143
+ <section class="space-y-2">
144
+ <div class="flex items-center gap-2">
145
+ <h4 class="text-sm font-semibold text-slate-200">Slack app (OAuth)</h4>
146
+ <UBadge
147
+ :color="summary?.slackOAuthConfigured ? 'success' : 'neutral'"
148
+ variant="subtle"
149
+ size="xs"
150
+ >
151
+ {{ summary?.slackOAuthConfigured ? 'Configured' : 'Not set' }}
152
+ </UBadge>
153
+ </div>
154
+ <p class="text-[11px] text-slate-400">
155
+ Enables the "Add to Slack" OAuth flow. Without it, workspaces can still connect Slack by
156
+ pasting a bot token.
157
+ </p>
158
+ <div class="grid grid-cols-1 gap-2 sm:grid-cols-3">
159
+ <UInput v-model="slack.clientId" placeholder="Client ID" size="sm" />
160
+ <UInput
161
+ v-model="slack.clientSecret"
162
+ type="password"
163
+ placeholder="Client secret"
164
+ size="sm"
165
+ />
166
+ <UInput v-model="slack.redirectUrl" placeholder="Redirect URL" size="sm" />
167
+ </div>
168
+ <div class="flex gap-2">
169
+ <UButton
170
+ color="primary"
171
+ size="xs"
172
+ icon="i-lucide-save"
173
+ :loading="savingSlack"
174
+ @click="saveSlack"
175
+ >
176
+ Save
177
+ </UButton>
178
+ <UButton
179
+ v-if="summary?.slackOAuthConfigured"
180
+ color="neutral"
181
+ variant="ghost"
182
+ size="xs"
183
+ :loading="savingSlack"
184
+ @click="clearSlack"
185
+ >
186
+ Clear
187
+ </UButton>
188
+ </div>
189
+ </section>
190
+
191
+ <!-- Web search keys -->
192
+ <section class="space-y-2 border-t border-slate-800 pt-6">
193
+ <div class="flex items-center gap-2">
194
+ <h4 class="text-sm font-semibold text-slate-200">Container web search</h4>
195
+ <UBadge :color="summary?.webSearch ? 'success' : 'neutral'" variant="subtle" size="xs">
196
+ {{ summary?.webSearch ? `Configured (${summary.webSearch})` : 'Not set' }}
197
+ </UBadge>
198
+ </div>
199
+ <p class="text-[11px] text-slate-400">
200
+ The search upstream container agents reach through the backend proxy. Set a Brave key
201
+ (recommended), or a self-hosted SearXNG URL (with an optional bearer key).
202
+ </p>
203
+ <div class="grid grid-cols-1 gap-2 sm:grid-cols-3">
204
+ <UInput v-model="web.braveApiKey" type="password" placeholder="Brave API key" size="sm" />
205
+ <UInput v-model="web.searxngUrl" placeholder="SearXNG URL" size="sm" />
206
+ <UInput
207
+ v-model="web.searxngApiKey"
208
+ type="password"
209
+ placeholder="SearXNG key (optional)"
210
+ size="sm"
211
+ />
212
+ </div>
213
+ <div class="flex gap-2">
214
+ <UButton
215
+ color="primary"
216
+ size="xs"
217
+ icon="i-lucide-save"
218
+ :loading="savingWeb"
219
+ @click="saveWeb"
220
+ >
221
+ Save
222
+ </UButton>
223
+ <UButton
224
+ v-if="summary?.webSearch"
225
+ color="neutral"
226
+ variant="ghost"
227
+ size="xs"
228
+ :loading="savingWeb"
229
+ @click="clearWeb"
230
+ >
231
+ Clear
232
+ </UButton>
233
+ </div>
234
+ </section>
235
+ </div>
236
+ </template>
@@ -1,6 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, onMounted, ref } from 'vue'
3
3
  import type { AccountRole } from '~/types/domain'
4
+ import AccountDeploymentSettings from '~/components/layout/AccountDeploymentSettings.vue'
4
5
 
5
6
  // Team settings for an org account: the member roster (with combinable admin /
6
7
  // developer / product roles), pending email invitations, and the per-account
@@ -236,5 +237,10 @@ async function disconnectEmail() {
236
237
  <ProvidersApiKeysSection :account-id="accountId" category="proxy" />
237
238
  </div>
238
239
  </section>
240
+
241
+ <!-- deployment integration secrets (admin-only): Slack OAuth app + web-search keys -->
242
+ <section v-if="isAdmin">
243
+ <AccountDeploymentSettings :account-id="accountId" />
244
+ </section>
239
245
  </div>
240
246
  </template>
@@ -25,6 +25,12 @@ const provider = ref<ObservabilityProviderKind>('datadog')
25
25
  const datadog = reactive({ site: 'datadoghq.com', apiKey: '', appKey: '' })
26
26
  const busy = ref(false)
27
27
 
28
+ // Incident enrichment (PagerDuty + incident.io) — write-only secrets; blank leaves the
29
+ // stored value unchanged. Paired with observability since it acts on the same regression.
30
+ const pagerDuty = reactive({ apiToken: '', fromEmail: '' })
31
+ const incidentIo = reactive({ apiKey: '' })
32
+ const incidentBusy = ref(false)
33
+
28
34
  function notifyError(title: string, e: unknown) {
29
35
  toast.add({
30
36
  title,
@@ -41,11 +47,50 @@ watch(open, async (isOpen) => {
41
47
  if (store.connection.provider) provider.value = store.connection.provider
42
48
  const site = store.connection.summary?.site
43
49
  if (site) datadog.site = site
50
+ await store.loadIncident()
44
51
  } catch (e) {
45
52
  notifyError('Could not load observability settings', e)
46
53
  }
47
54
  })
48
55
 
56
+ async function saveIncident() {
57
+ incidentBusy.value = true
58
+ try {
59
+ const input: Parameters<typeof store.saveIncident>[0] = {}
60
+ if (pagerDuty.apiToken.trim() && pagerDuty.fromEmail.trim()) {
61
+ input.pagerDuty = {
62
+ apiToken: pagerDuty.apiToken.trim(),
63
+ fromEmail: pagerDuty.fromEmail.trim(),
64
+ }
65
+ }
66
+ if (incidentIo.apiKey.trim()) input.incidentIo = { apiKey: incidentIo.apiKey.trim() }
67
+ if (!input.pagerDuty && !input.incidentIo) {
68
+ toast.add({ title: 'Enter PagerDuty or incident.io credentials', color: 'error' })
69
+ return
70
+ }
71
+ await store.saveIncident(input)
72
+ pagerDuty.apiToken = ''
73
+ pagerDuty.fromEmail = ''
74
+ incidentIo.apiKey = ''
75
+ toast.add({ title: 'Incident enrichment saved', icon: 'i-lucide-check', color: 'success' })
76
+ } catch (e) {
77
+ notifyError('Could not save incident enrichment', e)
78
+ } finally {
79
+ incidentBusy.value = false
80
+ }
81
+ }
82
+
83
+ async function disconnectIncident() {
84
+ incidentBusy.value = true
85
+ try {
86
+ await store.removeIncident()
87
+ } catch (e) {
88
+ notifyError('Could not disconnect incident enrichment', e)
89
+ } finally {
90
+ incidentBusy.value = false
91
+ }
92
+ }
93
+
49
94
  async function saveConnection() {
50
95
  busy.value = true
51
96
  try {
@@ -145,6 +190,53 @@ const connectedLabel = computed(() => {
145
190
  </UButton>
146
191
  </div>
147
192
  </section>
193
+
194
+ <!-- Incident enrichment (optional): annotate an incident PagerDuty / incident.io
195
+ already opened from the same monitors/SLOs. -->
196
+ <section
197
+ v-if="store.incidentAvailable !== false"
198
+ class="space-y-3 rounded-lg border border-slate-700 p-3"
199
+ >
200
+ <div class="flex items-center justify-between">
201
+ <h3 class="text-sm font-semibold">Incident enrichment</h3>
202
+ <UBadge :color="store.incident.connected ? 'success' : 'neutral'" variant="soft">
203
+ {{ store.incident.connected ? 'Configured' : 'Not set' }}
204
+ </UBadge>
205
+ </div>
206
+ <p class="text-[11px] text-slate-400">
207
+ Optional. On a regression, the on-call investigation is posted onto an incident these
208
+ systems ALREADY opened from the same signals (annotate, never re-alert). Secrets are
209
+ write-only — blank leaves a stored value unchanged.
210
+ </p>
211
+
212
+ <UFormField label="PagerDuty API token">
213
+ <UInput v-model="pagerDuty.apiToken" type="password" class="w-full" />
214
+ </UFormField>
215
+ <UFormField label="PagerDuty From email">
216
+ <UInput
217
+ v-model="pagerDuty.fromEmail"
218
+ type="email"
219
+ placeholder="oncall@example.com"
220
+ class="w-full"
221
+ />
222
+ </UFormField>
223
+ <UFormField label="incident.io API key">
224
+ <UInput v-model="incidentIo.apiKey" type="password" class="w-full" />
225
+ </UFormField>
226
+
227
+ <div class="flex gap-2">
228
+ <UButton :loading="incidentBusy" @click="saveIncident">Save</UButton>
229
+ <UButton
230
+ v-if="store.incident.connected"
231
+ color="error"
232
+ variant="soft"
233
+ :loading="incidentBusy"
234
+ @click="disconnectIncident"
235
+ >
236
+ Clear
237
+ </UButton>
238
+ </div>
239
+ </section>
148
240
  </div>
149
241
  </template>
150
242
  </UModal>
@@ -8,7 +8,7 @@
8
8
  // The latter three are body-only section components rendered in tabs here (no longer
9
9
  // standalone modals).
10
10
  import { reactive, ref, watch } from 'vue'
11
- import type { CreateTaskType, TaskLimitMode } from '~/types/domain'
11
+ import type { CreateTaskType, TaskLimitMode, WorkspaceSettings } from '~/types/domain'
12
12
  import MergeThresholdsPanel from '~/components/settings/MergeThresholdsPanel.vue'
13
13
  import IssueTrackerPanel from '~/components/settings/IssueTrackerPanel.vue'
14
14
  import ServiceFragmentDefaultsPanel from '~/components/settings/ServiceFragmentDefaultsPanel.vue'
@@ -36,6 +36,7 @@ const tabs = [
36
36
  icon: 'i-lucide-sliders-horizontal',
37
37
  slot: 'workspace',
38
38
  },
39
+ { value: 'budget', label: 'Budget', icon: 'i-lucide-wallet', slot: 'budget' },
39
40
  { value: 'merge', label: 'Merge thresholds', icon: 'i-lucide-git-merge', slot: 'merge' },
40
41
  {
41
42
  value: 'tracker',
@@ -64,6 +65,10 @@ const draft = reactive({
64
65
  taskLimitMode: 'off' as TaskLimitMode,
65
66
  taskLimitShared: 5 as number,
66
67
  perType: {} as Record<CreateTaskType, number>,
68
+ // Budget: empty string ⇒ "use the built-in default" (null on the wire).
69
+ spendCurrency: '',
70
+ spendMonthlyLimit: '',
71
+ spendModelPrices: '',
67
72
  })
68
73
 
69
74
  function hydrate() {
@@ -73,6 +78,9 @@ function hydrate() {
73
78
  draft.taskLimitShared = s.taskLimitShared ?? 5
74
79
  const pt = s.taskLimitPerType ?? {}
75
80
  for (const t of TASK_TYPES) draft.perType[t] = pt[t] ?? 3
81
+ draft.spendCurrency = s.spendCurrency ?? ''
82
+ draft.spendMonthlyLimit = s.spendMonthlyLimit == null ? '' : String(s.spendMonthlyLimit)
83
+ draft.spendModelPrices = s.spendModelPrices ? JSON.stringify(s.spendModelPrices, null, 2) : ''
76
84
  }
77
85
 
78
86
  watch(() => store.settings, hydrate, { immediate: true, deep: true })
@@ -109,6 +117,45 @@ async function save() {
109
117
  saving.value = false
110
118
  }
111
119
  }
120
+
121
+ const savingBudget = ref(false)
122
+
123
+ async function saveBudget() {
124
+ // Parse the optional per-model price overrides JSON (blank ⇒ no overrides).
125
+ let prices: WorkspaceSettings['spendModelPrices'] = null
126
+ const raw = draft.spendModelPrices.trim()
127
+ if (raw) {
128
+ try {
129
+ prices = JSON.parse(raw)
130
+ } catch {
131
+ toast.add({
132
+ title: 'Per-model prices must be valid JSON',
133
+ icon: 'i-lucide-triangle-alert',
134
+ color: 'error',
135
+ })
136
+ return
137
+ }
138
+ }
139
+ savingBudget.value = true
140
+ try {
141
+ await store.update({
142
+ spendCurrency: draft.spendCurrency.trim() ? draft.spendCurrency.trim().toUpperCase() : null,
143
+ spendMonthlyLimit:
144
+ draft.spendMonthlyLimit.trim() === '' ? null : Number(draft.spendMonthlyLimit),
145
+ spendModelPrices: prices,
146
+ })
147
+ toast.add({ title: 'Budget saved', icon: 'i-lucide-check', color: 'success' })
148
+ } catch (e) {
149
+ toast.add({
150
+ title: 'Could not save budget',
151
+ description: e instanceof Error ? e.message : String(e),
152
+ icon: 'i-lucide-triangle-alert',
153
+ color: 'error',
154
+ })
155
+ } finally {
156
+ savingBudget.value = false
157
+ }
158
+ }
112
159
  </script>
113
160
 
114
161
  <template>
@@ -195,6 +242,74 @@ async function save() {
195
242
  </div>
196
243
  </template>
197
244
 
245
+ <!-- Budget -->
246
+ <template #budget>
247
+ <div class="space-y-6">
248
+ <section class="space-y-2">
249
+ <h3 class="text-sm font-semibold text-slate-200">Monthly spend budget</h3>
250
+ <p class="text-[11px] text-slate-400">
251
+ Token usage is metered per LLM call, priced, and gated by this budget — when
252
+ reached, runs in this workspace pause and the board shows a warning. Leave blank to
253
+ inherit the built-in default (~100&nbsp;EUR/month).
254
+ </p>
255
+ <div class="grid grid-cols-2 gap-3">
256
+ <label class="block">
257
+ <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
258
+ Monthly limit
259
+ </span>
260
+ <UInput
261
+ v-model="draft.spendMonthlyLimit"
262
+ type="number"
263
+ :min="0"
264
+ placeholder="Default"
265
+ size="sm"
266
+ />
267
+ </label>
268
+ <label class="block">
269
+ <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
270
+ Currency (ISO 4217)
271
+ </span>
272
+ <UInput
273
+ v-model="draft.spendCurrency"
274
+ placeholder="EUR"
275
+ maxlength="3"
276
+ size="sm"
277
+ class="uppercase"
278
+ />
279
+ </label>
280
+ </div>
281
+ </section>
282
+
283
+ <section class="space-y-2">
284
+ <h3 class="text-sm font-semibold text-slate-200">Per-model price overrides</h3>
285
+ <p class="text-[11px] text-slate-400">
286
+ Optional. JSON object of <code>"provider:model"</code> (or a bare
287
+ <code>"provider"</code>) → <code>{ inputPerMillion, outputPerMillion }</code>,
288
+ overlaid on the built-in price table. Leave blank to use the defaults.
289
+ </p>
290
+ <UTextarea
291
+ v-model="draft.spendModelPrices"
292
+ :rows="6"
293
+ size="sm"
294
+ class="w-full font-mono text-[11px]"
295
+ placeholder='{"openai:gpt-4o":{"inputPerMillion":2.3,"outputPerMillion":9.2}}'
296
+ />
297
+ </section>
298
+
299
+ <div class="flex justify-end">
300
+ <UButton
301
+ color="primary"
302
+ icon="i-lucide-save"
303
+ size="sm"
304
+ :loading="savingBudget"
305
+ @click="saveBudget"
306
+ >
307
+ Save budget
308
+ </UButton>
309
+ </div>
310
+ </div>
311
+ </template>
312
+
198
313
  <!-- Merge thresholds -->
199
314
  <template #merge>
200
315
  <MergeThresholdsPanel />
@@ -7,6 +7,7 @@ import type {
7
7
  EmailConnection,
8
8
  UpdateAccountInput,
9
9
  } from '~/types/domain'
10
+ import type { AccountSettingsView, UpdateAccountSettingsInput } from '~/types/accountSettings'
10
11
  import type { ApiContext } from './context'
11
12
 
12
13
  /** Account (tenancy) management: orgs, members, invitations + the email sender. */
@@ -77,5 +78,16 @@ export function accountsApi({ http }: ApiContext) {
77
78
  method: 'POST',
78
79
  body: { to },
79
80
  }),
81
+
82
+ // Per-account deployment settings (admin only): integration secrets (Slack OAuth +
83
+ // web-search keys), sealed at rest. Read returns config + non-secret summary only.
84
+ getAccountSettings: (accountId: string) =>
85
+ http<AccountSettingsView>(`/accounts/${encodeURIComponent(accountId)}/settings`),
86
+
87
+ updateAccountSettings: (accountId: string, body: UpdateAccountSettingsInput) =>
88
+ http<AccountSettingsView>(`/accounts/${encodeURIComponent(accountId)}/settings`, {
89
+ method: 'PUT',
90
+ body,
91
+ }),
80
92
  }
81
93
  }
@@ -4,6 +4,10 @@ import type {
4
4
  UpsertObservabilityConnectionInput,
5
5
  UpsertReleaseHealthConfigInput,
6
6
  } from '~/types/releaseHealth'
7
+ import type {
8
+ IncidentEnrichmentView,
9
+ UpsertIncidentEnrichmentInput,
10
+ } from '~/types/incidentEnrichment'
7
11
  import type { ApiContext } from './context'
8
12
 
9
13
  /** Post-release-health: the observability connection + per-block monitor/SLO mapping. */
@@ -40,5 +44,18 @@ export function releaseHealthApi({ http, ws }: ApiContext) {
40
44
  http(`${ws(workspaceId)}/release-health-configs/${encodeURIComponent(blockId)}`, {
41
45
  method: 'DELETE',
42
46
  }),
47
+
48
+ // ---- Incident enrichment (PagerDuty + incident.io, write-only secrets) --
49
+ getIncidentEnrichment: (workspaceId: string) =>
50
+ http<IncidentEnrichmentView>(`${ws(workspaceId)}/incident-enrichment`),
51
+
52
+ setIncidentEnrichment: (workspaceId: string, body: UpsertIncidentEnrichmentInput) =>
53
+ http<IncidentEnrichmentView>(`${ws(workspaceId)}/incident-enrichment`, {
54
+ method: 'PUT',
55
+ body,
56
+ }),
57
+
58
+ deleteIncidentEnrichment: (workspaceId: string) =>
59
+ http(`${ws(workspaceId)}/incident-enrichment`, { method: 'DELETE' }),
43
60
  }
44
61
  }
@@ -0,0 +1,49 @@
1
+ import { defineStore } from 'pinia'
2
+ import { ref } from 'vue'
3
+ import type { AccountSettingsView, UpdateAccountSettingsInput } from '~/types/accountSettings'
4
+
5
+ /**
6
+ * Per-account deployment settings (admin only): the integration secrets — Slack app OAuth
7
+ * credentials + the web-search upstream keys — sealed at rest in the DB. Secrets are
8
+ * write-only: this store only ever holds the non-secret `summary` (which integrations are
9
+ * configured), never the values. `available` mirrors the backend opt-in: a 503 (no
10
+ * ENCRYPTION_KEY) hides the panel. Loaded on demand from the account-settings panel.
11
+ */
12
+ export const useAccountSettingsStore = defineStore('accountSettings', () => {
13
+ const api = useApi()
14
+
15
+ const view = ref<AccountSettingsView | null>(null)
16
+ const loading = ref(false)
17
+ const available = ref<boolean | null>(null)
18
+
19
+ async function load(accountId: string) {
20
+ loading.value = true
21
+ try {
22
+ view.value = await api.getAccountSettings(accountId)
23
+ available.value = true
24
+ } catch (e) {
25
+ // 503 ⇒ the settings store isn't wired (no ENCRYPTION_KEY) ⇒ hide the panel.
26
+ if (
27
+ e &&
28
+ typeof e === 'object' &&
29
+ 'statusCode' in e &&
30
+ (e as { statusCode?: number }).statusCode === 503
31
+ ) {
32
+ available.value = false
33
+ view.value = null
34
+ } else {
35
+ throw e
36
+ }
37
+ } finally {
38
+ loading.value = false
39
+ }
40
+ }
41
+
42
+ async function save(accountId: string, input: UpdateAccountSettingsInput) {
43
+ view.value = await api.updateAccountSettings(accountId, input)
44
+ available.value = true
45
+ return view.value
46
+ }
47
+
48
+ return { view, loading, available, load, save }
49
+ })
@@ -6,6 +6,10 @@ import type {
6
6
  UpsertObservabilityConnectionInput,
7
7
  UpsertReleaseHealthConfigInput,
8
8
  } from '~/types/releaseHealth'
9
+ import type {
10
+ IncidentEnrichmentView,
11
+ UpsertIncidentEnrichmentInput,
12
+ } from '~/types/incidentEnrichment'
9
13
  import { useWorkspaceStore } from '~/stores/workspace'
10
14
 
11
15
  /**
@@ -23,6 +27,10 @@ export const useReleaseHealthStore = defineStore('releaseHealth', () => {
23
27
  summary: null,
24
28
  })
25
29
  const configs = ref<ReleaseHealthConfig[]>([])
30
+ // Incident-enrichment (PagerDuty + incident.io) connection — write-only secrets, the
31
+ // store only ever holds the presence summary. Wired alongside observability.
32
+ const incident = ref<IncidentEnrichmentView>({ connected: false, summary: null })
33
+ const incidentAvailable = ref<boolean | null>(null)
26
34
  const loading = ref(false)
27
35
  // Mirrors the backend's opt-in gate (`OBSERVABILITY_ENABLED`): `null` until first
28
36
  // probed, then `true`/`false`. The hub + inspector hide their observability entry
@@ -95,9 +103,35 @@ export const useReleaseHealthStore = defineStore('releaseHealth', () => {
95
103
  configs.value = configs.value.filter((c) => c.blockId !== blockId)
96
104
  }
97
105
 
106
+ /** Load the incident-enrichment connection (separate opt-in gate from observability). */
107
+ async function loadIncident() {
108
+ const ws = useWorkspaceStore()
109
+ try {
110
+ incident.value = await api.getIncidentEnrichment(ws.requireId())
111
+ incidentAvailable.value = true
112
+ } catch {
113
+ incidentAvailable.value = false
114
+ incident.value = { connected: false, summary: null }
115
+ }
116
+ }
117
+
118
+ async function saveIncident(input: UpsertIncidentEnrichmentInput) {
119
+ const ws = useWorkspaceStore()
120
+ incident.value = await api.setIncidentEnrichment(ws.requireId(), input)
121
+ incidentAvailable.value = true
122
+ }
123
+
124
+ async function removeIncident() {
125
+ const ws = useWorkspaceStore()
126
+ await api.deleteIncidentEnrichment(ws.requireId())
127
+ incident.value = { connected: false, summary: null }
128
+ }
129
+
98
130
  return {
99
131
  connection,
100
132
  configs,
133
+ incident,
134
+ incidentAvailable,
101
135
  loading,
102
136
  available,
103
137
  load,
@@ -107,5 +141,8 @@ export const useReleaseHealthStore = defineStore('releaseHealth', () => {
107
141
  configForBlock,
108
142
  saveConfig,
109
143
  removeConfig,
144
+ loadIncident,
145
+ saveIncident,
146
+ removeIncident,
110
147
  }
111
148
  })
@@ -9,6 +9,9 @@ const DEFAULTS: WorkspaceSettings = {
9
9
  taskLimitMode: 'off',
10
10
  taskLimitShared: null,
11
11
  taskLimitPerType: null,
12
+ spendCurrency: null,
13
+ spendMonthlyLimit: null,
14
+ spendModelPrices: null,
12
15
  }
13
16
 
14
17
  /**
@@ -0,0 +1,39 @@
1
+ // Per-account (deployment-wide) integration settings. Mirrors
2
+ // `@cat-factory/contracts` accountSettings. Secrets are write-only — the read view
3
+ // returns only `config` + a non-secret presence `summary`.
4
+
5
+ export interface SlackOAuthSecret {
6
+ clientId: string
7
+ clientSecret: string
8
+ redirectUrl: string
9
+ }
10
+
11
+ export interface WebSearchSecret {
12
+ braveApiKey?: string
13
+ searxngUrl?: string
14
+ searxngApiKey?: string
15
+ }
16
+
17
+ /** Non-secret per-account config (empty today; reserved for forward-compatible tuning). */
18
+ export type AccountSettingsConfig = Record<string, never>
19
+
20
+ export interface AccountSettingsSummary {
21
+ slackOAuthConfigured: boolean
22
+ webSearch: 'brave' | 'searxng' | null
23
+ }
24
+
25
+ export interface AccountSettingsView {
26
+ config: AccountSettingsConfig
27
+ summary: AccountSettingsSummary
28
+ }
29
+
30
+ /**
31
+ * Admin write. Each secrets group: omit ⇒ leave unchanged, `null` ⇒ clear, value ⇒ set.
32
+ */
33
+ export interface UpdateAccountSettingsInput {
34
+ config?: AccountSettingsConfig
35
+ secrets?: {
36
+ slackOAuth?: SlackOAuthSecret | null
37
+ webSearch?: WebSearchSecret | null
38
+ }
39
+ }
@@ -493,6 +493,12 @@ export interface WorkspaceSettings {
493
493
  taskLimitShared: number | null
494
494
  /** The per-type caps, when `taskLimitMode` is `per_type` (type → cap). */
495
495
  taskLimitPerType: Partial<Record<CreateTaskType, number>> | null
496
+ /** Spend budget currency (ISO 4217). Null ⇒ the built-in default (`EUR`). */
497
+ spendCurrency: string | null
498
+ /** Monthly spend budget in `spendCurrency`. Null ⇒ the built-in default. */
499
+ spendMonthlyLimit: number | null
500
+ /** Per-model price overrides (`provider:model` → per-1M rates). Null ⇒ none. */
501
+ spendModelPrices: Record<string, { inputPerMillion: number; outputPerMillion: number }> | null
496
502
  }
497
503
 
498
504
  /** Patch a workspace's settings (only the supplied fields change). */
@@ -501,6 +507,9 @@ export interface UpdateWorkspaceSettingsInput {
501
507
  taskLimitMode?: TaskLimitMode
502
508
  taskLimitShared?: number | null
503
509
  taskLimitPerType?: Partial<Record<CreateTaskType, number>> | null
510
+ spendCurrency?: string | null
511
+ spendMonthlyLimit?: number | null
512
+ spendModelPrices?: Record<string, { inputPerMillion: number; outputPerMillion: number }> | null
504
513
  }
505
514
 
506
515
  /**
@@ -0,0 +1,28 @@
1
+ // Per-workspace incident-enrichment connection (PagerDuty + incident.io). Mirrors
2
+ // `@cat-factory/contracts` incident-enrichment. Credentials are write-only — the view
3
+ // returns only a presence `summary`.
4
+
5
+ export interface PagerDutyCredentials {
6
+ apiToken: string
7
+ fromEmail: string
8
+ }
9
+
10
+ export interface IncidentIoCredentials {
11
+ apiKey: string
12
+ }
13
+
14
+ /** Write input — set one or both providers; an omitted group is left unchanged. */
15
+ export interface UpsertIncidentEnrichmentInput {
16
+ pagerDuty?: PagerDutyCredentials
17
+ incidentIo?: IncidentIoCredentials
18
+ }
19
+
20
+ export interface IncidentEnrichmentSummary {
21
+ pagerDuty: boolean
22
+ incidentIo: boolean
23
+ }
24
+
25
+ export interface IncidentEnrichmentView {
26
+ connected: boolean
27
+ summary: IncidentEnrichmentSummary | null
28
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.26.7",
3
+ "version": "0.27.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",