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.
Files changed (152) hide show
  1. package/README.md +91 -108
  2. package/bin/antigravity.mjs +81 -52
  3. package/bin/total-recall.mjs +32 -57
  4. package/extension/background.js +1 -1
  5. package/extension/options/options.js +9 -3
  6. package/extension/sidepanel/sidepanel.js +0 -2
  7. package/frontend/index.html +4 -3
  8. package/frontend/package.json +1 -0
  9. package/frontend/src/App.tsx +109 -152
  10. package/frontend/src/api.ts +605 -23
  11. package/frontend/src/components/BrainSelector.tsx +211 -199
  12. package/frontend/src/components/DocumentEditorModal.tsx +57 -0
  13. package/frontend/src/components/DocumentTable.tsx +47 -0
  14. package/frontend/src/components/Graph3D.tsx +120 -71
  15. package/frontend/src/components/UsageChart.tsx +3 -3
  16. package/frontend/src/components/brand/BrandMark.tsx +95 -0
  17. package/frontend/src/index.css +286 -86
  18. package/frontend/src/pages/ApiKeysPage.tsx +1878 -215
  19. package/frontend/src/pages/ChatPage.tsx +103 -45
  20. package/frontend/src/pages/DesignDocsPage.tsx +5 -5
  21. package/frontend/src/pages/FilesPage.tsx +10 -6
  22. package/frontend/src/pages/GraphPage.tsx +5 -3
  23. package/frontend/src/pages/HelpPage.tsx +2 -2
  24. package/frontend/src/pages/InboxPage.tsx +82 -0
  25. package/frontend/src/pages/InstructionsPage.tsx +1 -1
  26. package/frontend/src/pages/IntegrationsPage.tsx +21 -10
  27. package/frontend/src/pages/LoginPage.tsx +95 -77
  28. package/frontend/src/pages/MemoryPage.tsx +31 -8
  29. package/frontend/src/pages/OnboardingPage.tsx +690 -0
  30. package/frontend/src/pages/OpenWikiPage.tsx +412 -323
  31. package/frontend/src/pages/SettingsPage.tsx +825 -922
  32. package/frontend/src/pages/SkillsPage.tsx +272 -147
  33. package/frontend/src/pages/UsagePage.tsx +23 -31
  34. package/frontend/src/pages/VaultPage.tsx +223 -0
  35. package/frontend/src/types.ts +13 -0
  36. package/package.json +4 -4
  37. package/scaffold/.agent/skills/total-recall/SKILL.md +43 -4
  38. package/scaffold/.agent/skills/total-recall/modules/README.md +10 -0
  39. package/scaffold/.agent/skills/total-recall/modules/agents/README.md +5 -0
  40. package/scaffold/.agent/skills/total-recall/{skills/tr-cli-agents → modules/agents}/agents.yml +8 -0
  41. package/scaffold/.agent/skills/total-recall/modules/skill-deploy/README.md +9 -0
  42. package/scaffold/.agent/skills/total-recall/openwiki/README.md +10 -0
  43. package/scaffold/.agent/skills/total-recall/openwiki/architecture.md +5 -0
  44. package/scaffold/.agent/skills/total-recall/openwiki/ide.md +10 -0
  45. package/scaffold/.agent/skills/total-recall/openwiki/memory.md +11 -0
  46. package/scaffold/.agent/skills/total-recall/openwiki/secrets.md +7 -0
  47. package/scaffold/.agent/skills/total-recall/openwiki/skills.md +7 -0
  48. package/src/cli/brain.mjs +63 -5
  49. package/src/cli/connect.mjs +11 -19
  50. package/src/cli/daemon.mjs +5 -4
  51. package/src/cli/deploy-ui.mjs +8 -3
  52. package/src/cli/deploy.mjs +84 -27
  53. package/src/cli/ingest/index.mjs +1 -1
  54. package/src/cli/init.mjs +63 -34
  55. package/src/cli/remember.mjs +3 -3
  56. package/src/cli/secret.mjs +812 -0
  57. package/src/cli/setup.mjs +34 -7
  58. package/src/cli/share.mjs +2 -2
  59. package/src/cli/skill.mjs +396 -35
  60. package/src/cli/sync.mjs +4 -4
  61. package/src/cli/task.mjs +287 -0
  62. package/src/core/clarity-rewriter.mjs +5 -5
  63. package/src/core/config.mjs +44 -2
  64. package/src/core/conflict-detector.mjs +5 -5
  65. package/src/core/crons.mjs +54 -0
  66. package/src/core/daemon-loop.mjs +87 -235
  67. package/src/core/dream.mjs +170 -10
  68. package/src/core/env-import.mjs +456 -0
  69. package/src/core/fact-seeker.mjs +14 -14
  70. package/src/core/import-rules.mjs +20 -7
  71. package/src/core/inference-engine.mjs +3 -3
  72. package/src/core/migrate.mjs +14 -8
  73. package/src/core/okf-adapter.mjs +2 -2
  74. package/src/core/operation-validator.mjs +39 -331
  75. package/src/core/optimizer.mjs +7 -0
  76. package/src/core/post-mortem.mjs +2 -2
  77. package/src/core/project-brain.mjs +349 -0
  78. package/src/core/provider-catalog.mjs +296 -0
  79. package/src/core/quick-capture.mjs +1 -1
  80. package/src/core/remote-vault-sync.mjs +194 -0
  81. package/src/core/research-queue.mjs +7 -11
  82. package/src/core/runtime.mjs +33 -9
  83. package/src/core/scheduler.mjs +59 -5
  84. package/src/core/schema.mjs +14 -0
  85. package/src/core/search.mjs +1 -1
  86. package/src/core/secrets-env-export.mjs +323 -0
  87. package/src/core/secrets-rotate.mjs +245 -0
  88. package/src/core/secrets-store.mjs +645 -0
  89. package/src/core/semantic-index.mjs +3 -3
  90. package/src/core/skills-registry.mjs +1062 -0
  91. package/src/core/source-adapters.mjs +4 -1
  92. package/src/core/ssss-host-extension.mjs +94 -0
  93. package/src/core/ssss-kernel-bridge.mjs +530 -0
  94. package/src/core/surface.mjs +14 -0
  95. package/src/core/task-envelope.mjs +298 -0
  96. package/src/core/task-executors.mjs +600 -0
  97. package/src/core/validated-write.mjs +124 -56
  98. package/src/core/vault.mjs +21 -34
  99. package/src/core/webauthn-store.mjs +272 -0
  100. package/src/server/api.mjs +26 -16
  101. package/src/server/auth.mjs +51 -0
  102. package/src/server/index.mjs +3 -3
  103. package/src/server/keys.mjs +30 -4
  104. package/src/server/rest.mjs +198 -25
  105. package/src/server/route-manifest.json +156 -0
  106. package/src/server/routes/_shared.mjs +40 -1
  107. package/src/server/routes/docs.mjs +260 -0
  108. package/src/server/routes/memory.mjs +145 -32
  109. package/src/server/routes/research.mjs +30 -0
  110. package/src/server/routes/secrets.mjs +470 -0
  111. package/src/server/routes/share.mjs +2 -2
  112. package/src/server/routes/skills.mjs +452 -54
  113. package/src/server/routes/sync.mjs +48 -0
  114. package/src/server/routes/webauthn.mjs +184 -0
  115. package/src/server/update-route-manifest.mjs +39 -0
  116. package/templates/onboarding-interview.md +32 -22
  117. package/templates/openwiki/README.md +10 -0
  118. package/templates/openwiki/architecture.md +5 -0
  119. package/templates/openwiki/ide.md +10 -0
  120. package/templates/openwiki/memory.md +11 -0
  121. package/templates/openwiki/secrets.md +7 -0
  122. package/templates/openwiki/skills.md +7 -0
  123. package/scaffold/.agent/skills/total-recall/skills/tr-cli-agents/SKILL.md +0 -121
  124. package/scaffold/.agent/skills/total-recall/skills/tr-cli-agents/references/antigravity.txt +0 -20
  125. package/scaffold/.agent/skills/total-recall/skills/tr-cli-agents/references/claude.txt +0 -58
  126. package/scaffold/.agent/skills/total-recall/skills/tr-cli-agents/references/codex.txt +0 -81
  127. package/scaffold/.agent/skills/total-recall/skills/tr-cli-agents/references/gemini.txt +0 -44
  128. package/scaffold/.agent/skills/total-recall/skills/tr-cli-agents/scripts/.gitkeep +0 -0
  129. package/scaffold/.agent/skills/total-recall/skills/tr-cli-agents/subagents/.gitkeep +0 -0
  130. package/scaffold/.agent/skills/total-recall/skills/tr-research/SKILL.md +0 -338
  131. package/scaffold/.agent/skills/total-recall/skills/tr-skill/SKILL.md +0 -282
  132. package/scaffold/.agent/skills/total-recall/skills/tr-skill/assets/skill-template/SKILL.md +0 -20
  133. package/scaffold/.agent/skills/total-recall/skills/tr-skill/evals/evals.json +0 -20
  134. package/scaffold/.agent/skills/total-recall/skills/tr-skill/hooks/on-skill-add.sh +0 -14
  135. package/scaffold/.agent/skills/total-recall/skills/tr-skill/hooks/on-skill-delete.sh +0 -14
  136. package/scaffold/.agent/skills/total-recall/skills/tr-skill/learnings/.gitkeep +0 -0
  137. package/scaffold/.agent/skills/total-recall/skills/tr-skill/references/.gitkeep +0 -0
  138. package/scaffold/.agent/skills/total-recall/skills/tr-skill/scripts/.gitkeep +0 -0
  139. package/scaffold/.agent/skills/total-recall/skills/tr-skill/scripts/create-skill.sh +0 -65
  140. package/scaffold/.agent/skills/total-recall/skills/tr-skill/scripts/enforce-skill-optimization.mjs +0 -135
  141. package/scaffold/.agent/skills/total-recall/skills/tr-skill/scripts/skill-watcher.mjs +0 -64
  142. package/scaffold/.agent/skills/total-recall/skills/tr-skill/subagents/audit-all-skills.md +0 -62
  143. package/scaffold/.agent/skills/total-recall/skills/tr-ssss/SKILL.md +0 -298
  144. package/scaffold/.agent/skills/total-recall/skills/tr-ssss/evals/evals.json +0 -17
  145. package/scaffold/.agent/skills/total-recall/skills/tr-ssss/references/CHANGELOG.md +0 -124
  146. package/scaffold/.agent/skills/total-recall/skills/tr-ssss/references/ssss-spec.md +0 -1325
  147. package/scaffold/.agent/skills/total-recall/skills/tr-ssss/scripts/validate-schema.mjs +0 -232
  148. package/scaffold/.agent/skills/total-recall/skills/tr-ssss/subagents/audit-ssss-node.md +0 -9
  149. /package/scaffold/.agent/skills/total-recall/{skills/tr-cli-agents/evals → modules/skill-deploy/scripts}/.gitkeep +0 -0
  150. /package/scaffold/.agent/skills/total-recall/{skills/tr-skill → modules/skill-deploy}/scripts/find-skills.mjs +0 -0
  151. /package/scaffold/.agent/skills/total-recall/{skills/tr-skill → modules/skill-deploy}/scripts/install-skill.mjs +0 -0
  152. /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 { listApiKeys, issueApiKey, revokeApiKey } from '../api'
