total-recall-brain 3.13.0 → 3.13.2
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/README.md +91 -108
- package/bin/antigravity.mjs +81 -52
- package/bin/total-recall.mjs +32 -57
- package/extension/background.js +1 -1
- package/extension/options/options.js +9 -3
- package/extension/sidepanel/sidepanel.js +0 -2
- package/frontend/index.html +4 -3
- package/frontend/package.json +1 -0
- package/frontend/src/App.tsx +109 -152
- package/frontend/src/api.ts +605 -23
- package/frontend/src/components/BrainSelector.tsx +211 -199
- package/frontend/src/components/DocumentEditorModal.tsx +57 -0
- package/frontend/src/components/DocumentTable.tsx +47 -0
- package/frontend/src/components/Graph3D.tsx +120 -71
- package/frontend/src/components/UsageChart.tsx +3 -3
- package/frontend/src/components/brand/BrandMark.tsx +95 -0
- package/frontend/src/index.css +286 -86
- package/frontend/src/pages/ApiKeysPage.tsx +1878 -215
- package/frontend/src/pages/ChatPage.tsx +103 -45
- package/frontend/src/pages/DesignDocsPage.tsx +5 -5
- package/frontend/src/pages/FilesPage.tsx +10 -6
- package/frontend/src/pages/GraphPage.tsx +5 -3
- package/frontend/src/pages/HelpPage.tsx +2 -2
- package/frontend/src/pages/InboxPage.tsx +82 -0
- package/frontend/src/pages/InstructionsPage.tsx +1 -1
- package/frontend/src/pages/IntegrationsPage.tsx +21 -10
- package/frontend/src/pages/LoginPage.tsx +95 -77
- package/frontend/src/pages/MemoryPage.tsx +31 -8
- package/frontend/src/pages/OnboardingPage.tsx +690 -0
- package/frontend/src/pages/OpenWikiPage.tsx +412 -323
- package/frontend/src/pages/SettingsPage.tsx +825 -922
- package/frontend/src/pages/SkillsPage.tsx +272 -147
- package/frontend/src/pages/UsagePage.tsx +23 -31
- package/frontend/src/pages/VaultPage.tsx +223 -0
- package/frontend/src/types.ts +13 -0
- package/package.json +4 -4
- package/scaffold/.agent/skills/total-recall/SKILL.md +43 -4
- package/scaffold/.agent/skills/total-recall/modules/README.md +10 -0
- package/scaffold/.agent/skills/total-recall/modules/agents/README.md +5 -0
- package/scaffold/.agent/skills/total-recall/{skills/tr-cli-agents → modules/agents}/agents.yml +8 -0
- package/scaffold/.agent/skills/total-recall/modules/skill-deploy/README.md +9 -0
- package/scaffold/.agent/skills/total-recall/openwiki/README.md +10 -0
- package/scaffold/.agent/skills/total-recall/openwiki/architecture.md +5 -0
- package/scaffold/.agent/skills/total-recall/openwiki/ide.md +10 -0
- package/scaffold/.agent/skills/total-recall/openwiki/memory.md +11 -0
- package/scaffold/.agent/skills/total-recall/openwiki/secrets.md +7 -0
- package/scaffold/.agent/skills/total-recall/openwiki/skills.md +7 -0
- package/src/cli/brain.mjs +63 -5
- package/src/cli/connect.mjs +11 -19
- package/src/cli/daemon.mjs +5 -4
- package/src/cli/deploy-ui.mjs +8 -3
- package/src/cli/deploy.mjs +84 -27
- package/src/cli/ingest/index.mjs +1 -1
- package/src/cli/init.mjs +63 -34
- package/src/cli/remember.mjs +3 -3
- package/src/cli/secret.mjs +812 -0
- package/src/cli/setup.mjs +34 -7
- package/src/cli/share.mjs +2 -2
- package/src/cli/skill.mjs +396 -35
- package/src/cli/sync.mjs +4 -4
- package/src/cli/task.mjs +287 -0
- package/src/core/clarity-rewriter.mjs +5 -5
- package/src/core/config.mjs +44 -2
- package/src/core/conflict-detector.mjs +5 -5
- package/src/core/crons.mjs +54 -0
- package/src/core/daemon-loop.mjs +87 -235
- package/src/core/dream.mjs +170 -10
- package/src/core/env-import.mjs +456 -0
- package/src/core/fact-seeker.mjs +14 -14
- package/src/core/import-rules.mjs +20 -7
- package/src/core/inference-engine.mjs +3 -3
- package/src/core/migrate.mjs +14 -8
- package/src/core/okf-adapter.mjs +2 -2
- package/src/core/operation-validator.mjs +39 -331
- package/src/core/optimizer.mjs +7 -0
- package/src/core/post-mortem.mjs +2 -2
- package/src/core/project-brain.mjs +349 -0
- package/src/core/provider-catalog.mjs +296 -0
- package/src/core/quick-capture.mjs +1 -1
- package/src/core/remote-vault-sync.mjs +194 -0
- package/src/core/research-queue.mjs +7 -11
- package/src/core/runtime.mjs +33 -9
- package/src/core/scheduler.mjs +59 -5
- package/src/core/schema.mjs +14 -0
- package/src/core/search.mjs +1 -1
- package/src/core/secrets-env-export.mjs +323 -0
- package/src/core/secrets-rotate.mjs +245 -0
- package/src/core/secrets-store.mjs +645 -0
- package/src/core/semantic-index.mjs +3 -3
- package/src/core/skills-registry.mjs +1062 -0
- package/src/core/source-adapters.mjs +4 -1
- package/src/core/ssss-host-extension.mjs +94 -0
- package/src/core/ssss-kernel-bridge.mjs +530 -0
- package/src/core/surface.mjs +14 -0
- package/src/core/task-envelope.mjs +298 -0
- package/src/core/task-executors.mjs +600 -0
- package/src/core/validated-write.mjs +124 -56
- package/src/core/vault.mjs +21 -34
- package/src/core/webauthn-store.mjs +272 -0
- package/src/server/api.mjs +26 -16
- package/src/server/auth.mjs +51 -0
- package/src/server/index.mjs +3 -3
- package/src/server/keys.mjs +30 -4
- package/src/server/rest.mjs +198 -25
- package/src/server/route-manifest.json +156 -0
- package/src/server/routes/_shared.mjs +40 -1
- package/src/server/routes/docs.mjs +260 -0
- package/src/server/routes/memory.mjs +145 -32
- package/src/server/routes/research.mjs +30 -0
- package/src/server/routes/secrets.mjs +470 -0
- package/src/server/routes/share.mjs +2 -2
- package/src/server/routes/skills.mjs +452 -54
- package/src/server/routes/sync.mjs +48 -0
- package/src/server/routes/webauthn.mjs +184 -0
- package/src/server/update-route-manifest.mjs +39 -0
- package/templates/onboarding-interview.md +32 -22
- package/templates/openwiki/README.md +10 -0
- package/templates/openwiki/architecture.md +5 -0
- package/templates/openwiki/ide.md +10 -0
- package/templates/openwiki/memory.md +11 -0
- package/templates/openwiki/secrets.md +7 -0
- package/templates/openwiki/skills.md +7 -0
- package/scaffold/.agent/skills/total-recall/skills/tr-cli-agents/SKILL.md +0 -121
- package/scaffold/.agent/skills/total-recall/skills/tr-cli-agents/references/antigravity.txt +0 -20
- package/scaffold/.agent/skills/total-recall/skills/tr-cli-agents/references/claude.txt +0 -58
- package/scaffold/.agent/skills/total-recall/skills/tr-cli-agents/references/codex.txt +0 -81
- package/scaffold/.agent/skills/total-recall/skills/tr-cli-agents/references/gemini.txt +0 -44
- package/scaffold/.agent/skills/total-recall/skills/tr-cli-agents/scripts/.gitkeep +0 -0
- package/scaffold/.agent/skills/total-recall/skills/tr-cli-agents/subagents/.gitkeep +0 -0
- package/scaffold/.agent/skills/total-recall/skills/tr-research/SKILL.md +0 -338
- package/scaffold/.agent/skills/total-recall/skills/tr-skill/SKILL.md +0 -282
- package/scaffold/.agent/skills/total-recall/skills/tr-skill/assets/skill-template/SKILL.md +0 -20
- package/scaffold/.agent/skills/total-recall/skills/tr-skill/evals/evals.json +0 -20
- package/scaffold/.agent/skills/total-recall/skills/tr-skill/hooks/on-skill-add.sh +0 -14
- package/scaffold/.agent/skills/total-recall/skills/tr-skill/hooks/on-skill-delete.sh +0 -14
- package/scaffold/.agent/skills/total-recall/skills/tr-skill/learnings/.gitkeep +0 -0
- package/scaffold/.agent/skills/total-recall/skills/tr-skill/references/.gitkeep +0 -0
- package/scaffold/.agent/skills/total-recall/skills/tr-skill/scripts/.gitkeep +0 -0
- package/scaffold/.agent/skills/total-recall/skills/tr-skill/scripts/create-skill.sh +0 -65
- package/scaffold/.agent/skills/total-recall/skills/tr-skill/scripts/enforce-skill-optimization.mjs +0 -135
- package/scaffold/.agent/skills/total-recall/skills/tr-skill/scripts/skill-watcher.mjs +0 -64
- package/scaffold/.agent/skills/total-recall/skills/tr-skill/subagents/audit-all-skills.md +0 -62
- package/scaffold/.agent/skills/total-recall/skills/tr-ssss/SKILL.md +0 -298
- package/scaffold/.agent/skills/total-recall/skills/tr-ssss/evals/evals.json +0 -17
- package/scaffold/.agent/skills/total-recall/skills/tr-ssss/references/CHANGELOG.md +0 -124
- package/scaffold/.agent/skills/total-recall/skills/tr-ssss/references/ssss-spec.md +0 -1325
- package/scaffold/.agent/skills/total-recall/skills/tr-ssss/scripts/validate-schema.mjs +0 -232
- package/scaffold/.agent/skills/total-recall/skills/tr-ssss/subagents/audit-ssss-node.md +0 -9
- /package/scaffold/.agent/skills/total-recall/{skills/tr-cli-agents/evals → modules/skill-deploy/scripts}/.gitkeep +0 -0
- /package/scaffold/.agent/skills/total-recall/{skills/tr-skill → modules/skill-deploy}/scripts/find-skills.mjs +0 -0
- /package/scaffold/.agent/skills/total-recall/{skills/tr-skill → modules/skill-deploy}/scripts/install-skill.mjs +0 -0
- /package/scaffold/.agent/skills/total-recall/{skills/tr-skill → modules/skill-deploy}/scripts/scan-skill.mjs +0 -0
|
@@ -1,288 +1,1951 @@
|
|
|
1
|
-
import { useState, useEffect, useCallback } from 'react'
|
|
2
|
-
import {
|
|
3
|
-
|
|
1
|
+
import { useState, useEffect, useCallback, type CSSProperties, type ReactNode } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
listApiKeys,
|
|
4
|
+
issueApiKey,
|
|
5
|
+
revokeApiKey,
|
|
6
|
+
fetchSecretsCatalog,
|
|
7
|
+
updateSecretMeta,
|
|
8
|
+
rotateSecretValue,
|
|
9
|
+
deleteProviderSecret,
|
|
10
|
+
recordSecretUsage,
|
|
11
|
+
scanEnvSecrets,
|
|
12
|
+
importEnvSecrets,
|
|
13
|
+
exportEnvFromSecrets,
|
|
14
|
+
enqueueRotationDue,
|
|
15
|
+
fetchWebAuthnStatus,
|
|
16
|
+
webauthnRegisterOptions,
|
|
17
|
+
webauthnRegisterVerify,
|
|
18
|
+
webauthnAssertOptions,
|
|
19
|
+
webauthnAssertVerify,
|
|
20
|
+
webauthnPasswordStepUp,
|
|
21
|
+
revealSecretValue,
|
|
22
|
+
type IssuedApiKey,
|
|
23
|
+
type SecretCatalog,
|
|
24
|
+
type SecretCatalogKey,
|
|
25
|
+
type WebAuthnStatus,
|
|
26
|
+
fetchConfigJson,
|
|
27
|
+
saveConfigJson,
|
|
28
|
+
fetchGeminiModels,
|
|
29
|
+
fetchClaudeModels,
|
|
30
|
+
fetchOpenaiModels,
|
|
31
|
+
fetchOpenRouterModels
|
|
32
|
+
} from '../api'
|
|
33
|
+
import type { ConfigJson, GeminiModelInfo } from '../types'
|
|
34
|
+
import { startRegistration, startAuthentication, browserSupportsWebAuthn } from '@simplewebauthn/browser'
|
|
35
|
+
|
|
36
|
+
type Tab = 'catalog' | 'pats' | 'import' | 'cloud'
|
|
37
|
+
|
|
38
|
+
/** Form state — string inputs; converted on save */
|
|
39
|
+
type MetaEdit = {
|
|
40
|
+
label: string
|
|
41
|
+
provider: string
|
|
42
|
+
repos: string
|
|
43
|
+
subscription_tier: string
|
|
44
|
+
monthly_cost_usd: string
|
|
45
|
+
monthly_cap_usd: string
|
|
46
|
+
api_docs_url: string
|
|
47
|
+
rotate_every_days: string
|
|
48
|
+
auto_rotate: boolean
|
|
49
|
+
notes: string
|
|
50
|
+
project_path: string
|
|
51
|
+
}
|
|
4
52
|
|
|
5
53
|
export default function ApiKeysPage() {
|
|
6
|
-
const [
|
|
7
|
-
|
|
54
|
+
const [tab, setTab] = useState<Tab>('catalog')
|
|
55
|
+
|
|
56
|
+
const [configData, setConfigData] = useState<ConfigJson | null>(null)
|
|
57
|
+
const [geminiModels, setGeminiModels] = useState<GeminiModelInfo[]>([])
|
|
58
|
+
const [claudeModels, setClaudeModels] = useState<GeminiModelInfo[]>([])
|
|
59
|
+
const [openaiModels, setOpenaiModels] = useState<GeminiModelInfo[]>([])
|
|
60
|
+
const [orModels, setOrModels] = useState<GeminiModelInfo[]>([])
|
|
61
|
+
const [configSuccess, setConfigSuccess] = useState<string | null>(null)
|
|
62
|
+
|
|
63
|
+
const [catalog, setCatalog] = useState<SecretCatalog | null>(null)
|
|
8
64
|
const [loading, setLoading] = useState(true)
|
|
9
65
|
const [error, setError] = useState<string | null>(null)
|
|
66
|
+
const [filter, setFilter] = useState('')
|
|
67
|
+
const [selected, setSelected] = useState<SecretCatalogKey | null>(null)
|
|
68
|
+
const [edit, setEdit] = useState<MetaEdit>({
|
|
69
|
+
label: '',
|
|
70
|
+
provider: '',
|
|
71
|
+
repos: '',
|
|
72
|
+
subscription_tier: '',
|
|
73
|
+
monthly_cost_usd: '',
|
|
74
|
+
monthly_cap_usd: '',
|
|
75
|
+
api_docs_url: '',
|
|
76
|
+
rotate_every_days: '',
|
|
77
|
+
auto_rotate: false,
|
|
78
|
+
notes: '',
|
|
79
|
+
project_path: '',
|
|
80
|
+
})
|
|
81
|
+
const [rotateVal, setRotateVal] = useState('')
|
|
82
|
+
const [saving, setSaving] = useState(false)
|
|
83
|
+
const [status, setStatus] = useState('')
|
|
84
|
+
/** Accordion: which repo sections are open (default: error + first product only) */
|
|
85
|
+
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set())
|
|
86
|
+
const [sectionsInited, setSectionsInited] = useState(false)
|
|
87
|
+
const [showHelp, setShowHelp] = useState(false)
|
|
88
|
+
const [showCostChart, setShowCostChart] = useState(false)
|
|
89
|
+
const [webauthn, setWebauthn] = useState<WebAuthnStatus | null>(null)
|
|
90
|
+
const [revealedValue, setRevealedValue] = useState<string | null>(null)
|
|
91
|
+
const [revealPassword, setRevealPassword] = useState('')
|
|
92
|
+
const [revealBusy, setRevealBusy] = useState(false)
|
|
93
|
+
|
|
94
|
+
// PAT state
|
|
95
|
+
const [pats, setPats] = useState<Awaited<ReturnType<typeof listApiKeys>>['keys']>([])
|
|
10
96
|
const [newKeyName, setNewKeyName] = useState('')
|
|
11
|
-
const [selectedScopes, setSelectedScopes] = useState<string[]>(['*'])
|
|
12
|
-
const [expiresAt, setExpiresAt] = useState('')
|
|
13
|
-
const [issuing, setIssuing] = useState(false)
|
|
14
97
|
const [newlyIssued, setNewlyIssued] = useState<IssuedApiKey | null>(null)
|
|
15
98
|
const [copied, setCopied] = useState(false)
|
|
16
99
|
|
|
17
|
-
|
|
18
|
-
|
|
100
|
+
// Import state
|
|
101
|
+
const [scanCandidates, setScanCandidates] = useState<
|
|
102
|
+
{ key: string; masked: string; already_set?: boolean; provider: string | null; source_label: string }[]
|
|
103
|
+
>([])
|
|
104
|
+
const [importSel, setImportSel] = useState<Set<string>>(new Set())
|
|
105
|
+
|
|
106
|
+
const loadCatalog = useCallback(async () => {
|
|
107
|
+
setLoading(true)
|
|
19
108
|
setError(null)
|
|
20
109
|
try {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
const config = await fetchConfigJson()
|
|
113
|
+
if (!config.secrets) config.secrets = {}
|
|
114
|
+
if (!config.brain) config.brain = {}
|
|
115
|
+
setConfigData(config)
|
|
116
|
+
|
|
117
|
+
const gem = await fetchGeminiModels().catch(() => [])
|
|
118
|
+
setGeminiModels(gem)
|
|
119
|
+
const cla = await fetchClaudeModels().catch(() => [])
|
|
120
|
+
setClaudeModels(cla)
|
|
121
|
+
const open = await fetchOpenaiModels().catch(() => [])
|
|
122
|
+
setOpenaiModels(open)
|
|
123
|
+
const or = await fetchOpenRouterModels().catch(() => [])
|
|
124
|
+
setOrModels(or)
|
|
125
|
+
} catch(e) {}
|
|
126
|
+
|
|
127
|
+
const data = await fetchSecretsCatalog()
|
|
128
|
+
setCatalog(data)
|
|
129
|
+
if (selected) {
|
|
130
|
+
const fresh = data.keys.find((k) => k.key === selected.key)
|
|
131
|
+
setSelected(fresh || null)
|
|
132
|
+
if (fresh) setEdit(metaForm(fresh))
|
|
133
|
+
}
|
|
24
134
|
} catch (e: unknown) {
|
|
25
135
|
setError((e as Error).message)
|
|
26
136
|
} finally {
|
|
27
137
|
setLoading(false)
|
|
28
138
|
}
|
|
139
|
+
}, [selected?.key])
|
|
140
|
+
|
|
141
|
+
const loadPats = useCallback(async () => {
|
|
142
|
+
try {
|
|
143
|
+
const r = await listApiKeys()
|
|
144
|
+
setPats(r.keys || [])
|
|
145
|
+
} catch {
|
|
146
|
+
// ignore
|
|
147
|
+
}
|
|
29
148
|
}, [])
|
|
30
149
|
|
|
31
|
-
|
|
32
|
-
|
|
150
|
+
const loadWebAuthn = useCallback(async () => {
|
|
151
|
+
try {
|
|
152
|
+
setWebauthn(await fetchWebAuthnStatus())
|
|
153
|
+
} catch {
|
|
154
|
+
setWebauthn(null)
|
|
155
|
+
}
|
|
156
|
+
}, [])
|
|
157
|
+
|
|
158
|
+
useEffect(() => {
|
|
159
|
+
void loadCatalog()
|
|
160
|
+
void loadPats()
|
|
161
|
+
void loadWebAuthn()
|
|
162
|
+
}, [])
|
|
33
163
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
164
|
+
|
|
165
|
+
const updateSecretsProp = (prop: string, value: string) => {
|
|
166
|
+
if (!configData) return;
|
|
167
|
+
setConfigData({
|
|
168
|
+
...configData,
|
|
169
|
+
secrets: { ...configData.secrets, [prop]: value }
|
|
170
|
+
});
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const updateBrainProp = (prop: string, value: string) => {
|
|
174
|
+
if (!configData) return;
|
|
175
|
+
setConfigData({
|
|
176
|
+
...configData,
|
|
177
|
+
brain: { ...configData.brain, [prop]: value }
|
|
178
|
+
});
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const handleSaveConfig = async () => {
|
|
182
|
+
if (!configData) return;
|
|
183
|
+
setSaving(true);
|
|
184
|
+
setError(null);
|
|
185
|
+
setConfigSuccess(null);
|
|
186
|
+
try {
|
|
187
|
+
await saveConfigJson(configData);
|
|
188
|
+
setConfigSuccess('Configuration saved successfully. Kernel will hot-reload settings.');
|
|
189
|
+
setTimeout(() => setConfigSuccess(null), 4000);
|
|
190
|
+
} catch (err: unknown) {
|
|
191
|
+
setError((err as Error).message || 'Failed to save configuration.');
|
|
192
|
+
} finally {
|
|
193
|
+
setSaving(false);
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
function metaForm(k: SecretCatalogKey): MetaEdit {
|
|
198
|
+
return {
|
|
199
|
+
label: k.label || '',
|
|
200
|
+
provider: k.provider || '',
|
|
201
|
+
repos: (k.repos || []).join(', '),
|
|
202
|
+
subscription_tier: k.subscription_tier || '',
|
|
203
|
+
monthly_cost_usd: k.monthly_cost_usd != null ? String(k.monthly_cost_usd) : '',
|
|
204
|
+
monthly_cap_usd: k.monthly_cap_usd != null ? String(k.monthly_cap_usd) : '',
|
|
205
|
+
api_docs_url: k.api_docs_url || '',
|
|
206
|
+
rotate_every_days: k.rotate_every_days != null ? String(k.rotate_every_days) : '',
|
|
207
|
+
auto_rotate: !!k.auto_rotate,
|
|
208
|
+
notes: k.notes || '',
|
|
209
|
+
project_path: k.project_path || '',
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function openKey(k: SecretCatalogKey, sectionId?: string) {
|
|
214
|
+
setSelected(k)
|
|
215
|
+
setEdit(metaForm(k))
|
|
216
|
+
setRotateVal('')
|
|
217
|
+
setStatus('')
|
|
218
|
+
setRevealedValue(null)
|
|
219
|
+
setRevealPassword('')
|
|
220
|
+
// Expand the section this key lives in so context stays visible
|
|
221
|
+
if (sectionId) {
|
|
222
|
+
setExpandedSections((prev) => {
|
|
223
|
+
const next = new Set(prev)
|
|
224
|
+
next.add(sectionId)
|
|
225
|
+
return next
|
|
226
|
+
})
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function registerPasskey() {
|
|
231
|
+
if (!browserSupportsWebAuthn()) {
|
|
232
|
+
setError('This browser does not support passkeys / WebAuthn')
|
|
233
|
+
return
|
|
234
|
+
}
|
|
235
|
+
setRevealBusy(true)
|
|
37
236
|
setError(null)
|
|
38
237
|
try {
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
await load(true)
|
|
238
|
+
const options = await webauthnRegisterOptions()
|
|
239
|
+
const attResp = await startRegistration({ optionsJSON: options as never })
|
|
240
|
+
await webauthnRegisterVerify(attResp, 'Touch ID / platform')
|
|
241
|
+
await loadWebAuthn()
|
|
242
|
+
setStatus('Passkey registered — use it to reveal secret values')
|
|
45
243
|
} catch (e: unknown) {
|
|
46
|
-
setError((e as Error).message)
|
|
244
|
+
setError((e as Error).message || 'Passkey registration cancelled')
|
|
47
245
|
} finally {
|
|
48
|
-
|
|
246
|
+
setRevealBusy(false)
|
|
49
247
|
}
|
|
50
248
|
}
|
|
51
249
|
|
|
52
|
-
|
|
53
|
-
|
|
250
|
+
async function stepUpWithPasskey(): Promise<string> {
|
|
251
|
+
const options = await webauthnAssertOptions()
|
|
252
|
+
const authResp = await startAuthentication({ optionsJSON: options as never })
|
|
253
|
+
const r = await webauthnAssertVerify(authResp)
|
|
254
|
+
return r.step_up_token
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async function stepUpWithPassword(): Promise<string> {
|
|
258
|
+
if (!revealPassword.trim()) throw new Error('Enter dashboard password')
|
|
259
|
+
const r = await webauthnPasswordStepUp(revealPassword)
|
|
260
|
+
setRevealPassword('')
|
|
261
|
+
return r.step_up_token
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function doReveal(method: 'passkey' | 'password') {
|
|
265
|
+
if (!selected) return
|
|
266
|
+
setRevealBusy(true)
|
|
267
|
+
setError(null)
|
|
268
|
+
setRevealedValue(null)
|
|
54
269
|
try {
|
|
55
|
-
await
|
|
56
|
-
await
|
|
270
|
+
const token = method === 'passkey' ? await stepUpWithPasskey() : await stepUpWithPassword()
|
|
271
|
+
const r = await revealSecretValue(selected.key, token)
|
|
272
|
+
setRevealedValue(r.value)
|
|
273
|
+
setStatus(`Revealed ${selected.key} (auto-hides in 30s)`)
|
|
274
|
+
window.setTimeout(() => setRevealedValue(null), 30_000)
|
|
57
275
|
} catch (e: unknown) {
|
|
58
276
|
setError((e as Error).message)
|
|
277
|
+
} finally {
|
|
278
|
+
setRevealBusy(false)
|
|
59
279
|
}
|
|
60
280
|
}
|
|
61
281
|
|
|
62
|
-
|
|
63
|
-
if (!
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
282
|
+
async function doCopyRevealed() {
|
|
283
|
+
if (!revealedValue) return
|
|
284
|
+
try {
|
|
285
|
+
await navigator.clipboard.writeText(revealedValue)
|
|
286
|
+
setStatus('Copied to clipboard')
|
|
287
|
+
} catch {
|
|
288
|
+
setError('Clipboard copy failed')
|
|
289
|
+
}
|
|
67
290
|
}
|
|
68
291
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
const withoutAll = current.filter(s => s !== '*')
|
|
76
|
-
const next = withoutAll.includes(scope)
|
|
77
|
-
? withoutAll.filter(s => s !== scope)
|
|
78
|
-
: [...withoutAll, scope]
|
|
79
|
-
return next.length ? next : ['*']
|
|
292
|
+
function toggleSection(id: string) {
|
|
293
|
+
setExpandedSections((prev) => {
|
|
294
|
+
const next = new Set(prev)
|
|
295
|
+
if (next.has(id)) next.delete(id)
|
|
296
|
+
else next.add(id)
|
|
297
|
+
return next
|
|
80
298
|
})
|
|
81
299
|
}
|
|
82
300
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
301
|
+
function expandOnly(id: string) {
|
|
302
|
+
setExpandedSections(new Set([id]))
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function expandAllSections(ids: string[]) {
|
|
306
|
+
setExpandedSections(new Set(ids))
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function collapseAllSections() {
|
|
310
|
+
setExpandedSections(new Set())
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async function saveMeta() {
|
|
314
|
+
if (!selected) return
|
|
315
|
+
setSaving(true)
|
|
316
|
+
setStatus('')
|
|
317
|
+
try {
|
|
318
|
+
const reposList = edit.repos
|
|
319
|
+
.split(/[,]+/)
|
|
320
|
+
.map((s) => s.trim())
|
|
321
|
+
.filter(Boolean)
|
|
322
|
+
if (reposList.length > 1) {
|
|
323
|
+
setError(
|
|
324
|
+
`Each key may bind to only ONE repo (you entered ${reposList.length}: ${reposList.join(', ')}). ` +
|
|
325
|
+
`Pick one product repo, or leave empty for Developer secrets (tooling).`,
|
|
326
|
+
)
|
|
327
|
+
setSaving(false)
|
|
328
|
+
return
|
|
329
|
+
}
|
|
330
|
+
const patch = {
|
|
331
|
+
label: edit.label.trim() || null,
|
|
332
|
+
provider: edit.provider.trim() || null,
|
|
333
|
+
repos: reposList,
|
|
334
|
+
subscription_tier: edit.subscription_tier.trim() || null,
|
|
335
|
+
monthly_cost_usd: edit.monthly_cost_usd.trim() === '' ? null : Number(edit.monthly_cost_usd),
|
|
336
|
+
monthly_cap_usd: edit.monthly_cap_usd.trim() === '' ? null : Number(edit.monthly_cap_usd),
|
|
337
|
+
api_docs_url: edit.api_docs_url.trim() || null,
|
|
338
|
+
rotate_every_days:
|
|
339
|
+
edit.rotate_every_days.trim() === '' ? null : Number(edit.rotate_every_days),
|
|
340
|
+
auto_rotate: !!edit.auto_rotate,
|
|
341
|
+
notes: edit.notes.trim() || null,
|
|
342
|
+
project_path: edit.project_path.trim() || null,
|
|
343
|
+
}
|
|
344
|
+
await updateSecretMeta(selected.key, patch)
|
|
345
|
+
setStatus('Metadata saved')
|
|
346
|
+
await loadCatalog()
|
|
347
|
+
} catch (e: unknown) {
|
|
348
|
+
setError((e as Error).message)
|
|
349
|
+
} finally {
|
|
350
|
+
setSaving(false)
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
async function doRotate() {
|
|
355
|
+
if (!selected || !rotateVal) return
|
|
356
|
+
setSaving(true)
|
|
357
|
+
try {
|
|
358
|
+
const r = await rotateSecretValue(selected.key, rotateVal, {
|
|
359
|
+
export_env: true,
|
|
360
|
+
export_all: true,
|
|
361
|
+
})
|
|
362
|
+
setRotateVal('')
|
|
363
|
+
const n = r.exports?.filter((e) => e.ok).length ?? 0
|
|
364
|
+
setStatus(
|
|
365
|
+
n > 0
|
|
366
|
+
? `Rotated ${selected.key} · exported .env to ${n} target(s)`
|
|
367
|
+
: `Rotated ${selected.key} · store updated (no project paths matched — bind --repo or Export .env)`,
|
|
368
|
+
)
|
|
369
|
+
await loadCatalog()
|
|
370
|
+
} catch (e: unknown) {
|
|
371
|
+
setError((e as Error).message)
|
|
372
|
+
} finally {
|
|
373
|
+
setSaving(false)
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
async function doDelete() {
|
|
378
|
+
if (!selected) return
|
|
379
|
+
if (!confirm(`Delete secret ${selected.key}?`)) return
|
|
380
|
+
try {
|
|
381
|
+
await deleteProviderSecret(selected.key)
|
|
382
|
+
setSelected(null)
|
|
383
|
+
await loadCatalog()
|
|
384
|
+
} catch (e: unknown) {
|
|
385
|
+
setError((e as Error).message)
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
async function sampleUsage() {
|
|
390
|
+
if (!selected) return
|
|
391
|
+
try {
|
|
392
|
+
await recordSecretUsage({
|
|
393
|
+
key_ref: selected.key,
|
|
394
|
+
provider: selected.provider || 'unknown',
|
|
395
|
+
cost_usd: 0.01,
|
|
396
|
+
model: 'manual-sample',
|
|
397
|
+
source: 'dashboard',
|
|
398
|
+
})
|
|
399
|
+
setStatus('Sample usage event recorded ($0.01)')
|
|
400
|
+
await loadCatalog()
|
|
401
|
+
} catch (e: unknown) {
|
|
402
|
+
setError((e as Error).message)
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
async function runScan() {
|
|
407
|
+
try {
|
|
408
|
+
const data = await scanEnvSecrets()
|
|
409
|
+
setScanCandidates(data.candidates || [])
|
|
410
|
+
setImportSel(new Set((data.candidates || []).filter((c) => !c.already_set).map((c) => c.key)))
|
|
411
|
+
setTab('import')
|
|
412
|
+
} catch (e: unknown) {
|
|
413
|
+
setError((e as Error).message)
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
async function runImport() {
|
|
418
|
+
try {
|
|
419
|
+
const r = await importEnvSecrets({ keys: [...importSel] })
|
|
420
|
+
setStatus(`Imported ${r.imported_count}, skipped ${r.skipped_count}`)
|
|
421
|
+
await loadCatalog()
|
|
422
|
+
setTab('catalog')
|
|
423
|
+
} catch (e: unknown) {
|
|
424
|
+
setError((e as Error).message)
|
|
425
|
+
}
|
|
86
426
|
}
|
|
87
427
|
|
|
428
|
+
const keys = (catalog?.keys || []).filter((k) => {
|
|
429
|
+
if (!filter.trim()) return true
|
|
430
|
+
const q = filter.toLowerCase()
|
|
431
|
+
return (
|
|
432
|
+
k.key.toLowerCase().includes(q) ||
|
|
433
|
+
(k.provider || '').toLowerCase().includes(q) ||
|
|
434
|
+
(k.repos || []).some((r) => r.toLowerCase().includes(q)) ||
|
|
435
|
+
(k.label || '').toLowerCase().includes(q)
|
|
436
|
+
)
|
|
437
|
+
})
|
|
438
|
+
|
|
439
|
+
/** Group filtered keys into repo sections (one key → one repo; multi = error; none = developer). */
|
|
440
|
+
const repoSections = groupKeysByRepo(keys)
|
|
441
|
+
|
|
442
|
+
// Default accordion: open errors + first product/developer section only
|
|
443
|
+
useEffect(() => {
|
|
444
|
+
if (sectionsInited || !repoSections.length) return
|
|
445
|
+
const next = new Set<string>()
|
|
446
|
+
for (const s of repoSections) {
|
|
447
|
+
if (s.kind === 'error') next.add(s.id)
|
|
448
|
+
}
|
|
449
|
+
const first = repoSections.find((s) => s.kind !== 'error')
|
|
450
|
+
if (first) next.add(first.id)
|
|
451
|
+
setExpandedSections(next)
|
|
452
|
+
setSectionsInited(true)
|
|
453
|
+
}, [repoSections, sectionsInited])
|
|
454
|
+
|
|
455
|
+
const summary = catalog?.summary
|
|
456
|
+
|
|
88
457
|
return (
|
|
89
|
-
<div className="page">
|
|
90
|
-
<div className="page-header">
|
|
91
|
-
<
|
|
92
|
-
|
|
458
|
+
<div className="page" style={{ maxWidth: selected ? 1400 : 1100 }}>
|
|
459
|
+
<div className="page-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end', flexWrap: 'wrap', gap: 12, marginBottom: 12 }}>
|
|
460
|
+
<div>
|
|
461
|
+
<h1 style={{ marginBottom: 4 }}>Secrets & Access</h1>
|
|
462
|
+
<p style={{ margin: 0, fontSize: 13, color: 'var(--text-secondary)' }}>
|
|
463
|
+
Provider keys (SSOT) · one key → one repo · empty repo = Developer secrets · PATs separate
|
|
464
|
+
</p>
|
|
465
|
+
</div>
|
|
466
|
+
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
|
467
|
+
<button
|
|
468
|
+
type="button"
|
|
469
|
+
className="btn btn-primary btn-sm"
|
|
470
|
+
onClick={async () => {
|
|
471
|
+
try {
|
|
472
|
+
const r = await exportEnvFromSecrets({ all_projects: true })
|
|
473
|
+
const n = r.results?.filter((x) => x.ok).length ?? r.count ?? 0
|
|
474
|
+
setStatus(`Exported .env to ${n} project(s) from secrets store`)
|
|
475
|
+
} catch (e: unknown) {
|
|
476
|
+
setError((e as Error).message)
|
|
477
|
+
}
|
|
478
|
+
}}
|
|
479
|
+
>
|
|
480
|
+
Export .env → projects
|
|
481
|
+
</button>
|
|
482
|
+
<button
|
|
483
|
+
type="button"
|
|
484
|
+
className="btn btn-ghost btn-sm"
|
|
485
|
+
onClick={async () => {
|
|
486
|
+
try {
|
|
487
|
+
const r = (await enqueueRotationDue()) as {
|
|
488
|
+
due?: number
|
|
489
|
+
tasks?: { status: string; key: string }[]
|
|
490
|
+
}
|
|
491
|
+
const created = r.tasks?.filter((t) => t.status === 'created').length ?? 0
|
|
492
|
+
const exists = r.tasks?.filter((t) => t.status === 'exists').length ?? 0
|
|
493
|
+
setStatus(
|
|
494
|
+
`Rotation queue: ${r.due ?? 0} due · ${created} task(s) created · ${exists} already pending`,
|
|
495
|
+
)
|
|
496
|
+
await loadCatalog()
|
|
497
|
+
} catch (e: unknown) {
|
|
498
|
+
setError((e as Error).message)
|
|
499
|
+
}
|
|
500
|
+
}}
|
|
501
|
+
>
|
|
502
|
+
Enqueue rotations
|
|
503
|
+
</button>
|
|
504
|
+
<button type="button" className="btn btn-ghost btn-sm" onClick={() => void runScan()}>
|
|
505
|
+
Migrate import
|
|
506
|
+
</button>
|
|
507
|
+
<button type="button" className="btn btn-ghost btn-sm" onClick={() => void loadCatalog()}>
|
|
508
|
+
Refresh
|
|
509
|
+
</button>
|
|
510
|
+
</div>
|
|
93
511
|
</div>
|
|
94
512
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
}}
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
513
|
+
<button
|
|
514
|
+
type="button"
|
|
515
|
+
className="btn btn-ghost btn-sm"
|
|
516
|
+
style={{ marginBottom: 10, fontSize: 11 }}
|
|
517
|
+
onClick={() => setShowHelp((v) => !v)}
|
|
518
|
+
>
|
|
519
|
+
{showHelp ? '▾ Hide model notes' : '▸ How secrets work'}
|
|
520
|
+
</button>
|
|
521
|
+
{showHelp && (
|
|
522
|
+
<div
|
|
523
|
+
className="card"
|
|
524
|
+
style={{
|
|
525
|
+
padding: 12,
|
|
526
|
+
marginBottom: 12,
|
|
527
|
+
fontSize: 12,
|
|
528
|
+
color: 'var(--text-secondary)',
|
|
529
|
+
lineHeight: 1.5,
|
|
530
|
+
borderColor: 'var(--border-accent)',
|
|
531
|
+
}}
|
|
532
|
+
>
|
|
533
|
+
<ul style={{ margin: 0, paddingLeft: 18 }}>
|
|
534
|
+
<li>
|
|
535
|
+
<strong>SSOT</strong> — keys in TR store; <strong>export-env</strong> writes repo <code>.env</code>.
|
|
536
|
+
</li>
|
|
537
|
+
<li>
|
|
538
|
+
<strong>One key → one product repo</strong>; multi-repo is an error. Empty = Developer secrets (tooling).
|
|
539
|
+
</li>
|
|
540
|
+
<li>
|
|
541
|
+
<strong>import-env</strong> = one-time migrate. <strong>PATs</strong> = brain API tokens only.
|
|
542
|
+
</li>
|
|
543
|
+
</ul>
|
|
544
|
+
</div>
|
|
545
|
+
)}
|
|
546
|
+
|
|
547
|
+
{error && (
|
|
548
|
+
<div className="badge badge-error" style={{ marginBottom: 16, display: 'block', padding: 12 }}>
|
|
549
|
+
{error}
|
|
550
|
+
<button type="button" className="btn btn-ghost btn-sm" style={{ marginLeft: 12 }} onClick={() => setError(null)}>
|
|
124
551
|
Dismiss
|
|
125
552
|
</button>
|
|
126
553
|
</div>
|
|
127
554
|
)}
|
|
555
|
+
{status && (
|
|
556
|
+
<div style={{ marginBottom: 12, fontSize: 13, color: '#34d399' }}>{status}</div>
|
|
557
|
+
)}
|
|
128
558
|
|
|
129
|
-
{/*
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
559
|
+
{/* Summary cards */}
|
|
560
|
+
{catalog?.store && (
|
|
561
|
+
<div
|
|
562
|
+
style={{
|
|
563
|
+
marginBottom: 12,
|
|
564
|
+
fontSize: 11,
|
|
565
|
+
color: 'var(--text-tertiary)',
|
|
566
|
+
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
|
|
567
|
+
wordBreak: 'break-all',
|
|
568
|
+
}}
|
|
569
|
+
>
|
|
570
|
+
Store (global SSOT — not filtered by brain selector): {catalog.store}
|
|
571
|
+
{typeof catalog?.summary?.total_keys === 'number' ? ` · ${catalog.summary.total_keys} keys` : ''}
|
|
572
|
+
</div>
|
|
573
|
+
)}
|
|
574
|
+
|
|
575
|
+
{summary && (
|
|
576
|
+
<div
|
|
577
|
+
style={{
|
|
578
|
+
display: 'grid',
|
|
579
|
+
gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))',
|
|
580
|
+
gap: 12,
|
|
581
|
+
marginBottom: 20,
|
|
582
|
+
}}
|
|
583
|
+
>
|
|
584
|
+
<SummaryCard label="Keys" value={String(summary.total_keys)} accent="#60a5fa" />
|
|
585
|
+
<SummaryCard label="Providers" value={String(summary.providers_active)} accent="#a78bfa" />
|
|
586
|
+
<SummaryCard
|
|
587
|
+
label="Planned / mo"
|
|
588
|
+
value={`$${(summary.monthly_subscription_usd || 0).toFixed(0)}`}
|
|
589
|
+
accent="#fbbf24"
|
|
590
|
+
/>
|
|
591
|
+
<SummaryCard
|
|
592
|
+
label="Usage 30d"
|
|
593
|
+
value={`$${(summary.usage_30d?.cost_usd || 0).toFixed(2)}`}
|
|
594
|
+
accent="#34d399"
|
|
595
|
+
/>
|
|
596
|
+
<SummaryCard
|
|
597
|
+
label="Usage 7d"
|
|
598
|
+
value={`$${(summary.usage_7d?.cost_usd || 0).toFixed(2)}`}
|
|
599
|
+
accent="#22d3ee"
|
|
600
|
+
/>
|
|
601
|
+
<SummaryCard
|
|
602
|
+
label="Rotate due"
|
|
603
|
+
value={String(summary.rotation_overdue || 0)}
|
|
604
|
+
accent={summary.rotation_overdue ? '#f87171' : '#64748b'}
|
|
148
605
|
/>
|
|
606
|
+
<SummaryCard
|
|
607
|
+
label="Multi-repo errors"
|
|
608
|
+
value={String(summary.multi_repo_violations || 0)}
|
|
609
|
+
accent={summary.multi_repo_violations ? '#f87171' : '#64748b'}
|
|
610
|
+
/>
|
|
611
|
+
<SummaryCard
|
|
612
|
+
label="Developer"
|
|
613
|
+
value={String(summary.developer_keys || 0)}
|
|
614
|
+
accent="#a78bfa"
|
|
615
|
+
/>
|
|
616
|
+
</div>
|
|
617
|
+
)}
|
|
618
|
+
|
|
619
|
+
{(summary?.multi_repo_violations || 0) > 0 && (
|
|
620
|
+
<div
|
|
621
|
+
className="card"
|
|
622
|
+
style={{
|
|
623
|
+
marginBottom: 16,
|
|
624
|
+
padding: 14,
|
|
625
|
+
borderColor: 'rgba(248,113,113,0.5)',
|
|
626
|
+
background: 'rgba(248,113,113,0.08)',
|
|
627
|
+
fontSize: 13,
|
|
628
|
+
lineHeight: 1.5,
|
|
629
|
+
}}
|
|
630
|
+
>
|
|
631
|
+
<strong style={{ color: '#f87171' }}>Multi-repo binding error</strong>
|
|
632
|
+
<p style={{ margin: '6px 0 0', color: 'var(--text-secondary)' }}>
|
|
633
|
+
{summary?.multi_repo_violations} key(s) are bound to more than one repo. Each secret must be
|
|
634
|
+
unique to a single product repo, or left unbound as a <strong>Developer secret</strong>{' '}
|
|
635
|
+
(tooling / personal). Fix in the red “Needs fix” section below — set <em>one</em> repo or
|
|
636
|
+
clear the field.
|
|
637
|
+
</p>
|
|
638
|
+
</div>
|
|
639
|
+
)}
|
|
640
|
+
|
|
641
|
+
{catalog?.by_provider && catalog.by_provider.length > 0 && (
|
|
642
|
+
<>
|
|
149
643
|
<button
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
644
|
+
type="button"
|
|
645
|
+
className="btn btn-ghost btn-sm"
|
|
646
|
+
style={{ marginBottom: 8, fontSize: 11 }}
|
|
647
|
+
onClick={() => setShowCostChart((v) => !v)}
|
|
154
648
|
>
|
|
155
|
-
{
|
|
649
|
+
{showCostChart ? '▾ Hide cost chart' : '▸ Cost by provider'}
|
|
156
650
|
</button>
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
651
|
+
{showCostChart && (
|
|
652
|
+
<div className="card" style={{ marginBottom: 12, padding: 12 }}>
|
|
653
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
654
|
+
{catalog.by_provider.map((p) => {
|
|
655
|
+
const max = Math.max(
|
|
656
|
+
...catalog.by_provider.map((x) => x.cost_30d + x.monthly_cost),
|
|
657
|
+
1,
|
|
658
|
+
)
|
|
659
|
+
const total = p.cost_30d + p.monthly_cost
|
|
660
|
+
const pct = Math.min(100, (total / max) * 100)
|
|
661
|
+
return (
|
|
662
|
+
<div
|
|
663
|
+
key={p.provider}
|
|
664
|
+
style={{ display: 'grid', gridTemplateColumns: '100px 1fr 80px', gap: 10, alignItems: 'center' }}
|
|
665
|
+
>
|
|
666
|
+
<span style={{ fontSize: 12, fontWeight: 600 }}>{p.provider}</span>
|
|
667
|
+
<div style={{ height: 6, background: 'rgba(148,163,184,0.12)', borderRadius: 99, overflow: 'hidden' }}>
|
|
668
|
+
<div
|
|
669
|
+
style={{
|
|
670
|
+
width: `${pct}%`,
|
|
671
|
+
height: '100%',
|
|
672
|
+
background: 'linear-gradient(90deg, #3b82f6, #22d3ee)',
|
|
673
|
+
borderRadius: 99,
|
|
674
|
+
}}
|
|
675
|
+
/>
|
|
676
|
+
</div>
|
|
677
|
+
<span style={{ fontSize: 11, color: 'var(--text-tertiary)', textAlign: 'right' }}>
|
|
678
|
+
${total.toFixed(2)} · {p.keys}k
|
|
679
|
+
</span>
|
|
680
|
+
</div>
|
|
681
|
+
)
|
|
682
|
+
})}
|
|
683
|
+
</div>
|
|
684
|
+
</div>
|
|
685
|
+
)}
|
|
686
|
+
</>
|
|
687
|
+
)}
|
|
688
|
+
|
|
689
|
+
{/* Tabs */}
|
|
690
|
+
<div style={{ display: 'flex', gap: 8, marginBottom: 12, flexWrap: 'wrap' }}>
|
|
691
|
+
{(
|
|
692
|
+
[
|
|
693
|
+
{ id: 'catalog' as Tab, label: 'Provider secrets (env)' },
|
|
694
|
+
{ id: 'import' as Tab, label: 'Import from env' },
|
|
695
|
+
{ id: 'cloud' as Tab, label: 'Cloud Models (API)' },
|
|
696
|
+
{ id: 'pats' as Tab, label: 'TR PATs (key generator)' },
|
|
697
|
+
] as const
|
|
698
|
+
).map((t) => (
|
|
699
|
+
<button
|
|
700
|
+
key={t.id}
|
|
701
|
+
type="button"
|
|
702
|
+
onClick={() => setTab(t.id)}
|
|
703
|
+
className={`btn btn-sm ${tab === t.id ? 'btn-primary' : 'btn-ghost'}`}
|
|
704
|
+
>
|
|
705
|
+
{t.label}
|
|
706
|
+
</button>
|
|
707
|
+
))}
|
|
708
|
+
</div>
|
|
709
|
+
|
|
710
|
+
{tab === 'catalog' && (
|
|
711
|
+
<div
|
|
712
|
+
style={{
|
|
713
|
+
display: 'flex',
|
|
714
|
+
flexDirection: 'column',
|
|
715
|
+
/* Keep list in a fixed viewport so editor never requires page-top scroll */
|
|
716
|
+
height: 'calc(100vh - 280px)',
|
|
717
|
+
minHeight: 420,
|
|
718
|
+
marginRight: selected ? 440 : 0,
|
|
719
|
+
transition: 'margin 0.15s ease',
|
|
720
|
+
}}
|
|
721
|
+
>
|
|
722
|
+
<div
|
|
723
|
+
style={{
|
|
724
|
+
display: 'flex',
|
|
725
|
+
gap: 8,
|
|
726
|
+
flexWrap: 'wrap',
|
|
727
|
+
alignItems: 'center',
|
|
728
|
+
marginBottom: 8,
|
|
729
|
+
flexShrink: 0,
|
|
730
|
+
}}
|
|
731
|
+
>
|
|
161
732
|
<input
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
733
|
+
value={filter}
|
|
734
|
+
onChange={(e) => setFilter(e.target.value)}
|
|
735
|
+
placeholder="Filter key, provider, repo, tier…"
|
|
165
736
|
style={{
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
737
|
+
flex: '1 1 200px',
|
|
738
|
+
minWidth: 160,
|
|
739
|
+
padding: '8px 12px',
|
|
740
|
+
borderRadius: 10,
|
|
741
|
+
border: '1px solid var(--border)',
|
|
742
|
+
background: 'var(--bg-primary)',
|
|
743
|
+
color: 'var(--text-primary)',
|
|
744
|
+
fontSize: 13,
|
|
169
745
|
}}
|
|
170
746
|
/>
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
{
|
|
176
|
-
|
|
177
|
-
|
|
747
|
+
<button
|
|
748
|
+
type="button"
|
|
749
|
+
className="btn btn-ghost btn-sm"
|
|
750
|
+
style={{ fontSize: 11 }}
|
|
751
|
+
onClick={() => expandAllSections(repoSections.map((s) => s.id))}
|
|
752
|
+
>
|
|
753
|
+
Expand all
|
|
754
|
+
</button>
|
|
755
|
+
<button type="button" className="btn btn-ghost btn-sm" style={{ fontSize: 11 }} onClick={collapseAllSections}>
|
|
756
|
+
Collapse all
|
|
757
|
+
</button>
|
|
758
|
+
<span style={{ fontSize: 11, color: 'var(--text-tertiary)' }}>
|
|
759
|
+
{keys.length} shown
|
|
760
|
+
{selected ? ` · editing ${selected.key}` : ''}
|
|
761
|
+
</span>
|
|
762
|
+
</div>
|
|
763
|
+
|
|
764
|
+
{/* Repo chips — click expands that section only */}
|
|
765
|
+
{repoSections.length > 0 && (
|
|
766
|
+
<div
|
|
767
|
+
style={{
|
|
768
|
+
display: 'flex',
|
|
769
|
+
flexWrap: 'wrap',
|
|
770
|
+
gap: 6,
|
|
771
|
+
marginBottom: 8,
|
|
772
|
+
flexShrink: 0,
|
|
773
|
+
maxHeight: 72,
|
|
774
|
+
overflowY: 'auto',
|
|
775
|
+
}}
|
|
776
|
+
>
|
|
777
|
+
{repoSections.map((sec) => {
|
|
778
|
+
const open = expandedSections.has(sec.id)
|
|
779
|
+
return (
|
|
780
|
+
<button
|
|
781
|
+
key={`chip-${sec.id}`}
|
|
782
|
+
type="button"
|
|
783
|
+
className="btn btn-sm"
|
|
784
|
+
style={{
|
|
785
|
+
fontSize: 11,
|
|
786
|
+
padding: '4px 10px',
|
|
787
|
+
background: open
|
|
788
|
+
? sec.kind === 'error'
|
|
789
|
+
? 'rgba(248,113,113,0.2)'
|
|
790
|
+
: 'rgba(59,130,246,0.18)'
|
|
791
|
+
: 'transparent',
|
|
792
|
+
borderColor:
|
|
793
|
+
sec.kind === 'error'
|
|
794
|
+
? 'rgba(248,113,113,0.45)'
|
|
795
|
+
: open
|
|
796
|
+
? 'var(--border-accent)'
|
|
797
|
+
: 'var(--border)',
|
|
798
|
+
color: sec.kind === 'error' ? '#f87171' : undefined,
|
|
799
|
+
}}
|
|
800
|
+
onClick={() => expandOnly(sec.id)}
|
|
801
|
+
title={open ? 'Currently expanded' : 'Expand this repo only'}
|
|
802
|
+
>
|
|
803
|
+
{open ? '▾ ' : '▸ '}
|
|
804
|
+
{sec.label}
|
|
805
|
+
<span style={{ marginLeft: 6, opacity: 0.7 }}>{sec.keys.length}</span>
|
|
806
|
+
</button>
|
|
807
|
+
)
|
|
808
|
+
})}
|
|
809
|
+
</div>
|
|
810
|
+
)}
|
|
811
|
+
|
|
812
|
+
{loading && !catalog ? (
|
|
813
|
+
<p style={{ color: 'var(--text-tertiary)' }}>Loading catalog…</p>
|
|
814
|
+
) : keys.length === 0 ? (
|
|
815
|
+
<div className="card" style={{ padding: 24, textAlign: 'center', color: 'var(--text-secondary)' }}>
|
|
816
|
+
<p style={{ marginBottom: 12 }}>
|
|
817
|
+
No provider secrets in the global store
|
|
818
|
+
{filter ? ` matching “${filter}”` : ''}.
|
|
819
|
+
</p>
|
|
820
|
+
<button type="button" className="btn btn-primary btn-sm" onClick={() => void runScan()}>
|
|
821
|
+
Scan / migrate import
|
|
822
|
+
</button>
|
|
823
|
+
</div>
|
|
824
|
+
) : (
|
|
825
|
+
<div
|
|
826
|
+
className="card"
|
|
827
|
+
style={{
|
|
828
|
+
flex: 1,
|
|
829
|
+
minHeight: 0,
|
|
830
|
+
overflowY: 'auto',
|
|
831
|
+
padding: 0,
|
|
832
|
+
display: 'flex',
|
|
833
|
+
flexDirection: 'column',
|
|
834
|
+
}}
|
|
835
|
+
>
|
|
836
|
+
{repoSections.map((sec) => {
|
|
837
|
+
const open = expandedSections.has(sec.id)
|
|
838
|
+
return (
|
|
839
|
+
<section
|
|
840
|
+
key={sec.id}
|
|
841
|
+
style={{
|
|
842
|
+
borderBottom: '1px solid var(--border)',
|
|
843
|
+
background:
|
|
844
|
+
sec.kind === 'error'
|
|
845
|
+
? 'rgba(248,113,113,0.04)'
|
|
846
|
+
: sec.kind === 'developer'
|
|
847
|
+
? 'rgba(167,139,250,0.04)'
|
|
848
|
+
: undefined,
|
|
849
|
+
}}
|
|
850
|
+
>
|
|
851
|
+
<button
|
|
852
|
+
type="button"
|
|
853
|
+
onClick={() => toggleSection(sec.id)}
|
|
854
|
+
style={{
|
|
855
|
+
width: '100%',
|
|
856
|
+
display: 'flex',
|
|
857
|
+
alignItems: 'center',
|
|
858
|
+
justifyContent: 'space-between',
|
|
859
|
+
gap: 12,
|
|
860
|
+
padding: '10px 14px',
|
|
861
|
+
background: 'transparent',
|
|
862
|
+
border: 'none',
|
|
863
|
+
color: 'inherit',
|
|
864
|
+
cursor: 'pointer',
|
|
865
|
+
textAlign: 'left',
|
|
866
|
+
}}
|
|
867
|
+
>
|
|
868
|
+
<div style={{ minWidth: 0 }}>
|
|
869
|
+
<div
|
|
870
|
+
style={{
|
|
871
|
+
fontSize: 13,
|
|
872
|
+
fontWeight: 700,
|
|
873
|
+
color: sec.kind === 'error' ? '#f87171' : 'var(--text-primary)',
|
|
874
|
+
}}
|
|
875
|
+
>
|
|
876
|
+
{open ? '▾' : '▸'} {sec.label}
|
|
877
|
+
</div>
|
|
878
|
+
<div style={{ fontSize: 10, color: 'var(--text-tertiary)', marginTop: 2 }}>
|
|
879
|
+
{sec.kind === 'error'
|
|
880
|
+
? 'Fix: one repo only, or clear for Developer secrets'
|
|
881
|
+
: sec.kind === 'developer'
|
|
882
|
+
? 'Tooling / unbound'
|
|
883
|
+
: 'Product repo'}
|
|
884
|
+
</div>
|
|
885
|
+
</div>
|
|
886
|
+
<span
|
|
887
|
+
style={{
|
|
888
|
+
fontSize: 11,
|
|
889
|
+
fontWeight: 600,
|
|
890
|
+
flexShrink: 0,
|
|
891
|
+
padding: '2px 8px',
|
|
892
|
+
borderRadius: 99,
|
|
893
|
+
background:
|
|
894
|
+
sec.kind === 'error'
|
|
895
|
+
? 'rgba(248,113,113,0.15)'
|
|
896
|
+
: 'rgba(148,163,184,0.12)',
|
|
897
|
+
color: sec.kind === 'error' ? '#f87171' : 'var(--text-secondary)',
|
|
898
|
+
}}
|
|
899
|
+
>
|
|
900
|
+
{sec.keys.length}
|
|
901
|
+
</span>
|
|
902
|
+
</button>
|
|
903
|
+
{open && (
|
|
904
|
+
<div style={{ padding: '0 8px 8px' }}>
|
|
905
|
+
{sec.keys.map((k) => (
|
|
906
|
+
<SecretKeyRow
|
|
907
|
+
key={`${sec.id}::${k.key}`}
|
|
908
|
+
k={k}
|
|
909
|
+
selected={selected?.key === k.key}
|
|
910
|
+
onOpen={() => openKey(k, sec.id)}
|
|
911
|
+
/>
|
|
912
|
+
))}
|
|
913
|
+
</div>
|
|
914
|
+
)}
|
|
915
|
+
</section>
|
|
916
|
+
)
|
|
917
|
+
})}
|
|
918
|
+
</div>
|
|
919
|
+
)}
|
|
920
|
+
|
|
921
|
+
{/* Fixed viewport drawer — no scroll-to-top */}
|
|
922
|
+
{selected && (
|
|
923
|
+
<div
|
|
924
|
+
role="dialog"
|
|
925
|
+
aria-label={`Edit ${selected.key}`}
|
|
926
|
+
style={{
|
|
927
|
+
position: 'fixed',
|
|
928
|
+
top: 0,
|
|
929
|
+
right: 0,
|
|
930
|
+
bottom: 0,
|
|
931
|
+
width: 'min(420px, 100vw)',
|
|
932
|
+
zIndex: 80,
|
|
933
|
+
background: 'var(--bg-secondary, #0f1419)',
|
|
934
|
+
borderLeft: '1px solid var(--border)',
|
|
935
|
+
boxShadow: '-12px 0 40px rgba(0,0,0,0.35)',
|
|
936
|
+
display: 'flex',
|
|
937
|
+
flexDirection: 'column',
|
|
938
|
+
overflow: 'hidden',
|
|
939
|
+
}}
|
|
940
|
+
>
|
|
941
|
+
<div
|
|
942
|
+
style={{
|
|
943
|
+
flexShrink: 0,
|
|
944
|
+
padding: '14px 16px',
|
|
945
|
+
borderBottom: '1px solid var(--border)',
|
|
946
|
+
display: 'flex',
|
|
947
|
+
justifyContent: 'space-between',
|
|
948
|
+
alignItems: 'flex-start',
|
|
949
|
+
gap: 10,
|
|
950
|
+
background: 'rgba(0,0,0,0.2)',
|
|
951
|
+
}}
|
|
952
|
+
>
|
|
953
|
+
<div style={{ minWidth: 0 }}>
|
|
954
|
+
<div style={{ fontSize: 10, color: 'var(--text-tertiary)', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
|
|
955
|
+
Edit secret
|
|
956
|
+
</div>
|
|
957
|
+
<strong
|
|
958
|
+
style={{
|
|
959
|
+
fontFamily: 'ui-monospace, monospace',
|
|
960
|
+
fontSize: 13,
|
|
961
|
+
wordBreak: 'break-all',
|
|
962
|
+
display: 'block',
|
|
963
|
+
marginTop: 2,
|
|
964
|
+
}}
|
|
965
|
+
>
|
|
966
|
+
{selected.key}
|
|
967
|
+
</strong>
|
|
968
|
+
<div style={{ fontSize: 11, color: 'var(--text-tertiary)', marginTop: 4 }}>
|
|
969
|
+
{selected.masked || '••••'}
|
|
970
|
+
{selected.subscription_tier ? ` · ${selected.subscription_tier}` : ''}
|
|
971
|
+
{selected.monthly_cost_usd != null ? ` · $${selected.monthly_cost_usd}/mo` : ''}
|
|
972
|
+
</div>
|
|
973
|
+
</div>
|
|
974
|
+
<button type="button" className="btn btn-ghost btn-sm" onClick={() => setSelected(null)}>
|
|
975
|
+
✕
|
|
976
|
+
</button>
|
|
977
|
+
</div>
|
|
978
|
+
<div style={{ flex: 1, overflowY: 'auto', padding: 16 }}>
|
|
979
|
+
|
|
980
|
+
<PanelSection
|
|
981
|
+
title="Reveal value (passkey)"
|
|
982
|
+
hint="Full secret values require step-up auth — passkey preferred, password fallback"
|
|
983
|
+
>
|
|
984
|
+
<div style={{ fontSize: 11, color: 'var(--text-tertiary)', marginBottom: 8 }}>
|
|
985
|
+
Passkeys: {webauthn?.count ?? 0}
|
|
986
|
+
{!browserSupportsWebAuthn() && ' · browser unsupported'}
|
|
987
|
+
</div>
|
|
988
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 10 }}>
|
|
989
|
+
<button
|
|
990
|
+
type="button"
|
|
991
|
+
className="btn btn-ghost btn-sm"
|
|
992
|
+
disabled={revealBusy || !browserSupportsWebAuthn()}
|
|
993
|
+
onClick={() => void registerPasskey()}
|
|
994
|
+
>
|
|
995
|
+
{webauthn?.has_passkeys ? 'Add another passkey' : 'Register passkey (Touch ID)'}
|
|
996
|
+
</button>
|
|
997
|
+
<button
|
|
998
|
+
type="button"
|
|
999
|
+
className="btn btn-primary btn-sm"
|
|
1000
|
+
disabled={revealBusy || !webauthn?.has_passkeys}
|
|
1001
|
+
onClick={() => void doReveal('passkey')}
|
|
1002
|
+
>
|
|
1003
|
+
Reveal with passkey
|
|
1004
|
+
</button>
|
|
1005
|
+
<div style={{ display: 'flex', gap: 6 }}>
|
|
1006
|
+
<input
|
|
1007
|
+
type="password"
|
|
1008
|
+
value={revealPassword}
|
|
1009
|
+
onChange={(e) => setRevealPassword(e.target.value)}
|
|
1010
|
+
placeholder="Or re-enter dashboard password"
|
|
1011
|
+
style={{ ...inputStyle, flex: 1, marginTop: 0 }}
|
|
1012
|
+
onKeyDown={(e) => {
|
|
1013
|
+
if (e.key === 'Enter') void doReveal('password')
|
|
1014
|
+
}}
|
|
1015
|
+
/>
|
|
1016
|
+
<button
|
|
1017
|
+
type="button"
|
|
1018
|
+
className="btn btn-ghost btn-sm"
|
|
1019
|
+
disabled={revealBusy || !revealPassword}
|
|
1020
|
+
onClick={() => void doReveal('password')}
|
|
1021
|
+
>
|
|
1022
|
+
Reveal
|
|
1023
|
+
</button>
|
|
1024
|
+
</div>
|
|
1025
|
+
</div>
|
|
1026
|
+
{revealedValue != null && (
|
|
1027
|
+
<div
|
|
1028
|
+
style={{
|
|
1029
|
+
padding: 10,
|
|
1030
|
+
borderRadius: 8,
|
|
1031
|
+
background: 'rgba(52,211,153,0.08)',
|
|
1032
|
+
border: '1px solid rgba(52,211,153,0.35)',
|
|
1033
|
+
marginBottom: 8,
|
|
1034
|
+
}}
|
|
1035
|
+
>
|
|
1036
|
+
<div style={{ fontSize: 10, color: 'var(--text-tertiary)', marginBottom: 4 }}>
|
|
1037
|
+
PLAINTEXT (hides in 30s) — never paste into chat/vault
|
|
1038
|
+
</div>
|
|
1039
|
+
<code
|
|
1040
|
+
style={{
|
|
1041
|
+
fontSize: 11,
|
|
1042
|
+
wordBreak: 'break-all',
|
|
1043
|
+
display: 'block',
|
|
1044
|
+
color: 'var(--text-primary)',
|
|
1045
|
+
}}
|
|
1046
|
+
>
|
|
1047
|
+
{revealedValue}
|
|
1048
|
+
</code>
|
|
1049
|
+
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
|
|
1050
|
+
<button type="button" className="btn btn-primary btn-sm" onClick={() => void doCopyRevealed()}>
|
|
1051
|
+
Copy
|
|
1052
|
+
</button>
|
|
1053
|
+
<button
|
|
1054
|
+
type="button"
|
|
1055
|
+
className="btn btn-ghost btn-sm"
|
|
1056
|
+
onClick={() => setRevealedValue(null)}
|
|
1057
|
+
>
|
|
1058
|
+
Hide now
|
|
1059
|
+
</button>
|
|
1060
|
+
</div>
|
|
1061
|
+
</div>
|
|
1062
|
+
)}
|
|
1063
|
+
</PanelSection>
|
|
1064
|
+
|
|
1065
|
+
{selected.schema && (
|
|
1066
|
+
<div style={{ fontSize: 11, marginBottom: 12, padding: 10, borderRadius: 8, background: 'rgba(59,130,246,0.08)', border: '1px solid var(--border-accent)' }}>
|
|
1067
|
+
<div style={{ fontWeight: 700, marginBottom: 4 }}>Schema</div>
|
|
1068
|
+
<div>auth: {selected.schema.auth}</div>
|
|
1069
|
+
{selected.schema.header && <div style={{ fontFamily: 'monospace' }}>{selected.schema.header}</div>}
|
|
1070
|
+
{selected.schema_notes && <div style={{ marginTop: 4, color: 'var(--text-tertiary)' }}>{selected.schema_notes}</div>}
|
|
1071
|
+
</div>
|
|
1072
|
+
)}
|
|
1073
|
+
|
|
1074
|
+
<PanelSection title="Identity">
|
|
1075
|
+
<Field label="Label" value={edit.label} onChange={(v) => setEdit({ ...edit, label: v })} />
|
|
1076
|
+
<Field label="Provider" value={edit.provider} onChange={(v) => setEdit({ ...edit, provider: v })} />
|
|
1077
|
+
</PanelSection>
|
|
1078
|
+
|
|
1079
|
+
<PanelSection title="Binding (one repo max)">
|
|
1080
|
+
<Field
|
|
1081
|
+
label="Product repo (exactly one name, or empty = Developer secrets)"
|
|
1082
|
+
value={edit.repos}
|
|
1083
|
+
onChange={(v) => setEdit({ ...edit, repos: v })}
|
|
1084
|
+
/>
|
|
1085
|
+
{edit.repos.split(/[,]+/).map((s) => s.trim()).filter(Boolean).length > 1 && (
|
|
1086
|
+
<div style={{ fontSize: 11, color: '#f87171', marginBottom: 10 }}>
|
|
1087
|
+
Error: only one repo allowed. Remove extras before save.
|
|
1088
|
+
</div>
|
|
1089
|
+
)}
|
|
1090
|
+
{selected.multi_repo_error && selected.binding_error && (
|
|
1091
|
+
<div style={{ fontSize: 11, color: '#f87171', marginBottom: 10 }}>
|
|
1092
|
+
{selected.binding_error}
|
|
1093
|
+
</div>
|
|
1094
|
+
)}
|
|
1095
|
+
<Field label="Project path (optional)" value={edit.project_path} onChange={(v) => setEdit({ ...edit, project_path: v })} />
|
|
1096
|
+
</PanelSection>
|
|
1097
|
+
|
|
1098
|
+
<PanelSection
|
|
1099
|
+
title="Subscription & cost"
|
|
1100
|
+
hint="Plan level + what you pay monthly for this key/provider"
|
|
1101
|
+
>
|
|
1102
|
+
<Field
|
|
1103
|
+
label="Subscription level / tier"
|
|
1104
|
+
value={edit.subscription_tier}
|
|
1105
|
+
onChange={(v) => setEdit({ ...edit, subscription_tier: v })}
|
|
1106
|
+
placeholder="e.g. free, pro, team, enterprise, payg"
|
|
1107
|
+
/>
|
|
1108
|
+
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginBottom: 10 }}>
|
|
1109
|
+
{(selected.tiers?.length
|
|
1110
|
+
? selected.tiers
|
|
1111
|
+
: DEFAULT_TIERS
|
|
1112
|
+
).map((t) => (
|
|
1113
|
+
<button
|
|
1114
|
+
key={t.id}
|
|
1115
|
+
type="button"
|
|
1116
|
+
className="btn btn-ghost btn-sm"
|
|
1117
|
+
style={{
|
|
1118
|
+
fontSize: 10,
|
|
1119
|
+
padding: '2px 8px',
|
|
1120
|
+
borderColor:
|
|
1121
|
+
edit.subscription_tier === t.id ? 'var(--border-accent)' : undefined,
|
|
1122
|
+
background:
|
|
1123
|
+
edit.subscription_tier === t.id ? 'rgba(59,130,246,0.15)' : undefined,
|
|
1124
|
+
}}
|
|
1125
|
+
onClick={() =>
|
|
1126
|
+
setEdit({
|
|
1127
|
+
...edit,
|
|
1128
|
+
subscription_tier: t.id,
|
|
1129
|
+
monthly_cost_usd:
|
|
1130
|
+
t.monthly_usd != null ? String(t.monthly_usd) : edit.monthly_cost_usd,
|
|
1131
|
+
})
|
|
1132
|
+
}
|
|
1133
|
+
>
|
|
1134
|
+
{t.label}
|
|
1135
|
+
{t.monthly_usd != null ? ` $${t.monthly_usd}` : ''}
|
|
1136
|
+
</button>
|
|
1137
|
+
))}
|
|
1138
|
+
</div>
|
|
1139
|
+
<div
|
|
178
1140
|
style={{
|
|
179
|
-
display: '
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
1141
|
+
display: 'grid',
|
|
1142
|
+
gridTemplateColumns: '1fr 1fr',
|
|
1143
|
+
gap: 10,
|
|
1144
|
+
marginBottom: 4,
|
|
183
1145
|
}}
|
|
184
1146
|
>
|
|
1147
|
+
<Field
|
|
1148
|
+
label="Monthly cost $"
|
|
1149
|
+
value={edit.monthly_cost_usd}
|
|
1150
|
+
onChange={(v) => setEdit({ ...edit, monthly_cost_usd: v })}
|
|
1151
|
+
placeholder="20"
|
|
1152
|
+
/>
|
|
1153
|
+
<Field
|
|
1154
|
+
label="Monthly cap $ (budget)"
|
|
1155
|
+
value={edit.monthly_cap_usd}
|
|
1156
|
+
onChange={(v) => setEdit({ ...edit, monthly_cap_usd: v })}
|
|
1157
|
+
placeholder="100"
|
|
1158
|
+
/>
|
|
1159
|
+
</div>
|
|
1160
|
+
{(edit.subscription_tier || edit.monthly_cost_usd) && (
|
|
1161
|
+
<div
|
|
1162
|
+
style={{
|
|
1163
|
+
fontSize: 12,
|
|
1164
|
+
marginBottom: 10,
|
|
1165
|
+
padding: '8px 10px',
|
|
1166
|
+
borderRadius: 8,
|
|
1167
|
+
background: 'rgba(251,191,36,0.08)',
|
|
1168
|
+
border: '1px solid rgba(251,191,36,0.25)',
|
|
1169
|
+
color: 'var(--text-secondary)',
|
|
1170
|
+
}}
|
|
1171
|
+
>
|
|
1172
|
+
Plan preview:{' '}
|
|
1173
|
+
<strong style={{ color: 'var(--text-primary)' }}>
|
|
1174
|
+
{edit.subscription_tier || '—'}
|
|
1175
|
+
</strong>
|
|
1176
|
+
{edit.monthly_cost_usd
|
|
1177
|
+
? ` · $${edit.monthly_cost_usd}/mo`
|
|
1178
|
+
: ' · no monthly cost set'}
|
|
1179
|
+
{edit.monthly_cap_usd ? ` · cap $${edit.monthly_cap_usd}` : ''}
|
|
1180
|
+
</div>
|
|
1181
|
+
)}
|
|
1182
|
+
</PanelSection>
|
|
1183
|
+
|
|
1184
|
+
<PanelSection title="Docs & rotation">
|
|
1185
|
+
<Field label="API docs URL" value={edit.api_docs_url} onChange={(v) => setEdit({ ...edit, api_docs_url: v })} />
|
|
1186
|
+
<Field label="Rotate every (days)" value={edit.rotate_every_days} onChange={(v) => setEdit({ ...edit, rotate_every_days: v })} />
|
|
1187
|
+
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12, marginBottom: 10 }}>
|
|
185
1188
|
<input
|
|
186
1189
|
type="checkbox"
|
|
187
|
-
checked={
|
|
188
|
-
onChange={() =>
|
|
189
|
-
style={{ margin: 0 }}
|
|
1190
|
+
checked={!!edit.auto_rotate}
|
|
1191
|
+
onChange={(e) => setEdit({ ...edit, auto_rotate: e.target.checked })}
|
|
190
1192
|
/>
|
|
191
|
-
|
|
1193
|
+
Auto-rotate when due (flag — use CLI/agent to supply new value)
|
|
192
1194
|
</label>
|
|
193
|
-
|
|
1195
|
+
<Field label="Notes" value={edit.notes} onChange={(v) => setEdit({ ...edit, notes: v })} />
|
|
1196
|
+
</PanelSection>
|
|
1197
|
+
|
|
1198
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginTop: 8 }}>
|
|
1199
|
+
<button type="button" className="btn btn-primary" disabled={saving} onClick={() => void saveMeta()}>
|
|
1200
|
+
Save metadata
|
|
1201
|
+
</button>
|
|
1202
|
+
<button type="button" className="btn btn-ghost btn-sm" onClick={() => void sampleUsage()}>
|
|
1203
|
+
Record sample usage $0.01
|
|
1204
|
+
</button>
|
|
1205
|
+
</div>
|
|
1206
|
+
|
|
1207
|
+
<div style={{ marginTop: 16, borderTop: '1px solid var(--border)', paddingTop: 12 }}>
|
|
1208
|
+
<div style={{ fontSize: 11, fontWeight: 700, marginBottom: 8 }}>Rotate value</div>
|
|
1209
|
+
<input
|
|
1210
|
+
type="password"
|
|
1211
|
+
value={rotateVal}
|
|
1212
|
+
onChange={(e) => setRotateVal(e.target.value)}
|
|
1213
|
+
placeholder="New secret value"
|
|
1214
|
+
style={inputStyle}
|
|
1215
|
+
/>
|
|
1216
|
+
<button
|
|
1217
|
+
type="button"
|
|
1218
|
+
className="btn btn-primary btn-sm"
|
|
1219
|
+
style={{ marginTop: 8, width: '100%' }}
|
|
1220
|
+
disabled={!rotateVal || saving}
|
|
1221
|
+
onClick={() => void doRotate()}
|
|
1222
|
+
>
|
|
1223
|
+
Rotate + export .env
|
|
1224
|
+
</button>
|
|
1225
|
+
<p style={{ fontSize: 10, color: 'var(--text-tertiary)', marginTop: 8 }}>
|
|
1226
|
+
Browser assist CLI: <code>npx total-recall secret rotate-browser {selected.key}</code>
|
|
1227
|
+
</p>
|
|
1228
|
+
{selected.next_rotate_due && (
|
|
1229
|
+
<div style={{ fontSize: 11, color: selected.rotation_overdue ? '#f87171' : 'var(--text-tertiary)', marginTop: 6 }}>
|
|
1230
|
+
Next due: {new Date(selected.next_rotate_due).toLocaleDateString()}
|
|
1231
|
+
{selected.rotation_overdue ? ' — overdue' : ''}
|
|
1232
|
+
</div>
|
|
1233
|
+
)}
|
|
1234
|
+
</div>
|
|
1235
|
+
|
|
1236
|
+
<div style={{ marginTop: 12, fontSize: 11, color: 'var(--text-tertiary)' }}>
|
|
1237
|
+
30d usage: {selected.usage_30d?.events || 0} events · $
|
|
1238
|
+
{(selected.usage_30d?.cost_usd || 0).toFixed(4)}
|
|
1239
|
+
</div>
|
|
1240
|
+
|
|
1241
|
+
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
|
|
1242
|
+
{selected.console_url && (
|
|
1243
|
+
<a href={selected.console_url} target="_blank" rel="noreferrer" className="btn btn-ghost btn-sm">
|
|
1244
|
+
Console
|
|
1245
|
+
</a>
|
|
1246
|
+
)}
|
|
1247
|
+
{selected.pricing_url && (
|
|
1248
|
+
<a href={selected.pricing_url} target="_blank" rel="noreferrer" className="btn btn-ghost btn-sm">
|
|
1249
|
+
Pricing
|
|
1250
|
+
</a>
|
|
1251
|
+
)}
|
|
1252
|
+
<button type="button" className="btn btn-ghost btn-sm" style={{ color: '#f87171', marginLeft: 'auto' }} onClick={() => void doDelete()}>
|
|
1253
|
+
Delete
|
|
1254
|
+
</button>
|
|
1255
|
+
</div>
|
|
1256
|
+
|
|
1257
|
+
<div style={{ marginTop: 12, fontSize: 10, color: 'var(--text-tertiary)', fontFamily: 'monospace' }}>
|
|
1258
|
+
CLI: secret meta {selected.key} --repo my-app --tier pro --monthly-cost 25 --rotate-days 90
|
|
1259
|
+
</div>
|
|
1260
|
+
</div>
|
|
194
1261
|
</div>
|
|
1262
|
+
)}
|
|
1263
|
+
</div>
|
|
1264
|
+
)}
|
|
1265
|
+
|
|
1266
|
+
{tab === 'import' && (
|
|
1267
|
+
<div className="card" style={{ padding: 20 }}>
|
|
1268
|
+
<h3 style={{ marginBottom: 8 }}>One-time migrate (import-env)</h3>
|
|
1269
|
+
<p style={{ fontSize: 12, color: 'var(--text-secondary)', marginBottom: 12, lineHeight: 1.45 }}>
|
|
1270
|
+
Pull legacy keys from local <code>.env</code> files into the TR store. Steady-state path is the opposite:{' '}
|
|
1271
|
+
<strong>export-env</strong> writes projections out from the store.
|
|
1272
|
+
</p>
|
|
1273
|
+
<button type="button" className="btn btn-ghost btn-sm" onClick={() => void runScan()} style={{ marginBottom: 12 }}>
|
|
1274
|
+
Re-scan
|
|
1275
|
+
</button>
|
|
1276
|
+
{scanCandidates.length === 0 ? (
|
|
1277
|
+
<p style={{ color: 'var(--text-tertiary)', fontSize: 13 }}>No candidates — click Re-scan or use onboarding paste.</p>
|
|
1278
|
+
) : (
|
|
1279
|
+
<>
|
|
1280
|
+
{scanCandidates.map((c) => (
|
|
1281
|
+
<label key={c.key} style={{ display: 'flex', gap: 10, padding: '8px 0', borderBottom: '1px solid var(--border)', opacity: c.already_set ? 0.5 : 1 }}>
|
|
1282
|
+
<input
|
|
1283
|
+
type="checkbox"
|
|
1284
|
+
disabled={!!c.already_set}
|
|
1285
|
+
checked={importSel.has(c.key)}
|
|
1286
|
+
onChange={() => {
|
|
1287
|
+
setImportSel((prev) => {
|
|
1288
|
+
const n = new Set(prev)
|
|
1289
|
+
if (n.has(c.key)) n.delete(c.key)
|
|
1290
|
+
else n.add(c.key)
|
|
1291
|
+
return n
|
|
1292
|
+
})
|
|
1293
|
+
}}
|
|
1294
|
+
/>
|
|
1295
|
+
<span style={{ fontFamily: 'monospace', fontSize: 12 }}>{c.key}</span>
|
|
1296
|
+
<span style={{ fontSize: 11, color: 'var(--text-tertiary)' }}>{c.masked} · {c.source_label}</span>
|
|
1297
|
+
</label>
|
|
1298
|
+
))}
|
|
1299
|
+
<button type="button" className="btn btn-primary" style={{ marginTop: 14 }} onClick={() => void runImport()}>
|
|
1300
|
+
Import {importSel.size} key(s)
|
|
1301
|
+
</button>
|
|
1302
|
+
</>
|
|
1303
|
+
)}
|
|
1304
|
+
</div>
|
|
1305
|
+
)}
|
|
1306
|
+
|
|
1307
|
+
{tab === 'pats' && (
|
|
1308
|
+
<div>
|
|
1309
|
+
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: 16 }}>
|
|
1310
|
+
<strong>Original Total Recall feature:</strong> issue Personal Access Tokens that authenticate to{' '}
|
|
1311
|
+
<code>this</code> brain (<code>Authorization: Bearer …</code> on <code>/api/*</code>). These are{' '}
|
|
1312
|
+
<em>not</em> OpenAI/Anthropic keys — they only unlock your TR API.
|
|
1313
|
+
</p>
|
|
1314
|
+
<div className="card" style={{ padding: 16, marginBottom: 16, display: 'flex', gap: 10, flexWrap: 'wrap' }}>
|
|
1315
|
+
<input
|
|
1316
|
+
value={newKeyName}
|
|
1317
|
+
onChange={(e) => setNewKeyName(e.target.value)}
|
|
1318
|
+
placeholder="PAT name"
|
|
1319
|
+
style={{ ...inputStyle, flex: 1, minWidth: 160 }}
|
|
1320
|
+
/>
|
|
1321
|
+
<button
|
|
1322
|
+
type="button"
|
|
1323
|
+
className="btn btn-primary"
|
|
1324
|
+
disabled={!newKeyName.trim()}
|
|
1325
|
+
onClick={async () => {
|
|
1326
|
+
try {
|
|
1327
|
+
const k = await issueApiKey(newKeyName.trim(), ['*'])
|
|
1328
|
+
setNewlyIssued(k)
|
|
1329
|
+
setNewKeyName('')
|
|
1330
|
+
await loadPats()
|
|
1331
|
+
} catch (e: unknown) {
|
|
1332
|
+
setError((e as Error).message)
|
|
1333
|
+
}
|
|
1334
|
+
}}
|
|
1335
|
+
>
|
|
1336
|
+
Issue PAT
|
|
1337
|
+
</button>
|
|
195
1338
|
</div>
|
|
1339
|
+
{newlyIssued && (
|
|
1340
|
+
<div className="card" style={{ padding: 16, marginBottom: 16, borderColor: 'rgba(52,211,153,0.4)' }}>
|
|
1341
|
+
<strong>Copy now — shown once</strong>
|
|
1342
|
+
<div style={{ fontFamily: 'monospace', fontSize: 12, marginTop: 8, wordBreak: 'break-all' }}>{newlyIssued.token}</div>
|
|
1343
|
+
<button
|
|
1344
|
+
type="button"
|
|
1345
|
+
className="btn btn-sm btn-primary"
|
|
1346
|
+
style={{ marginTop: 8 }}
|
|
1347
|
+
onClick={() => {
|
|
1348
|
+
navigator.clipboard.writeText(newlyIssued.token)
|
|
1349
|
+
setCopied(true)
|
|
1350
|
+
setTimeout(() => setCopied(false), 2000)
|
|
1351
|
+
}}
|
|
1352
|
+
>
|
|
1353
|
+
{copied ? 'Copied' : 'Copy'}
|
|
1354
|
+
</button>
|
|
1355
|
+
</div>
|
|
1356
|
+
)}
|
|
1357
|
+
{pats.filter((k) => !k.revoked).map((k) => (
|
|
1358
|
+
<div key={k.id} className="card" style={{ padding: 12, marginBottom: 8, display: 'flex', justifyContent: 'space-between' }}>
|
|
1359
|
+
<div>
|
|
1360
|
+
<div style={{ fontWeight: 600 }}>{k.name}</div>
|
|
1361
|
+
<div style={{ fontSize: 11, color: 'var(--text-tertiary)' }}>{k.token_preview}… · {k.scopes?.join(', ')}</div>
|
|
1362
|
+
</div>
|
|
1363
|
+
<button type="button" className="btn btn-ghost btn-sm" style={{ color: '#f87171' }} onClick={() => void revokeApiKey(k.id).then(loadPats)}>
|
|
1364
|
+
Revoke
|
|
1365
|
+
</button>
|
|
1366
|
+
</div>
|
|
1367
|
+
))}
|
|
196
1368
|
</div>
|
|
197
|
-
|
|
1369
|
+
)}
|
|
198
1370
|
|
|
199
|
-
{
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
<div style={{ color: 'var(--text-tertiary)', padding: 20 }}>Loading…</div>
|
|
206
|
-
) : activeKeys.length === 0 ? (
|
|
207
|
-
<div style={{
|
|
208
|
-
textAlign: 'center', padding: '40px 20px', color: 'var(--text-tertiary)',
|
|
209
|
-
border: '1px dashed var(--border)', borderRadius: 12,
|
|
210
|
-
}}>
|
|
211
|
-
No active keys yet. Issue one above to connect Total Recall to any tool.
|
|
1371
|
+
{tab === 'cloud' && (
|
|
1372
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
|
1373
|
+
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 8 }}>
|
|
1374
|
+
<button onClick={handleSaveConfig} disabled={saving} className="btn btn-primary" style={{ minWidth: 120 }}>
|
|
1375
|
+
{saving ? 'Saving...' : 'Save Configuration'}
|
|
1376
|
+
</button>
|
|
212
1377
|
</div>
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
<
|
|
223
|
-
<
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
<
|
|
229
|
-
{
|
|
230
|
-
<
|
|
231
|
-
|
|
232
|
-
<
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
1378
|
+
{configSuccess && (
|
|
1379
|
+
<div className="badge badge-success" style={{ padding: '6px 12px', background: 'rgba(16, 185, 129, 0.1)', border: '1px solid #10b981', color: '#34d399' }}>
|
|
1380
|
+
✓ {configSuccess}
|
|
1381
|
+
</div>
|
|
1382
|
+
)}
|
|
1383
|
+
<div className="card" style={{ padding: 24, background: 'rgba(18, 18, 26, 0.6)', backdropFilter: 'blur(8px)', border: '1px solid var(--border)', display: 'flex', flexDirection: 'column', gap: 16 }}>
|
|
1384
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 10, borderBottom: '1px solid var(--border)', paddingBottom: 12 }}>
|
|
1385
|
+
<span style={{ fontSize: 20 }}>🔑</span>
|
|
1386
|
+
<div>
|
|
1387
|
+
<h3 style={{ fontSize: 14, fontWeight: 600 }}>Cloud & Search APIs</h3>
|
|
1388
|
+
<p style={{ fontSize: 11, color: 'var(--text-tertiary)' }}>Bring your own cloud models</p>
|
|
1389
|
+
</div>
|
|
1390
|
+
</div>
|
|
1391
|
+
|
|
1392
|
+
{/* Google Gemini API Key */}
|
|
1393
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, paddingBottom: 16, borderBottom: '1px solid rgba(255,255,255,0.05)' }}>
|
|
1394
|
+
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
|
1395
|
+
<label htmlFor="google_api_key" style={{ fontSize: 13, fontWeight: 500 }}>Google Gemini API Key</label>
|
|
1396
|
+
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, cursor: 'pointer', color: configData?.brain?.preferred_agent === 'gemini' ? 'var(--accent)' : 'var(--text-tertiary)' }}>
|
|
1397
|
+
<input
|
|
1398
|
+
type="radio"
|
|
1399
|
+
name="preferred_agent"
|
|
1400
|
+
checked={configData?.brain?.preferred_agent === 'gemini'}
|
|
1401
|
+
onChange={() => updateBrainProp('preferred_agent', 'gemini')}
|
|
1402
|
+
/>
|
|
1403
|
+
Set Active
|
|
1404
|
+
</label>
|
|
1405
|
+
</div>
|
|
1406
|
+
<form onSubmit={e => e.preventDefault()} style={{display:'inline',margin:0,padding:0,width:'100%'}}><input id="google_api_key"
|
|
1407
|
+
type="password"
|
|
1408
|
+
placeholder="AIzaSy..."
|
|
1409
|
+
value={configData?.secrets?.google_api_key || ''}
|
|
1410
|
+
onChange={(e) => updateSecretsProp('google_api_key', e.target.value)}
|
|
1411
|
+
style={{
|
|
1412
|
+
background: 'var(--bg-tertiary)',
|
|
1413
|
+
color: 'var(--text-primary)',
|
|
1414
|
+
border: '1px solid var(--border)',
|
|
1415
|
+
padding: '8px 12px',
|
|
1416
|
+
borderRadius: 6,
|
|
1417
|
+
outline: 'none',
|
|
1418
|
+
fontSize: 13
|
|
1419
|
+
}}
|
|
1420
|
+
/></form>
|
|
1421
|
+
|
|
1422
|
+
<select
|
|
1423
|
+
disabled={!configData?.secrets?.google_api_key}
|
|
1424
|
+
value={configData?.brain?.gemini_model || ''}
|
|
1425
|
+
onChange={(e) => updateBrainProp('gemini_model', e.target.value)}
|
|
1426
|
+
style={{
|
|
1427
|
+
background: 'var(--bg-tertiary)',
|
|
1428
|
+
color: 'var(--text-primary)',
|
|
1429
|
+
border: '1px solid var(--border)',
|
|
1430
|
+
padding: '8px 12px',
|
|
1431
|
+
borderRadius: 6,
|
|
1432
|
+
outline: 'none',
|
|
1433
|
+
fontSize: 13,
|
|
1434
|
+
marginTop: 4
|
|
1435
|
+
}}
|
|
1436
|
+
>
|
|
1437
|
+
<option value="">Default Gemini Model</option>
|
|
1438
|
+
{geminiModels.map(m => {
|
|
1439
|
+
let costStr = '';
|
|
1440
|
+
if (m.pricing && m.pricing.prompt && m.pricing.completion) {
|
|
1441
|
+
const promptCost = (parseFloat(m.pricing.prompt as string) * 1000000).toFixed(2);
|
|
1442
|
+
const compCost = (parseFloat(m.pricing.completion as string) * 1000000).toFixed(2);
|
|
1443
|
+
costStr = ` - ${promptCost}/${compCost} per 1M`;
|
|
1444
|
+
}
|
|
1445
|
+
return (
|
|
1446
|
+
<option key={m.id} value={m.id}>{m.displayName} ({m.id}){costStr}</option>
|
|
1447
|
+
);
|
|
1448
|
+
})}
|
|
1449
|
+
</select>
|
|
1450
|
+
{!configData?.secrets?.google_api_key && (
|
|
1451
|
+
<div style={{ fontSize: 11, color: 'var(--text-error, #f44336)', marginTop: 8 }}>
|
|
1452
|
+
⚠ API Key required to unlock model selection
|
|
1453
|
+
</div>
|
|
1454
|
+
)}
|
|
1455
|
+
</div>
|
|
1456
|
+
|
|
1457
|
+
{/* Anthropic API Key */}
|
|
1458
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, paddingBottom: 16, borderBottom: '1px solid rgba(255,255,255,0.05)' }}>
|
|
1459
|
+
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
|
1460
|
+
<label htmlFor="anthropic_api_key" style={{ fontSize: 13, fontWeight: 500 }}>Anthropic API Key (Claude)</label>
|
|
1461
|
+
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, cursor: 'pointer', color: configData?.brain?.preferred_agent === 'claude' ? 'var(--accent)' : 'var(--text-tertiary)' }}>
|
|
1462
|
+
<input
|
|
1463
|
+
type="radio"
|
|
1464
|
+
name="preferred_agent"
|
|
1465
|
+
checked={configData?.brain?.preferred_agent === 'claude'}
|
|
1466
|
+
onChange={() => updateBrainProp('preferred_agent', 'claude')}
|
|
1467
|
+
/>
|
|
1468
|
+
Set Active
|
|
1469
|
+
</label>
|
|
1470
|
+
</div>
|
|
1471
|
+
<form onSubmit={e => e.preventDefault()} style={{display:'inline',margin:0,padding:0,width:'100%'}}><input id="anthropic_api_key"
|
|
1472
|
+
type="password"
|
|
1473
|
+
placeholder="sk-ant-..."
|
|
1474
|
+
value={configData?.secrets?.anthropic_api_key || ''}
|
|
1475
|
+
onChange={(e) => updateSecretsProp('anthropic_api_key', e.target.value)}
|
|
1476
|
+
style={{
|
|
1477
|
+
background: 'var(--bg-tertiary)',
|
|
1478
|
+
color: 'var(--text-primary)',
|
|
1479
|
+
border: '1px solid var(--border)',
|
|
1480
|
+
padding: '8px 12px',
|
|
1481
|
+
borderRadius: 6,
|
|
1482
|
+
outline: 'none',
|
|
1483
|
+
fontSize: 13
|
|
1484
|
+
}}
|
|
1485
|
+
/></form>
|
|
1486
|
+
|
|
1487
|
+
<select
|
|
1488
|
+
disabled={!configData?.secrets?.anthropic_api_key}
|
|
1489
|
+
value={configData?.brain?.claude_model || ''}
|
|
1490
|
+
onChange={(e) => updateBrainProp('claude_model', e.target.value)}
|
|
1491
|
+
style={{
|
|
1492
|
+
background: 'var(--bg-tertiary)',
|
|
1493
|
+
color: 'var(--text-primary)',
|
|
1494
|
+
border: '1px solid var(--border)',
|
|
1495
|
+
padding: '8px 12px',
|
|
1496
|
+
borderRadius: 6,
|
|
1497
|
+
outline: 'none',
|
|
1498
|
+
fontSize: 13,
|
|
1499
|
+
marginTop: 4
|
|
1500
|
+
}}
|
|
1501
|
+
>
|
|
1502
|
+
<option value="">Default Claude Model</option>
|
|
1503
|
+
{claudeModels.map(m => {
|
|
1504
|
+
let costStr = '';
|
|
1505
|
+
if (m.pricing && m.pricing.prompt && m.pricing.completion) {
|
|
1506
|
+
const promptCost = (parseFloat(m.pricing.prompt as string) * 1000000).toFixed(2);
|
|
1507
|
+
const compCost = (parseFloat(m.pricing.completion as string) * 1000000).toFixed(2);
|
|
1508
|
+
costStr = ` - ${promptCost}/${compCost} per 1M`;
|
|
1509
|
+
}
|
|
1510
|
+
return (
|
|
1511
|
+
<option key={m.id} value={m.id}>{m.displayName} ({m.id}){costStr}</option>
|
|
1512
|
+
);
|
|
1513
|
+
})}
|
|
1514
|
+
</select>
|
|
1515
|
+
{!configData?.secrets?.anthropic_api_key && (
|
|
1516
|
+
<div style={{ fontSize: 11, color: 'var(--text-error, #f44336)', marginTop: 8 }}>
|
|
1517
|
+
⚠ API Key required to unlock model selection
|
|
1518
|
+
</div>
|
|
1519
|
+
)}
|
|
1520
|
+
</div>
|
|
1521
|
+
|
|
1522
|
+
{/* OpenAI API Key */}
|
|
1523
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, paddingBottom: 16, borderBottom: '1px solid rgba(255,255,255,0.05)' }}>
|
|
1524
|
+
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
|
1525
|
+
<label htmlFor="openai_api_key" style={{ fontSize: 13, fontWeight: 500 }}>OpenAI API Key (Codex)</label>
|
|
1526
|
+
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, cursor: 'pointer', color: configData?.brain?.preferred_agent === 'codex' ? 'var(--accent)' : 'var(--text-tertiary)' }}>
|
|
1527
|
+
<input
|
|
1528
|
+
type="radio"
|
|
1529
|
+
name="preferred_agent"
|
|
1530
|
+
checked={configData?.brain?.preferred_agent === 'codex'}
|
|
1531
|
+
onChange={() => updateBrainProp('preferred_agent', 'codex')}
|
|
1532
|
+
/>
|
|
1533
|
+
Set Active
|
|
1534
|
+
</label>
|
|
1535
|
+
</div>
|
|
1536
|
+
<form onSubmit={e => e.preventDefault()} style={{display:'inline',margin:0,padding:0,width:'100%'}}><input id="openai_api_key"
|
|
1537
|
+
type="password"
|
|
1538
|
+
placeholder="sk-proj-..."
|
|
1539
|
+
value={configData?.secrets?.openai_api_key || ''}
|
|
1540
|
+
onChange={(e) => updateSecretsProp('openai_api_key', e.target.value)}
|
|
1541
|
+
style={{
|
|
1542
|
+
background: 'var(--bg-tertiary)',
|
|
1543
|
+
color: 'var(--text-primary)',
|
|
1544
|
+
border: '1px solid var(--border)',
|
|
1545
|
+
padding: '8px 12px',
|
|
1546
|
+
borderRadius: 6,
|
|
1547
|
+
outline: 'none',
|
|
1548
|
+
fontSize: 13
|
|
1549
|
+
}}
|
|
1550
|
+
/></form>
|
|
1551
|
+
|
|
1552
|
+
<select
|
|
1553
|
+
disabled={!configData?.secrets?.openai_api_key}
|
|
1554
|
+
value={configData?.brain?.openai_model || ''}
|
|
1555
|
+
onChange={(e) => updateBrainProp('openai_model', e.target.value)}
|
|
1556
|
+
style={{
|
|
1557
|
+
background: 'var(--bg-tertiary)',
|
|
1558
|
+
color: 'var(--text-primary)',
|
|
1559
|
+
border: '1px solid var(--border)',
|
|
1560
|
+
padding: '8px 12px',
|
|
1561
|
+
borderRadius: 6,
|
|
1562
|
+
outline: 'none',
|
|
1563
|
+
fontSize: 13,
|
|
1564
|
+
marginTop: 4
|
|
1565
|
+
}}
|
|
1566
|
+
>
|
|
1567
|
+
<option value="">Default OpenAI Model</option>
|
|
1568
|
+
{openaiModels.map(m => {
|
|
1569
|
+
let costStr = '';
|
|
1570
|
+
if (m.pricing && m.pricing.prompt && m.pricing.completion) {
|
|
1571
|
+
const promptCost = (parseFloat(m.pricing.prompt as string) * 1000000).toFixed(2);
|
|
1572
|
+
const compCost = (parseFloat(m.pricing.completion as string) * 1000000).toFixed(2);
|
|
1573
|
+
costStr = ` - ${promptCost}/${compCost} per 1M`;
|
|
1574
|
+
}
|
|
1575
|
+
return (
|
|
1576
|
+
<option key={m.id} value={m.id}>{m.displayName} ({m.id}){costStr}</option>
|
|
1577
|
+
);
|
|
1578
|
+
})}
|
|
1579
|
+
</select>
|
|
1580
|
+
{!configData?.secrets?.openai_api_key && (
|
|
1581
|
+
<div style={{ fontSize: 11, color: 'var(--text-error, #f44336)', marginTop: 8 }}>
|
|
1582
|
+
⚠ API Key required to unlock model selection
|
|
1583
|
+
</div>
|
|
1584
|
+
)}
|
|
1585
|
+
</div>
|
|
1586
|
+
|
|
1587
|
+
{/* OpenRouter API Key */}
|
|
1588
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, paddingBottom: 8 }}>
|
|
1589
|
+
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
|
1590
|
+
<label htmlFor="openrouter_api_key" style={{ fontSize: 13, fontWeight: 500 }}>OpenRouter API Key</label>
|
|
1591
|
+
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, cursor: 'pointer', color: configData?.brain?.preferred_agent === 'openrouter' ? 'var(--accent)' : 'var(--text-tertiary)' }}>
|
|
1592
|
+
<input
|
|
1593
|
+
type="radio"
|
|
1594
|
+
name="preferred_agent"
|
|
1595
|
+
checked={configData?.brain?.preferred_agent === 'openrouter'}
|
|
1596
|
+
onChange={() => updateBrainProp('preferred_agent', 'openrouter')}
|
|
1597
|
+
/>
|
|
1598
|
+
Set Active
|
|
1599
|
+
</label>
|
|
1600
|
+
</div>
|
|
1601
|
+
<form onSubmit={e => e.preventDefault()} style={{display:'inline',margin:0,padding:0,width:'100%'}}><input id="openrouter_api_key"
|
|
1602
|
+
type="password"
|
|
1603
|
+
placeholder="sk-or-..."
|
|
1604
|
+
value={configData?.secrets?.openrouter_api_key || ''}
|
|
1605
|
+
onChange={(e) => updateSecretsProp('openrouter_api_key', e.target.value)}
|
|
1606
|
+
style={{
|
|
1607
|
+
background: 'var(--bg-tertiary)',
|
|
1608
|
+
color: 'var(--text-primary)',
|
|
1609
|
+
border: '1px solid var(--border)',
|
|
1610
|
+
padding: '8px 12px',
|
|
1611
|
+
borderRadius: 6,
|
|
1612
|
+
outline: 'none',
|
|
1613
|
+
fontSize: 13
|
|
1614
|
+
}}
|
|
1615
|
+
/></form>
|
|
1616
|
+
|
|
1617
|
+
|
|
1618
|
+
|
|
1619
|
+
{loading && <div style={{ fontSize: 11, color: 'var(--text-tertiary)', padding: '0 4px' }}>Fetching models...</div>}
|
|
1620
|
+
|
|
1621
|
+
{!loading && orModels.length === 0 && configData?.secrets?.openrouter_api_key?.startsWith('sk-or-') && (
|
|
1622
|
+
<div style={{ fontSize: 11, color: 'var(--accent-red)', padding: '0 4px' }}>Failed to fetch OpenRouter models.</div>
|
|
1623
|
+
)}
|
|
1624
|
+
{!loading && orModels.length > 0 && (
|
|
1625
|
+
<select
|
|
1626
|
+
id="openrouter_model"
|
|
1627
|
+
value={configData?.brain?.openrouter_model || ''}
|
|
1628
|
+
onChange={(e) => updateBrainProp('openrouter_model', e.target.value)}
|
|
1629
|
+
style={{
|
|
1630
|
+
background: 'var(--bg-tertiary)',
|
|
1631
|
+
color: 'var(--text-primary)',
|
|
1632
|
+
border: '1px solid var(--border)',
|
|
1633
|
+
padding: '8px 12px',
|
|
1634
|
+
borderRadius: 6,
|
|
1635
|
+
outline: 'none',
|
|
1636
|
+
fontSize: 13,
|
|
1637
|
+
marginTop: 4
|
|
1638
|
+
}}
|
|
1639
|
+
>
|
|
1640
|
+
<option value="">Default OpenRouter Model</option>
|
|
1641
|
+
{(() => {
|
|
1642
|
+
const groups: Record<string, typeof orModels> = {};
|
|
1643
|
+
orModels.forEach(m => {
|
|
1644
|
+
const provider = m.id.split('/')[0].toUpperCase();
|
|
1645
|
+
if (!groups[provider]) groups[provider] = [];
|
|
1646
|
+
groups[provider].push(m);
|
|
1647
|
+
});
|
|
1648
|
+
|
|
1649
|
+
// Sort providers alphabetically
|
|
1650
|
+
const sortedProviders = Object.keys(groups).sort((a, b) => a.localeCompare(b));
|
|
1651
|
+
|
|
1652
|
+
return sortedProviders.map(provider => {
|
|
1653
|
+
// Sort models within provider by ID alphabetically
|
|
1654
|
+
const sortedModels = groups[provider].sort((a, b) => a.id.localeCompare(b.id));
|
|
1655
|
+
return (
|
|
1656
|
+
<optgroup key={provider} label={provider}>
|
|
1657
|
+
{sortedModels.map(m => {
|
|
1658
|
+
let costStr = '';
|
|
1659
|
+
if (m.pricing && m.pricing.prompt && m.pricing.completion) {
|
|
1660
|
+
const promptCost = (parseFloat(m.pricing.prompt) * 1000000).toFixed(2);
|
|
1661
|
+
const compCost = (parseFloat(m.pricing.completion) * 1000000).toFixed(2);
|
|
1662
|
+
costStr = ` - ${promptCost}/${compCost} per 1M`;
|
|
1663
|
+
}
|
|
1664
|
+
return (
|
|
1665
|
+
<option key={m.id} value={m.id}>{m.displayName} ({m.id}){costStr}</option>
|
|
1666
|
+
);
|
|
1667
|
+
})}
|
|
1668
|
+
</optgroup>
|
|
1669
|
+
);
|
|
1670
|
+
});
|
|
1671
|
+
})()}
|
|
1672
|
+
</select>
|
|
1673
|
+
)}
|
|
1674
|
+
</div>
|
|
1675
|
+
|
|
1676
|
+
|
|
1677
|
+
{/* Search & Tool APIs */}
|
|
1678
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, paddingBottom: 16, borderBottom: '1px solid rgba(255,255,255,0.05)', marginTop: 16 }}>
|
|
1679
|
+
<h4 style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 8 }}>Search & External Tools</h4>
|
|
1680
|
+
{['tavily_api_key', 'brave_api_key', 'exa_api_key', 'serper_api_key', 'github_token'].map(key => (
|
|
1681
|
+
<div key={key} style={{ display: 'flex', flexDirection: 'column', gap: 4, marginBottom: 8 }}>
|
|
1682
|
+
<label style={{ fontSize: 12, fontWeight: 500 }}>{key.replace(/_/g, ' ').replace('api', 'API').replace('key', 'Key').replace('token', 'Token').replace(/\\b\\w/g, c => c.toUpperCase())}</label>
|
|
1683
|
+
<input type="password" placeholder="Enter token..." value={(configData?.secrets as any)?.[key] || ''} onChange={(e) => updateSecretsProp(key, e.target.value)} style={{ background: 'var(--bg-tertiary)', color: 'var(--text-primary)', border: '1px solid var(--border)', padding: '8px 12px', borderRadius: 6, outline: 'none', fontSize: 13 }} />
|
|
1684
|
+
</div>
|
|
260
1685
|
))}
|
|
261
|
-
</
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
1686
|
+
</div>
|
|
1687
|
+
<span style={{ fontSize: 10, color: 'var(--text-tertiary)' }}>
|
|
1688
|
+
These keys are stored locally and injected into the CLI reasoning agents on dispatch.
|
|
1689
|
+
</span>
|
|
1690
|
+
</div>
|
|
1691
|
+
</div>
|
|
1692
|
+
)}
|
|
1693
|
+
|
|
1694
|
+
<p style={{ marginTop: 24, fontSize: 11, color: 'var(--text-tertiary)' }}>
|
|
1695
|
+
CLI: <code>secret catalog</code> · <code>secret meta KEY --repo app --tier pro --monthly-cost 25 --rotate-days 90</code> ·{' '}
|
|
1696
|
+
<code>secret rotation-due</code> · <code>secret usage</code> · <code>secret providers</code>
|
|
1697
|
+
</p>
|
|
1698
|
+
</div>
|
|
1699
|
+
)
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
const DEVELOPER_ID = '__developer__'
|
|
1703
|
+
const MULTI_ERROR_ID = '__multi_repo_error__'
|
|
1704
|
+
|
|
1705
|
+
function groupKeysByRepo(keys: SecretCatalogKey[]): {
|
|
1706
|
+
id: string
|
|
1707
|
+
label: string
|
|
1708
|
+
keys: SecretCatalogKey[]
|
|
1709
|
+
kind: 'error' | 'repo' | 'developer'
|
|
1710
|
+
}[] {
|
|
1711
|
+
const map = new Map<string, SecretCatalogKey[]>()
|
|
1712
|
+
const developer: SecretCatalogKey[] = []
|
|
1713
|
+
const multiError: SecretCatalogKey[] = []
|
|
1714
|
+
|
|
1715
|
+
for (const k of keys) {
|
|
1716
|
+
const repos = (k.repos || []).map((r) => r.trim()).filter(Boolean)
|
|
1717
|
+
if (repos.length > 1 || k.multi_repo_error) {
|
|
1718
|
+
multiError.push(k)
|
|
1719
|
+
continue
|
|
1720
|
+
}
|
|
1721
|
+
if (!repos.length) {
|
|
1722
|
+
developer.push(k)
|
|
1723
|
+
continue
|
|
1724
|
+
}
|
|
1725
|
+
// Exactly one product repo
|
|
1726
|
+
const repo = repos[0]
|
|
1727
|
+
if (!map.has(repo)) map.set(repo, [])
|
|
1728
|
+
map.get(repo)!.push(k)
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
const sections: {
|
|
1732
|
+
id: string
|
|
1733
|
+
label: string
|
|
1734
|
+
keys: SecretCatalogKey[]
|
|
1735
|
+
kind: 'error' | 'repo' | 'developer'
|
|
1736
|
+
}[] = []
|
|
1737
|
+
|
|
1738
|
+
// Errors first so they are impossible to miss
|
|
1739
|
+
if (multiError.length) {
|
|
1740
|
+
sections.push({
|
|
1741
|
+
id: MULTI_ERROR_ID,
|
|
1742
|
+
label: 'Needs fix — multi-repo binding',
|
|
1743
|
+
keys: multiError.slice().sort((a, b) => a.key.localeCompare(b.key)),
|
|
1744
|
+
kind: 'error',
|
|
1745
|
+
})
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
for (const [repo, list] of [...map.entries()].sort(([a], [b]) =>
|
|
1749
|
+
a.localeCompare(b, undefined, { sensitivity: 'base' }),
|
|
1750
|
+
)) {
|
|
1751
|
+
sections.push({
|
|
1752
|
+
id: repo.replace(/[^a-zA-Z0-9._-]+/g, '-'),
|
|
1753
|
+
label: repo,
|
|
1754
|
+
keys: list.slice().sort((a, b) => a.key.localeCompare(b.key)),
|
|
1755
|
+
kind: 'repo',
|
|
1756
|
+
})
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
if (developer.length) {
|
|
1760
|
+
sections.push({
|
|
1761
|
+
id: DEVELOPER_ID,
|
|
1762
|
+
label: 'Developer secrets',
|
|
1763
|
+
keys: developer.slice().sort((a, b) => a.key.localeCompare(b.key)),
|
|
1764
|
+
kind: 'developer',
|
|
1765
|
+
})
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
return sections
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
/** Compact single-line-ish row for long catalogs */
|
|
1772
|
+
function SecretKeyRow({
|
|
1773
|
+
k,
|
|
1774
|
+
selected,
|
|
1775
|
+
onOpen,
|
|
1776
|
+
}: {
|
|
1777
|
+
k: SecretCatalogKey
|
|
1778
|
+
selected: boolean
|
|
1779
|
+
onOpen: () => void
|
|
1780
|
+
}) {
|
|
1781
|
+
return (
|
|
1782
|
+
<button
|
|
1783
|
+
type="button"
|
|
1784
|
+
onClick={onOpen}
|
|
1785
|
+
style={{
|
|
1786
|
+
width: '100%',
|
|
1787
|
+
display: 'grid',
|
|
1788
|
+
gridTemplateColumns: 'minmax(0, 1.4fr) minmax(0, 1fr) auto',
|
|
1789
|
+
gap: 8,
|
|
1790
|
+
alignItems: 'center',
|
|
1791
|
+
padding: '8px 10px',
|
|
1792
|
+
marginBottom: 4,
|
|
1793
|
+
borderRadius: 8,
|
|
1794
|
+
border: `1px solid ${selected ? 'var(--border-accent)' : 'transparent'}`,
|
|
1795
|
+
background: selected
|
|
1796
|
+
? 'rgba(59,130,246,0.14)'
|
|
1797
|
+
: k.multi_repo_error || k.rotation_overdue
|
|
1798
|
+
? 'rgba(248,113,113,0.06)'
|
|
1799
|
+
: 'rgba(148,163,184,0.04)',
|
|
1800
|
+
color: 'inherit',
|
|
1801
|
+
cursor: 'pointer',
|
|
1802
|
+
textAlign: 'left',
|
|
1803
|
+
}}
|
|
1804
|
+
>
|
|
1805
|
+
<div style={{ minWidth: 0 }}>
|
|
1806
|
+
<div
|
|
1807
|
+
style={{
|
|
1808
|
+
fontFamily: 'ui-monospace, monospace',
|
|
1809
|
+
fontWeight: 650,
|
|
1810
|
+
fontSize: 12,
|
|
1811
|
+
overflow: 'hidden',
|
|
1812
|
+
textOverflow: 'ellipsis',
|
|
1813
|
+
whiteSpace: 'nowrap',
|
|
1814
|
+
}}
|
|
1815
|
+
>
|
|
1816
|
+
{k.key}
|
|
1817
|
+
{k.multi_repo_error && (
|
|
1818
|
+
<span style={{ marginLeft: 6, fontSize: 9, color: '#f87171' }}>MULTI</span>
|
|
1819
|
+
)}
|
|
1820
|
+
{k.rotation_overdue && (
|
|
1821
|
+
<span style={{ marginLeft: 6, fontSize: 9, color: '#f87171' }}>DUE</span>
|
|
1822
|
+
)}
|
|
1823
|
+
</div>
|
|
1824
|
+
<div
|
|
1825
|
+
style={{
|
|
1826
|
+
fontSize: 10,
|
|
1827
|
+
color: 'var(--text-tertiary)',
|
|
1828
|
+
overflow: 'hidden',
|
|
1829
|
+
textOverflow: 'ellipsis',
|
|
1830
|
+
whiteSpace: 'nowrap',
|
|
1831
|
+
}}
|
|
1832
|
+
>
|
|
1833
|
+
{k.provider_name || k.provider || '—'} · {k.masked || '••••'}
|
|
1834
|
+
{k.multi_repo_error ? ` · ${k.repos.join(', ')}` : ''}
|
|
1835
|
+
</div>
|
|
265
1836
|
</div>
|
|
1837
|
+
<div style={{ fontSize: 10, color: 'var(--text-secondary)', minWidth: 0 }}>
|
|
1838
|
+
<span style={{ color: k.subscription_tier ? '#fbbf24' : 'var(--text-tertiary)' }}>
|
|
1839
|
+
{k.subscription_tier || 'no tier'}
|
|
1840
|
+
</span>
|
|
1841
|
+
{' · '}
|
|
1842
|
+
<span style={{ color: k.monthly_cost_usd != null ? '#34d399' : 'var(--text-tertiary)' }}>
|
|
1843
|
+
{k.monthly_cost_usd != null ? `$${k.monthly_cost_usd}/mo` : 'no $/mo'}
|
|
1844
|
+
</span>
|
|
1845
|
+
</div>
|
|
1846
|
+
<span style={{ fontSize: 11, color: 'var(--text-tertiary)', flexShrink: 0 }}>›</span>
|
|
1847
|
+
</button>
|
|
1848
|
+
)
|
|
1849
|
+
}
|
|
266
1850
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
1851
|
+
function SummaryCard({ label, value, accent }: { label: string; value: string; accent: string }) {
|
|
1852
|
+
return (
|
|
1853
|
+
<div
|
|
1854
|
+
className="card"
|
|
1855
|
+
style={{
|
|
1856
|
+
padding: '14px 16px',
|
|
1857
|
+
borderLeft: `3px solid ${accent}`,
|
|
1858
|
+
}}
|
|
1859
|
+
>
|
|
1860
|
+
<div style={{ fontSize: 20, fontWeight: 700, letterSpacing: '-0.02em' }}>{value}</div>
|
|
1861
|
+
<div style={{ fontSize: 10, color: 'var(--text-tertiary)', textTransform: 'uppercase', letterSpacing: '0.06em', marginTop: 2 }}>
|
|
1862
|
+
{label}
|
|
1863
|
+
</div>
|
|
1864
|
+
</div>
|
|
1865
|
+
)
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
const DEFAULT_TIERS: { id: string; label: string; monthly_usd?: number | null }[] = [
|
|
1869
|
+
{ id: 'free', label: 'Free', monthly_usd: 0 },
|
|
1870
|
+
{ id: 'payg', label: 'Pay-as-you-go', monthly_usd: null },
|
|
1871
|
+
{ id: 'pro', label: 'Pro', monthly_usd: 20 },
|
|
1872
|
+
{ id: 'team', label: 'Team', monthly_usd: 30 },
|
|
1873
|
+
{ id: 'business', label: 'Business', monthly_usd: 50 },
|
|
1874
|
+
{ id: 'enterprise', label: 'Enterprise', monthly_usd: null },
|
|
1875
|
+
]
|
|
1876
|
+
|
|
1877
|
+
function PanelSection({
|
|
1878
|
+
title,
|
|
1879
|
+
hint,
|
|
1880
|
+
children,
|
|
1881
|
+
}: {
|
|
1882
|
+
title: string
|
|
1883
|
+
hint?: string
|
|
1884
|
+
children: ReactNode
|
|
1885
|
+
}) {
|
|
1886
|
+
return (
|
|
1887
|
+
<div
|
|
1888
|
+
style={{
|
|
1889
|
+
marginBottom: 14,
|
|
1890
|
+
paddingBottom: 12,
|
|
1891
|
+
borderBottom: '1px solid var(--border)',
|
|
1892
|
+
}}
|
|
1893
|
+
>
|
|
1894
|
+
<div
|
|
1895
|
+
style={{
|
|
1896
|
+
fontSize: 11,
|
|
1897
|
+
fontWeight: 700,
|
|
1898
|
+
textTransform: 'uppercase',
|
|
1899
|
+
letterSpacing: '0.06em',
|
|
1900
|
+
color: 'var(--text-secondary)',
|
|
1901
|
+
marginBottom: 4,
|
|
1902
|
+
}}
|
|
1903
|
+
>
|
|
1904
|
+
{title}
|
|
1905
|
+
</div>
|
|
1906
|
+
{hint && (
|
|
1907
|
+
<div style={{ fontSize: 11, color: 'var(--text-tertiary)', marginBottom: 8, lineHeight: 1.4 }}>
|
|
1908
|
+
{hint}
|
|
1909
|
+
</div>
|
|
285
1910
|
)}
|
|
1911
|
+
{children}
|
|
286
1912
|
</div>
|
|
287
1913
|
)
|
|
288
1914
|
}
|
|
1915
|
+
|
|
1916
|
+
function Field({
|
|
1917
|
+
label,
|
|
1918
|
+
value,
|
|
1919
|
+
onChange,
|
|
1920
|
+
placeholder,
|
|
1921
|
+
}: {
|
|
1922
|
+
label: string
|
|
1923
|
+
value: string
|
|
1924
|
+
onChange: (v: string) => void
|
|
1925
|
+
placeholder?: string
|
|
1926
|
+
}) {
|
|
1927
|
+
return (
|
|
1928
|
+
<label style={{ display: 'block', marginBottom: 10 }}>
|
|
1929
|
+
<span style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-tertiary)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
|
1930
|
+
{label}
|
|
1931
|
+
</span>
|
|
1932
|
+
<input
|
|
1933
|
+
value={value}
|
|
1934
|
+
onChange={(e) => onChange(e.target.value)}
|
|
1935
|
+
placeholder={placeholder}
|
|
1936
|
+
style={{ ...inputStyle, marginTop: 4 }}
|
|
1937
|
+
/>
|
|
1938
|
+
</label>
|
|
1939
|
+
)
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1942
|
+
const inputStyle: CSSProperties = {
|
|
1943
|
+
width: '100%',
|
|
1944
|
+
boxSizing: 'border-box',
|
|
1945
|
+
padding: '8px 10px',
|
|
1946
|
+
borderRadius: 8,
|
|
1947
|
+
border: '1px solid var(--border)',
|
|
1948
|
+
background: 'var(--bg-primary)',
|
|
1949
|
+
color: 'var(--text-primary)',
|
|
1950
|
+
fontSize: 12,
|
|
1951
|
+
}
|