@cat-factory/app 0.10.0 → 0.12.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.
- package/app/components/board/AddTaskModal.vue +1 -1
- package/app/components/layout/AccountTeamSettings.vue +6 -3
- package/app/components/layout/CommandBar.vue +2 -2
- package/app/components/layout/IntegrationsHub.vue +5 -3
- package/app/components/panels/InspectorPanel.vue +1 -1
- package/app/components/providers/ApiKeysSection.vue +67 -26
- package/app/components/providers/VendorCredentialsModal.vue +115 -84
- package/app/components/tasks/TaskImportModal.vue +6 -6
- package/app/components/tasks/TaskSourceConnectModal.vue +74 -20
- package/app/composables/api/tasks.ts +11 -4
- package/app/stores/tasks.spec.ts +4 -2
- package/app/stores/tasks.ts +19 -5
- package/app/types/tasks.ts +11 -0
- package/package.json +1 -1
|
@@ -187,7 +187,7 @@ const pendingContext = ref<PendingContext[]>([])
|
|
|
187
187
|
// always shown (ungated): when the relevant integration isn't connected the Attach
|
|
188
188
|
// button is disabled with a tooltip rather than the section being hidden.
|
|
189
189
|
const docsConnected = computed(() => documents.available && documents.anyConnected)
|
|
190
|
-
const issuesConnected = computed(() => tasks.available && tasks.
|
|
190
|
+
const issuesConnected = computed(() => tasks.available && tasks.anyOffered)
|
|
191
191
|
const pendingDocs = computed(() => pendingContext.value.filter((c) => c.kind === 'document'))
|
|
192
192
|
const pendingIssues = computed(() => pendingContext.value.filter((c) => c.kind === 'task'))
|
|
193
193
|
|
|
@@ -228,10 +228,13 @@ async function disconnectEmail() {
|
|
|
228
228
|
</template>
|
|
229
229
|
</section>
|
|
230
230
|
|
|
231
|
-
<!-- account-wide provider API keys (admin-only) -->
|
|
232
|
-
<section v-if="isAdmin">
|
|
231
|
+
<!-- account-wide provider API keys (admin-only): direct vendors + proxy gateways -->
|
|
232
|
+
<section v-if="isAdmin" class="space-y-6">
|
|
233
233
|
<h3 class="mb-2 font-semibold text-white">Account API keys</h3>
|
|
234
|
-
<ProvidersApiKeysSection :account-id="accountId" />
|
|
234
|
+
<ProvidersApiKeysSection :account-id="accountId" category="direct" />
|
|
235
|
+
<div class="border-t border-slate-800 pt-6">
|
|
236
|
+
<ProvidersApiKeysSection :account-id="accountId" category="proxy" />
|
|
237
|
+
</div>
|
|
235
238
|
</section>
|
|
236
239
|
</div>
|
|
237
240
|
</template>
|
|
@@ -111,14 +111,14 @@ const commands = computed<Command[]>(() => {
|
|
|
111
111
|
for (const src of tasks.sources) {
|
|
112
112
|
list.push({
|
|
113
113
|
id: `task-connect-${src.source}`,
|
|
114
|
-
label:
|
|
114
|
+
label: src.available ? `Manage ${src.label}` : `Connect ${src.label}`,
|
|
115
115
|
group: 'Integrations',
|
|
116
116
|
icon: src.icon,
|
|
117
117
|
keywords: 'task source tracker issues',
|
|
118
118
|
run: () => ui.openTaskConnect(src.source),
|
|
119
119
|
})
|
|
120
120
|
}
|
|
121
|
-
if (tasks.
|
|
121
|
+
if (tasks.anyOffered) {
|
|
122
122
|
list.push({
|
|
123
123
|
id: 'task-import',
|
|
124
124
|
label: 'Import issues',
|
|
@@ -114,11 +114,13 @@ const groups = computed<IntegrationGroup[]>(() => {
|
|
|
114
114
|
icon: src.icon,
|
|
115
115
|
label: src.label,
|
|
116
116
|
description: `Link ${src.label} to import and reference tracker issues.`,
|
|
117
|
-
|
|
118
|
-
|
|
117
|
+
// Available + enabled ⇒ offered (green); available + off ⇒ "Disabled";
|
|
118
|
+
// not available ⇒ no badge (Jira needs connecting; GitHub needs its App).
|
|
119
|
+
status: src.available ? (src.enabled ? undefined : 'Disabled') : undefined,
|
|
120
|
+
connected: src.available && src.enabled,
|
|
119
121
|
onClick: () => go(() => ui.openTaskConnect(src.source)),
|
|
120
122
|
}))
|
|
121
|
-
if (tasks.
|
|
123
|
+
if (tasks.anyOffered) {
|
|
122
124
|
trackers.push({
|
|
123
125
|
key: 'task:import',
|
|
124
126
|
icon: 'i-lucide-file-down',
|
|
@@ -386,7 +386,7 @@ const showOriginalDescription = ref(false)
|
|
|
386
386
|
icon="i-lucide-ticket"
|
|
387
387
|
@click="ui.openTaskImport()"
|
|
388
388
|
>
|
|
389
|
-
{{ tasks.
|
|
389
|
+
{{ tasks.anyOffered ? 'Import issue' : 'Connect a tracker' }}
|
|
390
390
|
</UButton>
|
|
391
391
|
<UButton
|
|
392
392
|
v-if="isContainer && documents.available && documents.anyConnected"
|
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
2
|
+
// Provider API keys: connect a vendor API key so agent steps and inline calls run on
|
|
3
|
+
// that provider. Keys are stored encrypted in the DB, pooled and rotated by usage —
|
|
4
|
+
// replacing deployment env vars.
|
|
5
5
|
//
|
|
6
|
-
//
|
|
6
|
+
// `category` splits the providers into two kinds:
|
|
7
|
+
// - 'direct' (default): you reach the vendor directly (OpenAI/Anthropic/Qwen/DeepSeek/
|
|
8
|
+
// Moonshot).
|
|
9
|
+
// - 'proxy': an intermediary gateway that fronts many vendors behind one key
|
|
10
|
+
// (OpenRouter, LiteLLM). These are NOT direct vendors, so they get their own section.
|
|
11
|
+
//
|
|
12
|
+
// Two scopes:
|
|
7
13
|
// - Default (no `accountId`): manage WORKSPACE keys (shared by the team) and YOUR own
|
|
8
14
|
// keys (your personal pool, usable in any workspace), toggled by the Scope select.
|
|
9
15
|
// - With `accountId`: manage ACCOUNT-wide keys (shared by every workspace in the
|
|
@@ -11,7 +17,9 @@
|
|
|
11
17
|
import { computed, ref, watch } from 'vue'
|
|
12
18
|
import type { ApiKey, ApiKeyProvider } from '~/types/domain'
|
|
13
19
|
|
|
14
|
-
const props = defineProps<{ accountId?: string }>()
|
|
20
|
+
const props = withDefaults(defineProps<{ accountId?: string; category?: 'direct' | 'proxy' }>(), {
|
|
21
|
+
category: 'direct',
|
|
22
|
+
})
|
|
15
23
|
|
|
16
24
|
const workspace = useWorkspaceStore()
|
|
17
25
|
const keys = useApiKeysStore()
|
|
@@ -21,13 +29,15 @@ const toast = useToast()
|
|
|
21
29
|
/** Account-wide mode (single account scope) vs the default workspace/user toggle. */
|
|
22
30
|
const isAccount = computed(() => !!props.accountId)
|
|
23
31
|
|
|
24
|
-
|
|
25
|
-
const PROVIDERS: {
|
|
32
|
+
interface ProviderMeta {
|
|
26
33
|
value: ApiKeyProvider
|
|
27
34
|
label: string
|
|
28
35
|
url: string
|
|
29
36
|
steps: string[]
|
|
30
|
-
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Direct vendors: the key reaches that one vendor's own endpoint. */
|
|
40
|
+
const DIRECT_PROVIDERS: ProviderMeta[] = [
|
|
31
41
|
{
|
|
32
42
|
value: 'openai',
|
|
33
43
|
label: 'OpenAI',
|
|
@@ -73,6 +83,10 @@ const PROVIDERS: {
|
|
|
73
83
|
'Copy the key; it authenticates the OpenAI-compatible Moonshot endpoint.',
|
|
74
84
|
],
|
|
75
85
|
},
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
/** Proxies / gateways: one key fronts many vendors. These are intermediaries, not vendors. */
|
|
89
|
+
const PROXY_PROVIDERS: ProviderMeta[] = [
|
|
76
90
|
{
|
|
77
91
|
value: 'openrouter',
|
|
78
92
|
label: 'OpenRouter',
|
|
@@ -93,8 +107,12 @@ const PROVIDERS: {
|
|
|
93
107
|
},
|
|
94
108
|
]
|
|
95
109
|
|
|
110
|
+
/** Providers for the requested category; labels everywhere fall back to the full set. */
|
|
111
|
+
const PROVIDERS = computed(() => (props.category === 'proxy' ? PROXY_PROVIDERS : DIRECT_PROVIDERS))
|
|
112
|
+
const ALL_PROVIDERS = [...DIRECT_PROVIDERS, ...PROXY_PROVIDERS]
|
|
113
|
+
|
|
96
114
|
const scope = ref<'workspace' | 'user'>('workspace')
|
|
97
|
-
const provider = ref<ApiKeyProvider>('openai')
|
|
115
|
+
const provider = ref<ApiKeyProvider>(props.category === 'proxy' ? 'openrouter' : 'openai')
|
|
98
116
|
const label = ref('')
|
|
99
117
|
const key = ref('')
|
|
100
118
|
const busy = ref(false)
|
|
@@ -115,17 +133,23 @@ watch(
|
|
|
115
133
|
{ immediate: true },
|
|
116
134
|
)
|
|
117
135
|
|
|
118
|
-
const selected = computed(
|
|
119
|
-
|
|
120
|
-
|
|
136
|
+
const selected = computed(
|
|
137
|
+
() => PROVIDERS.value.find((p) => p.value === provider.value) ?? PROVIDERS.value[0]!,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
/** Keys for the active scope, narrowed to this section's category (direct vs proxy). */
|
|
141
|
+
const categoryProviders = computed(() => new Set(PROVIDERS.value.map((p) => p.value)))
|
|
142
|
+
const connected = computed<ApiKey[]>(() => {
|
|
143
|
+
const all = isAccount.value
|
|
121
144
|
? keys.accountKeys
|
|
122
145
|
: scope.value === 'workspace'
|
|
123
146
|
? keys.workspaceKeys
|
|
124
|
-
: keys.userKeys
|
|
125
|
-
)
|
|
147
|
+
: keys.userKeys
|
|
148
|
+
return all.filter((k) => categoryProviders.value.has(k.provider))
|
|
149
|
+
})
|
|
126
150
|
|
|
127
151
|
function providerLabel(p: ApiKeyProvider): string {
|
|
128
|
-
return
|
|
152
|
+
return ALL_PROVIDERS.find((x) => x.value === p)?.label ?? p
|
|
129
153
|
}
|
|
130
154
|
|
|
131
155
|
async function add() {
|
|
@@ -176,17 +200,34 @@ async function remove(k: ApiKey) {
|
|
|
176
200
|
<div class="space-y-4">
|
|
177
201
|
<div>
|
|
178
202
|
<h4 class="text-xs font-semibold uppercase tracking-wide text-slate-500">
|
|
179
|
-
Direct provider API keys
|
|
203
|
+
{{ category === 'proxy' ? 'Proxy / gateway API keys' : 'Direct provider API keys' }}
|
|
180
204
|
</h4>
|
|
181
|
-
<
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
205
|
+
<template v-if="category === 'proxy'">
|
|
206
|
+
<p v-if="isAccount" class="mt-1 text-sm text-slate-400">
|
|
207
|
+
Connect a <strong>proxy</strong> key (OpenRouter, LiteLLM) shared by
|
|
208
|
+
<strong>every workspace</strong> in this account. A proxy is an intermediary that fronts
|
|
209
|
+
many vendors behind a single key. Keys are stored encrypted, pooled, and rotated by usage.
|
|
210
|
+
</p>
|
|
211
|
+
<p v-else class="mt-1 text-sm text-slate-400">
|
|
212
|
+
Connect a <strong>proxy</strong> key (OpenRouter, LiteLLM). A proxy is an intermediary
|
|
213
|
+
that reaches many vendors' models through a single gateway, rather than one vendor
|
|
214
|
+
directly. Keys are stored encrypted, pooled, and rotated by usage. Scope a key to this
|
|
215
|
+
<strong>workspace</strong> (shared with the team) or to <strong>you</strong> (your own
|
|
216
|
+
pool, usable anywhere).
|
|
217
|
+
</p>
|
|
218
|
+
</template>
|
|
219
|
+
<template v-else>
|
|
220
|
+
<p v-if="isAccount" class="mt-1 text-sm text-slate-400">
|
|
221
|
+
Connect a vendor API key shared by <strong>every workspace</strong> in this account. Keys
|
|
222
|
+
are stored encrypted, pooled, and rotated by usage. Account keys are admin-managed.
|
|
223
|
+
</p>
|
|
224
|
+
<p v-else class="mt-1 text-sm text-slate-400">
|
|
225
|
+
Connect a vendor API key so models run directly on that provider. Keys are stored
|
|
226
|
+
encrypted, pooled, and rotated by usage. Scope a key to this
|
|
227
|
+
<strong>workspace</strong> (shared with the team) or to <strong>you</strong> (your own
|
|
228
|
+
pool, usable anywhere).
|
|
229
|
+
</p>
|
|
230
|
+
</template>
|
|
190
231
|
</div>
|
|
191
232
|
|
|
192
233
|
<!-- scope + provider -->
|
|
@@ -201,7 +242,7 @@ async function remove(k: ApiKey) {
|
|
|
201
242
|
class="w-48"
|
|
202
243
|
/>
|
|
203
244
|
</UFormField>
|
|
204
|
-
<UFormField label="Provider">
|
|
245
|
+
<UFormField :label="category === 'proxy' ? 'Proxy' : 'Provider'">
|
|
205
246
|
<USelect
|
|
206
247
|
v-model="provider"
|
|
207
248
|
:items="PROVIDERS.map((p) => ({ label: p.label, value: p.value }))"
|
|
@@ -18,6 +18,21 @@ const open = computed({
|
|
|
18
18
|
set: (v: boolean) => (v ? ui.openVendorCredentials() : ui.closeVendorCredentials()),
|
|
19
19
|
})
|
|
20
20
|
|
|
21
|
+
// Horizontal tabs replace the old long vertical scroll: each credential kind is its own
|
|
22
|
+
// section (pooled subscriptions, direct vendor keys, proxy/gateway keys, personal subs).
|
|
23
|
+
const activeTab = ref('pool')
|
|
24
|
+
const tabs = [
|
|
25
|
+
{ value: 'pool', label: 'Workspace pool', icon: 'i-lucide-users', slot: 'pool' },
|
|
26
|
+
{ value: 'direct', label: 'Direct providers', icon: 'i-lucide-key-round', slot: 'direct' },
|
|
27
|
+
{ value: 'proxy', label: 'Proxies', icon: 'i-lucide-route', slot: 'proxy' },
|
|
28
|
+
{
|
|
29
|
+
value: 'personal',
|
|
30
|
+
label: 'Personal subscriptions',
|
|
31
|
+
icon: 'i-lucide-user',
|
|
32
|
+
slot: 'personal',
|
|
33
|
+
},
|
|
34
|
+
]
|
|
35
|
+
|
|
21
36
|
// Only commercial coding-plan vendors that permit team/organization use are poolable here.
|
|
22
37
|
// Claude, GLM and ChatGPT/Codex are licensed for individual use only, so they are connected
|
|
23
38
|
// per-user in the "Personal subscriptions" section below (PersonalSubscriptionSection).
|
|
@@ -102,96 +117,112 @@ function vendorLabel(v: SubscriptionVendor): string {
|
|
|
102
117
|
<template>
|
|
103
118
|
<UModal v-model:open="open" title="LLM Vendors" :ui="{ content: 'max-w-2xl' }">
|
|
104
119
|
<template #body>
|
|
105
|
-
<
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
<div>
|
|
168
|
-
<
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
120
|
+
<UTabs
|
|
121
|
+
v-model="activeTab"
|
|
122
|
+
:items="tabs"
|
|
123
|
+
variant="link"
|
|
124
|
+
:ui="{ root: 'gap-4', list: 'overflow-x-auto' }"
|
|
125
|
+
>
|
|
126
|
+
<!-- Workspace pool (commercial coding-plan subscriptions) -->
|
|
127
|
+
<template #pool>
|
|
128
|
+
<div class="space-y-5">
|
|
129
|
+
<p class="text-sm text-slate-400">
|
|
130
|
+
Connect a <strong>commercial</strong> coding-plan subscription (Kimi, DeepSeek) that
|
|
131
|
+
permits team/organization use to run agent steps on the Claude Code harness instead of
|
|
132
|
+
an API key. Tokens are stored encrypted, pooled, and rotated by usage. Subscription
|
|
133
|
+
models are flat-rate quota — they don’t draw on your spend budget. Individual-use
|
|
134
|
+
subscriptions (Claude, GLM, ChatGPT/Codex) are connected per-user in the Personal
|
|
135
|
+
subscriptions tab.
|
|
136
|
+
</p>
|
|
137
|
+
|
|
138
|
+
<!-- vendor picker -->
|
|
139
|
+
<div class="flex flex-wrap items-end gap-3">
|
|
140
|
+
<UFormField label="Vendor">
|
|
141
|
+
<USelect
|
|
142
|
+
v-model="vendor"
|
|
143
|
+
:items="visibleVendors.map((v) => ({ label: v.label, value: v.value }))"
|
|
144
|
+
class="w-64"
|
|
145
|
+
/>
|
|
146
|
+
</UFormField>
|
|
147
|
+
</div>
|
|
148
|
+
|
|
149
|
+
<!-- guided steps -->
|
|
150
|
+
<ol
|
|
151
|
+
class="list-decimal space-y-1.5 rounded-lg border border-slate-700 bg-slate-900/60 p-4 pl-8 text-sm text-slate-300"
|
|
152
|
+
>
|
|
153
|
+
<li v-for="(step, i) in steps" :key="i">{{ step }}</li>
|
|
154
|
+
</ol>
|
|
155
|
+
|
|
156
|
+
<!-- add form -->
|
|
157
|
+
<div class="space-y-2">
|
|
158
|
+
<UFormField label="Label (optional)">
|
|
159
|
+
<UInput v-model="label" placeholder="e.g. work account" />
|
|
160
|
+
</UFormField>
|
|
161
|
+
<UFormField label="Token">
|
|
162
|
+
<UTextarea
|
|
163
|
+
v-model="token"
|
|
164
|
+
:rows="3"
|
|
165
|
+
:placeholder="tokenPlaceholder"
|
|
166
|
+
class="font-mono"
|
|
167
|
+
/>
|
|
168
|
+
</UFormField>
|
|
169
|
+
<div class="flex justify-end">
|
|
170
|
+
<UButton
|
|
171
|
+
:loading="busy"
|
|
172
|
+
:disabled="!token.trim()"
|
|
173
|
+
icon="i-lucide-plus"
|
|
174
|
+
@click="add()"
|
|
175
|
+
>
|
|
176
|
+
Connect
|
|
177
|
+
</UButton>
|
|
178
|
+
</div>
|
|
179
|
+
</div>
|
|
180
|
+
|
|
181
|
+
<!-- connected pool -->
|
|
182
|
+
<div v-if="creds.credentials.length" class="space-y-2">
|
|
183
|
+
<h4 class="text-xs font-semibold uppercase tracking-wide text-slate-500">
|
|
184
|
+
Connected ({{ creds.credentials.length }})
|
|
185
|
+
</h4>
|
|
186
|
+
<div
|
|
187
|
+
v-for="c in creds.credentials"
|
|
188
|
+
:key="c.id"
|
|
189
|
+
class="flex items-center justify-between rounded-md border border-slate-700 bg-slate-900/50 px-3 py-2 text-sm"
|
|
190
|
+
>
|
|
191
|
+
<div>
|
|
192
|
+
<span class="font-medium text-slate-200">{{ c.label }}</span>
|
|
193
|
+
<span class="ml-2 text-xs text-slate-500">{{ vendorLabel(c.vendor) }}</span>
|
|
194
|
+
<div class="text-[11px] tabular-nums text-slate-500">
|
|
195
|
+
{{ (c.inputTokens + c.outputTokens).toLocaleString() }} tok this window ·
|
|
196
|
+
{{ c.requestCount }} run{{ c.requestCount === 1 ? '' : 's' }}
|
|
197
|
+
</div>
|
|
198
|
+
</div>
|
|
199
|
+
<UButton
|
|
200
|
+
icon="i-lucide-trash-2"
|
|
201
|
+
color="error"
|
|
202
|
+
variant="ghost"
|
|
203
|
+
size="xs"
|
|
204
|
+
@click="remove(c.id)"
|
|
205
|
+
/>
|
|
173
206
|
</div>
|
|
174
207
|
</div>
|
|
175
|
-
<UButton
|
|
176
|
-
icon="i-lucide-trash-2"
|
|
177
|
-
color="error"
|
|
178
|
-
variant="ghost"
|
|
179
|
-
size="xs"
|
|
180
|
-
@click="remove(c.id)"
|
|
181
|
-
/>
|
|
182
208
|
</div>
|
|
183
|
-
</
|
|
209
|
+
</template>
|
|
210
|
+
|
|
211
|
+
<!-- Direct provider API keys (OpenAI/Anthropic/Qwen/DeepSeek/Moonshot), pooled -->
|
|
212
|
+
<template #direct>
|
|
213
|
+
<ProvidersApiKeysSection category="direct" />
|
|
214
|
+
</template>
|
|
184
215
|
|
|
185
|
-
<!--
|
|
186
|
-
<
|
|
187
|
-
<ProvidersApiKeysSection />
|
|
188
|
-
</
|
|
216
|
+
<!-- Proxies / gateways (OpenRouter, LiteLLM): intermediaries that front many vendors -->
|
|
217
|
+
<template #proxy>
|
|
218
|
+
<ProvidersApiKeysSection category="proxy" />
|
|
219
|
+
</template>
|
|
189
220
|
|
|
190
|
-
<!--
|
|
191
|
-
<
|
|
221
|
+
<!-- Personal (individual-usage) subscriptions: Claude / GLM / Codex, per-user -->
|
|
222
|
+
<template #personal>
|
|
192
223
|
<ProvidersPersonalSubscriptionSection />
|
|
193
|
-
</
|
|
194
|
-
</
|
|
224
|
+
</template>
|
|
225
|
+
</UTabs>
|
|
195
226
|
</template>
|
|
196
227
|
</UModal>
|
|
197
228
|
</template>
|
|
@@ -24,7 +24,7 @@ const ref_ = ref('')
|
|
|
24
24
|
const importing = ref(false)
|
|
25
25
|
|
|
26
26
|
const sourceItems = computed(() =>
|
|
27
|
-
tasks.
|
|
27
|
+
tasks.offeredSources.map((s) => ({ label: s.label, value: s.source })),
|
|
28
28
|
)
|
|
29
29
|
const descriptor = computed(() => (source.value ? tasks.descriptorFor(source.value) : undefined))
|
|
30
30
|
|
|
@@ -52,7 +52,7 @@ const creatingId = ref<string | null>(null)
|
|
|
52
52
|
watch(open, (isOpen) => {
|
|
53
53
|
if (isOpen) {
|
|
54
54
|
ref_.value = ''
|
|
55
|
-
source.value = ui.taskImport?.source ?? tasks.
|
|
55
|
+
source.value = ui.taskImport?.source ?? tasks.offeredSources[0]?.source ?? undefined
|
|
56
56
|
containerId.value = containerItems.value[0]?.value
|
|
57
57
|
creatingId.value = null
|
|
58
58
|
tasks.loadTasks().catch(() => {})
|
|
@@ -102,10 +102,10 @@ async function doImport() {
|
|
|
102
102
|
<template>
|
|
103
103
|
<UModal v-model:open="open" title="Import from a task source">
|
|
104
104
|
<template #body>
|
|
105
|
-
<!-- Empty state: no
|
|
106
|
-
<div v-if="!tasks.
|
|
105
|
+
<!-- Empty state: no source offered (none connected/installed, or all disabled) -->
|
|
106
|
+
<div v-if="!tasks.anyOffered" class="space-y-3 text-center">
|
|
107
107
|
<UIcon name="i-lucide-plug" class="mx-auto h-8 w-8 text-slate-500" />
|
|
108
|
-
<p class="text-sm text-slate-400">Connect a task source first.</p>
|
|
108
|
+
<p class="text-sm text-slate-400">Connect or enable a task source first.</p>
|
|
109
109
|
<div class="flex justify-center gap-2">
|
|
110
110
|
<UButton
|
|
111
111
|
v-for="s in tasks.sources"
|
|
@@ -115,7 +115,7 @@ async function doImport() {
|
|
|
115
115
|
:icon="s.icon"
|
|
116
116
|
@click="ui.openTaskConnect(s.source)"
|
|
117
117
|
>
|
|
118
|
-
|
|
118
|
+
{{ s.available ? `Enable ${s.label}` : `Connect ${s.label}` }}
|
|
119
119
|
</UButton>
|
|
120
120
|
</div>
|
|
121
121
|
</div>
|
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
// Connect
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
2
|
+
// Connect/manage a task source for the workspace. The form is rendered generically
|
|
3
|
+
// from the source's descriptor (credential fields), so the same modal serves Jira
|
|
4
|
+
// and any future credentialed tracker. A credentialless source (GitHub Issues)
|
|
5
|
+
// has no form — it rides the workspace's installed GitHub App — so the modal just
|
|
6
|
+
// offers the on/off toggle. Secret credentials are write-only: the backend never
|
|
7
|
+
// returns them, so on reload we show "Connected" with empty fields.
|
|
8
|
+
//
|
|
9
|
+
// The on/off toggle is the per-workspace switch (persisted in task_source_settings):
|
|
10
|
+
// a workspace can offer GitHub repos without offering their issues, and can park a
|
|
11
|
+
// connected Jira without disconnecting it. The toggle only applies once a source is
|
|
12
|
+
// available (Jira connected / the GitHub App installed) — there is nothing to offer
|
|
13
|
+
// before that.
|
|
7
14
|
const ui = useUiStore()
|
|
8
15
|
const tasks = useTasksStore()
|
|
9
16
|
const toast = useToast()
|
|
@@ -12,6 +19,10 @@ const source = computed(() => ui.taskConnect?.source ?? null)
|
|
|
12
19
|
const descriptor = computed(() => (source.value ? tasks.descriptorFor(source.value) : undefined))
|
|
13
20
|
const connection = computed(() => (source.value ? tasks.connectionFor(source.value) : undefined))
|
|
14
21
|
const connected = computed(() => connection.value !== undefined)
|
|
22
|
+
// A credentialless source (GitHub Issues) reuses the installed GitHub App: no form.
|
|
23
|
+
const credentialless = computed(() => (descriptor.value?.credentialFields.length ?? 0) === 0)
|
|
24
|
+
// Usable right now: a credentialed source is connected; GitHub Issues' App is installed.
|
|
25
|
+
const available = computed(() => descriptor.value?.available ?? false)
|
|
15
26
|
|
|
16
27
|
const open = computed({
|
|
17
28
|
get: () => ui.taskConnect !== null,
|
|
@@ -23,24 +34,19 @@ const open = computed({
|
|
|
23
34
|
/** One value per credential field, reset whenever the modal (re)opens. */
|
|
24
35
|
const values = ref<Record<string, string>>({})
|
|
25
36
|
const saving = ref(false)
|
|
37
|
+
const togglingEnabled = ref(false)
|
|
26
38
|
|
|
27
39
|
watch(open, (isOpen) => {
|
|
28
40
|
if (isOpen) values.value = {}
|
|
29
41
|
})
|
|
30
42
|
|
|
31
|
-
// A source with no credential fields (e.g. GitHub, which reuses the workspace's
|
|
32
|
-
// installed GitHub App) connects with an empty bag — there is nothing to fill in,
|
|
33
|
-
// so the button is enabled as long as it isn't already connected.
|
|
34
|
-
const credentialless = computed(() => (descriptor.value?.credentialFields.length ?? 0) === 0)
|
|
35
|
-
|
|
36
43
|
const canSubmit = computed(() => {
|
|
37
44
|
const fields = descriptor.value?.credentialFields ?? []
|
|
38
|
-
if (credentialless.value) return !connected.value
|
|
39
45
|
return fields.every((f) => (values.value[f.key] ?? '').trim())
|
|
40
46
|
})
|
|
41
47
|
|
|
42
48
|
async function submit() {
|
|
43
|
-
if (!canSubmit.value || !source.value) return
|
|
49
|
+
if (!canSubmit.value || !source.value || credentialless.value) return
|
|
44
50
|
const credentials: Record<string, string> = {}
|
|
45
51
|
for (const f of descriptor.value!.credentialFields) {
|
|
46
52
|
credentials[f.key] = values.value[f.key]!.trim()
|
|
@@ -53,7 +59,8 @@ async function submit() {
|
|
|
53
59
|
icon: 'i-lucide-check',
|
|
54
60
|
color: 'success',
|
|
55
61
|
})
|
|
56
|
-
|
|
62
|
+
// Re-probe so `available`/`enabled` reflect the new connection.
|
|
63
|
+
await tasks.probe()
|
|
57
64
|
} catch (e) {
|
|
58
65
|
toast.add({
|
|
59
66
|
title: 'Could not connect',
|
|
@@ -69,28 +76,53 @@ async function submit() {
|
|
|
69
76
|
async function disconnect() {
|
|
70
77
|
if (!source.value) return
|
|
71
78
|
await tasks.disconnect(source.value)
|
|
79
|
+
await tasks.probe()
|
|
72
80
|
toast.add({
|
|
73
81
|
title: `${descriptor.value?.label ?? 'Source'} disconnected`,
|
|
74
82
|
icon: 'i-lucide-unplug',
|
|
75
83
|
})
|
|
76
84
|
ui.closeTaskConnect()
|
|
77
85
|
}
|
|
86
|
+
|
|
87
|
+
async function toggleEnabled(enabled: boolean) {
|
|
88
|
+
if (!source.value) return
|
|
89
|
+
togglingEnabled.value = true
|
|
90
|
+
try {
|
|
91
|
+
await tasks.setEnabled(source.value, enabled)
|
|
92
|
+
} catch (e) {
|
|
93
|
+
toast.add({
|
|
94
|
+
title: 'Could not update',
|
|
95
|
+
description: e instanceof Error ? e.message : String(e),
|
|
96
|
+
icon: 'i-lucide-triangle-alert',
|
|
97
|
+
color: 'error',
|
|
98
|
+
})
|
|
99
|
+
} finally {
|
|
100
|
+
togglingEnabled.value = false
|
|
101
|
+
}
|
|
102
|
+
}
|
|
78
103
|
</script>
|
|
79
104
|
|
|
80
105
|
<template>
|
|
81
|
-
<UModal v-model:open="open" :title="descriptor?.label ?? '
|
|
106
|
+
<UModal v-model:open="open" :title="descriptor?.label ?? 'Task source'">
|
|
82
107
|
<template #body>
|
|
83
108
|
<div v-if="descriptor" class="space-y-4">
|
|
84
109
|
<p class="text-sm text-slate-400">
|
|
85
|
-
|
|
110
|
+
{{ descriptor.label }} lets you import issues and attach them to tasks as agent context.
|
|
86
111
|
</p>
|
|
87
112
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
113
|
+
<!-- Credentialless source (GitHub Issues): no form, just the on/off toggle. -->
|
|
114
|
+
<template v-if="credentialless">
|
|
115
|
+
<p class="text-[11px] text-slate-500">
|
|
116
|
+
This source uses the GitHub App already installed on your workspace — there are no
|
|
117
|
+
credentials to enter.
|
|
118
|
+
</p>
|
|
119
|
+
<p v-if="!available" class="text-[11px] text-amber-400">
|
|
120
|
+
Install the workspace's GitHub App (connect GitHub repos) to offer {{ descriptor.label }}.
|
|
121
|
+
</p>
|
|
122
|
+
</template>
|
|
92
123
|
|
|
93
|
-
|
|
124
|
+
<!-- Credentialed source (Jira): the connect form, shown until connected. -->
|
|
125
|
+
<div v-else-if="!connected" class="space-y-3">
|
|
94
126
|
<UFormField
|
|
95
127
|
v-for="field in descriptor.credentialFields"
|
|
96
128
|
:key="field.key"
|
|
@@ -105,6 +137,27 @@ async function disconnect() {
|
|
|
105
137
|
/>
|
|
106
138
|
</UFormField>
|
|
107
139
|
</div>
|
|
140
|
+
<p v-else class="text-[11px] text-slate-500">
|
|
141
|
+
Connected{{ connection?.label ? ` to ${connection.label}` : '' }}.
|
|
142
|
+
</p>
|
|
143
|
+
|
|
144
|
+
<!-- The per-workspace on/off toggle, available once the source is usable. -->
|
|
145
|
+
<div
|
|
146
|
+
v-if="available"
|
|
147
|
+
class="flex items-center justify-between gap-2 rounded-md border border-slate-800 px-3 py-2"
|
|
148
|
+
>
|
|
149
|
+
<div class="text-sm">
|
|
150
|
+
<div class="font-medium text-slate-200">Offer to this workspace</div>
|
|
151
|
+
<div class="text-[11px] text-slate-500">
|
|
152
|
+
When off, {{ descriptor.label }} is hidden from import and linking.
|
|
153
|
+
</div>
|
|
154
|
+
</div>
|
|
155
|
+
<USwitch
|
|
156
|
+
:model-value="descriptor.enabled"
|
|
157
|
+
:loading="togglingEnabled"
|
|
158
|
+
@update:model-value="toggleEnabled"
|
|
159
|
+
/>
|
|
160
|
+
</div>
|
|
108
161
|
|
|
109
162
|
<div class="flex items-center justify-between gap-2 pt-1">
|
|
110
163
|
<UButton
|
|
@@ -118,6 +171,7 @@ async function disconnect() {
|
|
|
118
171
|
</UButton>
|
|
119
172
|
<div v-else />
|
|
120
173
|
<UButton
|
|
174
|
+
v-if="!credentialless"
|
|
121
175
|
color="primary"
|
|
122
176
|
icon="i-lucide-plug"
|
|
123
177
|
:loading="saving"
|
|
@@ -3,8 +3,8 @@ import type {
|
|
|
3
3
|
SourceTask,
|
|
4
4
|
TaskConnection,
|
|
5
5
|
TaskSearchResult,
|
|
6
|
-
TaskSourceDescriptor,
|
|
7
6
|
TaskSourceKind,
|
|
7
|
+
TaskSourceState,
|
|
8
8
|
} from '~/types/domain'
|
|
9
9
|
import type { PutTrackerSettingsInput, TrackerSettings } from '~/types/tracker'
|
|
10
10
|
import type { ApiContext } from './context'
|
|
@@ -13,10 +13,17 @@ import type { ApiContext } from './context'
|
|
|
13
13
|
export function tasksApi({ http, ws }: ApiContext) {
|
|
14
14
|
return {
|
|
15
15
|
// ---- task sources (Jira, …) ------------------------------------------
|
|
16
|
-
// The configured trackers + their connect/import metadata
|
|
17
|
-
//
|
|
16
|
+
// The configured trackers + their connect/import metadata + the workspace's
|
|
17
|
+
// per-source state (available + enabled). A 503 means the integration is off
|
|
18
|
+
// (the store hides its UI on any error here).
|
|
18
19
|
listTaskSources: (workspaceId: string) =>
|
|
19
|
-
http<{ sources:
|
|
20
|
+
http<{ sources: TaskSourceState[] }>(`${ws(workspaceId)}/task-sources`),
|
|
21
|
+
|
|
22
|
+
setTaskSourceEnabled: (workspaceId: string, source: TaskSourceKind, enabled: boolean) =>
|
|
23
|
+
http(`${ws(workspaceId)}/task-sources/${source}/enabled`, {
|
|
24
|
+
method: 'PUT',
|
|
25
|
+
body: { enabled },
|
|
26
|
+
}),
|
|
20
27
|
|
|
21
28
|
listTaskConnections: (workspaceId: string) =>
|
|
22
29
|
http<{ connections: TaskConnection[] }>(`${ws(workspaceId)}/task-sources/connections`),
|
package/app/stores/tasks.spec.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach } from 'vitest'
|
|
2
|
-
import type { SourceTask, TaskConnection,
|
|
2
|
+
import type { SourceTask, TaskConnection, TaskSourceState } from '~/types/domain'
|
|
3
3
|
import { useTasksStore } from '~/stores/tasks'
|
|
4
4
|
|
|
5
5
|
/** Minimal SourceTask factory — only the fields the read getters care about. */
|
|
@@ -23,13 +23,15 @@ function sourceTask(externalId: string, over: Partial<SourceTask> = {}): SourceT
|
|
|
23
23
|
}
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
const jiraDescriptor:
|
|
26
|
+
const jiraDescriptor: TaskSourceState = {
|
|
27
27
|
source: 'jira',
|
|
28
28
|
label: 'Jira',
|
|
29
29
|
icon: 'i-lucide-square-check',
|
|
30
30
|
credentialFields: [],
|
|
31
31
|
refLabel: 'Issue key or URL',
|
|
32
32
|
refPlaceholder: 'PROJ-123',
|
|
33
|
+
available: true,
|
|
34
|
+
enabled: true,
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
const jiraConnection: TaskConnection = { source: 'jira', label: 'acme', connectedAt: 0 }
|
package/app/stores/tasks.ts
CHANGED
|
@@ -4,8 +4,8 @@ import type {
|
|
|
4
4
|
SourceTask,
|
|
5
5
|
TaskConnection,
|
|
6
6
|
TaskSearchResult,
|
|
7
|
-
TaskSourceDescriptor,
|
|
8
7
|
TaskSourceKind,
|
|
8
|
+
TaskSourceState,
|
|
9
9
|
} from '~/types/domain'
|
|
10
10
|
import { useWorkspaceStore } from '~/stores/workspace'
|
|
11
11
|
|
|
@@ -27,9 +27,9 @@ export const useTasksStore = defineStore('tasks', () => {
|
|
|
27
27
|
|
|
28
28
|
/** null = unknown (not probed yet), true/false = integration on/off. */
|
|
29
29
|
const available = ref<boolean | null>(null)
|
|
30
|
-
/** The configured sources
|
|
31
|
-
const sources = ref<
|
|
32
|
-
/** Live connections, one per connected source. */
|
|
30
|
+
/** The configured sources, each with its descriptor + per-workspace state (available + enabled). */
|
|
31
|
+
const sources = ref<TaskSourceState[]>([])
|
|
32
|
+
/** Live connections, one per connected (credentialed) source. */
|
|
33
33
|
const connections = ref<TaskConnection[]>([])
|
|
34
34
|
const tasks = ref<SourceTask[]>([])
|
|
35
35
|
const loading = ref(false)
|
|
@@ -40,7 +40,11 @@ export const useTasksStore = defineStore('tasks', () => {
|
|
|
40
40
|
)
|
|
41
41
|
const anyConnected = computed(() => connections.value.length > 0)
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
/** Sources offered for import: available (connected / App installed) AND enabled. */
|
|
44
|
+
const offeredSources = computed(() => sources.value.filter((s) => s.available && s.enabled))
|
|
45
|
+
const anyOffered = computed(() => offeredSources.value.length > 0)
|
|
46
|
+
|
|
47
|
+
function descriptorFor(source: TaskSourceKind): TaskSourceState | undefined {
|
|
44
48
|
return sources.value.find((s) => s.source === source)
|
|
45
49
|
}
|
|
46
50
|
|
|
@@ -104,6 +108,13 @@ export const useTasksStore = defineStore('tasks', () => {
|
|
|
104
108
|
connections.value = connections.value.filter((c) => c.source !== source)
|
|
105
109
|
}
|
|
106
110
|
|
|
111
|
+
/** Enable or disable a source for the workspace (the per-workspace toggle). */
|
|
112
|
+
async function setEnabled(source: TaskSourceKind, enabled: boolean) {
|
|
113
|
+
await api.setTaskSourceEnabled(workspace.requireId(), source, enabled)
|
|
114
|
+
const i = sources.value.findIndex((s) => s.source === source)
|
|
115
|
+
if (i >= 0) sources.value[i] = { ...sources.value[i]!, enabled }
|
|
116
|
+
}
|
|
117
|
+
|
|
107
118
|
/** Load the imported issues for the workspace (across sources). */
|
|
108
119
|
async function loadTasks() {
|
|
109
120
|
tasks.value = await api.listTasks(workspace.requireId())
|
|
@@ -160,6 +171,8 @@ export const useTasksStore = defineStore('tasks', () => {
|
|
|
160
171
|
loading,
|
|
161
172
|
connectedSources,
|
|
162
173
|
anyConnected,
|
|
174
|
+
offeredSources,
|
|
175
|
+
anyOffered,
|
|
163
176
|
descriptorFor,
|
|
164
177
|
connectionFor,
|
|
165
178
|
isConnected,
|
|
@@ -167,6 +180,7 @@ export const useTasksStore = defineStore('tasks', () => {
|
|
|
167
180
|
probe,
|
|
168
181
|
connect,
|
|
169
182
|
disconnect,
|
|
183
|
+
setEnabled,
|
|
170
184
|
loadTasks,
|
|
171
185
|
importTask,
|
|
172
186
|
search,
|
package/app/types/tasks.ts
CHANGED
|
@@ -26,6 +26,17 @@ export interface TaskSourceDescriptor {
|
|
|
26
26
|
searchable?: boolean
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* A source's descriptor plus the workspace's live state: whether it's usable now
|
|
31
|
+
* (`available` — a credentialed source is connected; GitHub Issues' App is
|
|
32
|
+
* installed) and whether the workspace offers it (`enabled`, the per-workspace
|
|
33
|
+
* toggle, default true). `available && enabled` is what makes a source offered.
|
|
34
|
+
*/
|
|
35
|
+
export interface TaskSourceState extends TaskSourceDescriptor {
|
|
36
|
+
available: boolean
|
|
37
|
+
enabled: boolean
|
|
38
|
+
}
|
|
39
|
+
|
|
29
40
|
/** A workspace's connection to a task source (never carries credentials). */
|
|
30
41
|
export interface TaskConnection {
|
|
31
42
|
source: TaskSourceKind
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cat-factory/app",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.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",
|