3
- import type { ApiKey, IssuedApiKey } from '../api'
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 [keys, setKeys] = useState<ApiKey[]>([])
7
- const [availableScopes, setAvailableScopes] = useState<string[]>(['*'])
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
- const load = useCallback(async (showLoading = false) => {
18
- if (showLoading) setLoading(true)
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
- const result = await listApiKeys()
22
- setKeys(result.keys || [])
23
- setAvailableScopes(result.available_scopes || ['*'])
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
- // eslint-disable-next-line react-hooks/set-state-in-effect -- legitimate one-time data fetch on mount
32
- useEffect(() => { void load(false) }, [load])
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
- const handleIssue = async () => {
35
- if (!newKeyName.trim()) return
36
- setIssuing(true)
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 key = await issueApiKey(newKeyName.trim(), selectedScopes, expiresAt || null)
40
- setNewlyIssued(key)
41
- setNewKeyName('')
42
- setSelectedScopes(['*'])
43
- setExpiresAt('')
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
- setIssuing(false)
246
+ setRevealBusy(false)
49
247
  }
50
248
  }
51
249
 
52
- const handleRevoke = async (id: string, name: string) => {
53
- if (!confirm(`Revoke key "${name}"? This cannot be undone.`)) return
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 revokeApiKey(id)
56
- await load(true)
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
- const copyToken = () => {
63
- if (!newlyIssued) return
64
- navigator.clipboard.writeText(newlyIssued.token)
65
- setCopied(true)
66
- setTimeout(() => setCopied(false), 2000)
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
- const activeKeys = keys.filter(k => !k.revoked)
70
- const revokedKeys = keys.filter(k => k.revoked)
71
-
72
- const toggleScope = (scope: string) => {
73
- setSelectedScopes(current => {
74
- if (scope === '*') return ['*']
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
- const formatScopes = (scopes: string[]) => {
84
- if (!scopes || scopes.length === 0 || scopes.includes('*')) return 'Full access'
85
- return scopes.join(', ')
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
- <h1>API Keys</h1>
92
- <p>Issue and manage Personal Access Tokens to use Total Recall anywhere</p>
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 &amp; 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
- {/* New token banner — shown once after issuing */}
96
- {newlyIssued && (
97
- <div style={{
98
- background: 'linear-gradient(135deg, rgba(16, 185, 129, 0.15), rgba(16, 185, 129, 0.05))',
99
- border: '1px solid rgba(16, 185, 129, 0.4)',
100
- borderRadius: 12,
101
- padding: '20px 24px',
102
- marginBottom: 24,
103
- }}>
104
- <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 8 }}>
105
- <span style={{ fontSize: 18 }}>🔑</span>
106
- <strong style={{ color: 'var(--accent)' }}>New key issued — copy it now! It won't be shown again.</strong>
107
- </div>
108
- <div style={{
109
- display: 'flex', alignItems: 'center', gap: 12,
110
- background: 'var(--bg-primary)', borderRadius: 8, padding: '10px 16px',
111
- border: '1px solid var(--border)', fontFamily: 'monospace', fontSize: 13,
112
- wordBreak: 'break-all',
113
- }}>
114
- <span style={{ flex: 1 }}>{newlyIssued.token}</span>
115
- <button className="btn btn-sm btn-primary" onClick={copyToken} style={{ flexShrink: 0, minWidth: 70 }}>
116
- {copied ? '✓ Copied' : 'Copy'}
117
- </button>
118
- </div>
119
- <div style={{ marginTop: 8, fontSize: 12, color: 'var(--text-tertiary)' }}>
120
- Usage: <code>Authorization: Bearer {newlyIssued.token.slice(0, 12)}…</code>
121
- <span style={{ marginLeft: 12 }}>Scopes: {formatScopes(newlyIssued.scopes)}</span>
122
- </div>
123
- <button className="btn btn-ghost btn-sm" style={{ marginTop: 8 }} onClick={() => setNewlyIssued(null)}>
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
- {/* Issue new key */}
130
- <div style={{
131
- background: 'var(--bg-secondary)', borderRadius: 12,
132
- border: '1px solid var(--border)', padding: '20px 24px', marginBottom: 24,
133
- }}>
134
- <h3 style={{ margin: '0 0 14px' }}>Issue New Key</h3>
135
- <div style={{ display: 'flex', gap: 12, marginBottom: 14 }}>
136
- <input
137
- id="new-api-key-name"
138
- type="text"
139
- placeholder="e.g. Cursor IDE, iOS Shortcut, CI Pipeline…"
140
- value={newKeyName}
141
- onChange={e => setNewKeyName(e.target.value)}
142
- onKeyDown={e => { if (e.key === 'Enter') handleIssue() }}
143
- style={{
144
- flex: 1, padding: '10px 14px', borderRadius: 8, fontSize: 14,
145
- background: 'var(--bg-primary)', color: 'var(--text-primary)',
146
- border: '1px solid var(--border)', outline: 'none',
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
- className="btn btn-primary"
151
- onClick={handleIssue}
152
- disabled={issuing || !newKeyName.trim()}
153
- style={{ minWidth: 120 }}
644
+ type="button"
645
+ className="btn btn-ghost btn-sm"
646
+ style={{ marginBottom: 8, fontSize: 11 }}
647
+ onClick={() => setShowCostChart((v) => !v)}
154
648
  >
155
- {issuing ? 'Issuing…' : '+ Issue Key'}
649
+ {showCostChart ? '▾ Hide cost chart' : '▸ Cost by provider'}
156
650
  </button>
157
- </div>
158
- <div style={{ display: 'grid', gridTemplateColumns: 'minmax(180px, 240px) 1fr', gap: 16, alignItems: 'start' }}>
159
- <label style={{ display: 'flex', flexDirection: 'column', gap: 6, fontSize: 12, color: 'var(--text-secondary)' }}>
160
- Expires
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
- type="date"
163
- value={expiresAt}
164
- onChange={e => setExpiresAt(e.target.value)}
733
+ value={filter}
734
+ onChange={(e) => setFilter(e.target.value)}
735
+ placeholder="Filter key, provider, repo, tier…"
165
736
  style={{
166
- padding: '8px 10px', borderRadius: 8, fontSize: 13,
167
- background: 'var(--bg-primary)', color: 'var(--text-primary)',
168
- border: '1px solid var(--border)', outline: 'none',
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
- </label>
172
- <div>
173
- <div style={{ fontSize: 12, color: 'var(--text-secondary)', marginBottom: 8 }}>Scopes</div>
174
- <div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
175
- {availableScopes.map(scope => (
176
- <label
177
- key={scope}
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: 'inline-flex', alignItems: 'center', gap: 6,
180
- padding: '6px 9px', borderRadius: 8, border: '1px solid var(--border)',
181
- background: selectedScopes.includes(scope) ? 'rgba(59, 130, 246, 0.12)' : 'var(--bg-primary)',
182
- color: 'var(--text-secondary)', fontSize: 12, cursor: 'pointer',
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={selectedScopes.includes(scope)}
188
- onChange={() => toggleScope(scope)}
189
- style={{ margin: 0 }}
1190
+ checked={!!edit.auto_rotate}
1191
+ onChange={(e) => setEdit({ ...edit, auto_rotate: e.target.checked })}
190
1192
  />
191
- <span>{scope === '*' ? 'Full access' : scope}</span>
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
- </div>
1369
+ )}
198
1370
 
199
- {error && <div className="badge badge-warning" style={{ marginBottom: 16, alignSelf: 'flex-start' }}>⚠️ {error}</div>}
200
-
201
- {/* Active keys table */}
202
- <div style={{ marginBottom: 32 }}>
203
- <h3 style={{ marginBottom: 12 }}>Active Keys ({activeKeys.length})</h3>
204
- {loading ? (
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
- <div style={{ overflowX: 'auto' }}>
215
- <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
216
- <thead>
217
- <tr style={{ borderBottom: '1px solid var(--border)', color: 'var(--text-secondary)', textAlign: 'left' }}>
218
- <th style={{ padding: '8px 12px' }}>Name</th>
219
- <th style={{ padding: '8px 12px' }}>Token</th>
220
- <th style={{ padding: '8px 12px' }}>Scopes</th>
221
- <th style={{ padding: '8px 12px' }}>Requests</th>
222
- <th style={{ padding: '8px 12px' }}>Last Used</th>
223
- <th style={{ padding: '8px 12px' }}>Expires</th>
224
- <th style={{ padding: '8px 12px' }}>Created</th>
225
- <th style={{ padding: '8px 12px' }}></th>
226
- </tr>
227
- </thead>
228
- <tbody>
229
- {activeKeys.map(k => (
230
- <tr key={k.id} style={{ borderBottom: '1px solid var(--border)' }}>
231
- <td style={{ padding: '10px 12px', fontWeight: 500 }}>{k.name}</td>
232
- <td style={{ padding: '10px 12px', fontFamily: 'monospace', color: 'var(--text-secondary)' }}>{k.token_preview}</td>
233
- <td style={{ padding: '10px 12px', color: 'var(--text-secondary)', maxWidth: 220 }}>
234
- <span title={formatScopes(k.scopes)} style={{ display: 'inline-block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '100%' }}>
235
- {formatScopes(k.scopes)}
236
- </span>
237
- </td>
238
- <td style={{ padding: '10px 12px' }}>
239
- <span className="badge badge-accent">{k.hit_count.toLocaleString()}</span>
240
- </td>
241
- <td style={{ padding: '10px 12px', color: 'var(--text-secondary)' }}>
242
- {k.last_used_at ? new Date(k.last_used_at).toLocaleString() : '—'}
243
- </td>
244
- <td style={{ padding: '10px 12px', color: 'var(--text-secondary)' }}>
245
- {k.expires_at ? new Date(k.expires_at).toLocaleDateString() : 'Never'}
246
- </td>
247
- <td style={{ padding: '10px 12px', color: 'var(--text-secondary)' }}>
248
- {new Date(k.created_at).toLocaleDateString()}
249
- </td>
250
- <td style={{ padding: '10px 12px' }}>
251
- <button
252
- className="btn btn-ghost btn-sm"
253
- style={{ color: 'var(--error, #ef4444)', borderColor: 'var(--error, #ef4444)' }}
254
- onClick={() => handleRevoke(k.id, k.name)}
255
- >
256
- Revoke
257
- </button>
258
- </td>
259
- </tr>
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
- </tbody>
262
- </table>
263
- </div>
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
- {/* Revoked keys (collapsed) */}
268
- {revokedKeys.length > 0 && (
269
- <details style={{ color: 'var(--text-tertiary)', fontSize: 13 }}>
270
- <summary style={{ cursor: 'pointer', marginBottom: 8 }}>
271
- Revoked Keys ({revokedKeys.length})
272
- </summary>
273
- {revokedKeys.map(k => (
274
- <div key={k.id} style={{
275
- display: 'flex', gap: 16, padding: '8px 12px',
276
- borderBottom: '1px solid var(--border)', opacity: 0.5,
277
- }}>
278
- <span style={{ flex: 1, textDecoration: 'line-through' }}>{k.name}</span>
279
- <span style={{ fontFamily: 'monospace' }}>{k.token_preview}</span>
280
- <span>{formatScopes(k.scopes)}</span>
281
- <span>{k.hit_count} requests</span>
282
- </div>
283
- ))}
284
- </details>
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
+ }