auramaxx 0.0.1

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 (418) hide show
  1. package/LICENSE +26 -0
  2. package/README.md +77 -0
  3. package/apps/desktop-electron/main.js +428 -0
  4. package/bin/auramaxx.js +1063 -0
  5. package/docs/ADAPTERS.md +466 -0
  6. package/docs/AGENT_SETUP.md +159 -0
  7. package/docs/API.md +127 -0
  8. package/docs/APPS.md +199 -0
  9. package/docs/ARCHITECTURE.md +235 -0
  10. package/docs/AUTH.md +318 -0
  11. package/docs/BEST-PRACTICES.md +82 -0
  12. package/docs/CLI.md +141 -0
  13. package/docs/DESKTOP_ELECTRON.md +26 -0
  14. package/docs/DEVELOPING-APPS.md +453 -0
  15. package/docs/MCP.md +122 -0
  16. package/docs/PACKAGING_POLICY.md +19 -0
  17. package/docs/PERMISSION.md +137 -0
  18. package/docs/PROTOCOL.md +142 -0
  19. package/docs/README.md +50 -0
  20. package/docs/SKILLS.md +132 -0
  21. package/docs/TROUBLESHOOTING.md +376 -0
  22. package/docs/WORKSPACE.md +673 -0
  23. package/docs/agent-auth.md +14 -0
  24. package/docs/api/authentication.md +79 -0
  25. package/docs/api/secrets/api-keys.md +28 -0
  26. package/docs/api/secrets/credentials.md +80 -0
  27. package/docs/api/secrets/sharing.md +48 -0
  28. package/docs/api/system.md +41 -0
  29. package/docs/api/wallets/apps-strategies.md +66 -0
  30. package/docs/api/wallets/core.md +46 -0
  31. package/docs/api/wallets/data-portfolio.md +42 -0
  32. package/docs/aura-file.md +48 -0
  33. package/docs/core-concepts/FEATURES.md +114 -0
  34. package/docs/credentials.md +120 -0
  35. package/docs/external/HOW_TO_AURAMAXX/GETTING_SECRETS.md +33 -0
  36. package/docs/external/HOW_TO_AURAMAXX/README.md +45 -0
  37. package/docs/external/getting-started.md +10 -0
  38. package/docs/external/overview.md +19 -0
  39. package/docs/external/persona-paths.md +7 -0
  40. package/docs/external/share-secret.md +76 -0
  41. package/docs/external/why-aura.md +7 -0
  42. package/docs/security.md +227 -0
  43. package/docs/templates/RELEASE_NOTES_TEMPLATE.md +22 -0
  44. package/docs/wallet/AI.md +508 -0
  45. package/docs/wallet/DEVELOPING-STRATEGIES.md +713 -0
  46. package/docs/wallet/README.md +47 -0
  47. package/docs/wallet/STRATEGY.md +89 -0
  48. package/next.config.ts +28 -0
  49. package/package.json +167 -0
  50. package/postcss.config.mjs +8 -0
  51. package/prisma/migrations/20260214170000_baseline/migration.sql +511 -0
  52. package/prisma/migrations/20260216214537_add_passkey_model/migration.sql +18 -0
  53. package/prisma/migrations/20260217150500_add_credential_access_audit/migration.sql +31 -0
  54. package/prisma/migrations/20260222090000_update_admin_ttl_default/migration.sql +10 -0
  55. package/prisma/migrations/migration_lock.toml +3 -0
  56. package/prisma/schema.prisma +447 -0
  57. package/public/logo.webp +0 -0
  58. package/scripts/add-app.js +245 -0
  59. package/server/abi/SwapHelper.json +438 -0
  60. package/server/cli/approval.ts +447 -0
  61. package/server/cli/commands/actions.ts +474 -0
  62. package/server/cli/commands/api.ts +220 -0
  63. package/server/cli/commands/apikey.ts +277 -0
  64. package/server/cli/commands/app.ts +204 -0
  65. package/server/cli/commands/auth.ts +464 -0
  66. package/server/cli/commands/cron.ts +24 -0
  67. package/server/cli/commands/diary.ts +274 -0
  68. package/server/cli/commands/doctor.ts +1247 -0
  69. package/server/cli/commands/env.ts +476 -0
  70. package/server/cli/commands/experimental.ts +69 -0
  71. package/server/cli/commands/init.ts +798 -0
  72. package/server/cli/commands/lock.ts +157 -0
  73. package/server/cli/commands/mcp.ts +285 -0
  74. package/server/cli/commands/quickhack.ts +86 -0
  75. package/server/cli/commands/release-check.ts +231 -0
  76. package/server/cli/commands/restore.ts +314 -0
  77. package/server/cli/commands/service.ts +320 -0
  78. package/server/cli/commands/shell-hook.ts +512 -0
  79. package/server/cli/commands/skill.ts +216 -0
  80. package/server/cli/commands/start.ts +139 -0
  81. package/server/cli/commands/status.ts +59 -0
  82. package/server/cli/commands/stop.ts +36 -0
  83. package/server/cli/commands/token.ts +180 -0
  84. package/server/cli/commands/unlock.ts +50 -0
  85. package/server/cli/commands/vault.ts +1323 -0
  86. package/server/cli/commands/wallet.ts +209 -0
  87. package/server/cli/index.ts +280 -0
  88. package/server/cli/lib/approval-poll.ts +94 -0
  89. package/server/cli/lib/aura-parser.ts +64 -0
  90. package/server/cli/lib/credential-create.ts +74 -0
  91. package/server/cli/lib/credential-resolve.ts +280 -0
  92. package/server/cli/lib/dotenv-migrate.ts +116 -0
  93. package/server/cli/lib/dotenv-parser.ts +146 -0
  94. package/server/cli/lib/escalation.ts +57 -0
  95. package/server/cli/lib/http.ts +91 -0
  96. package/server/cli/lib/init-steps.ts +76 -0
  97. package/server/cli/lib/local-agent-trust.ts +45 -0
  98. package/server/cli/lib/lock-unlock-helper.ts +71 -0
  99. package/server/cli/lib/process.ts +162 -0
  100. package/server/cli/lib/prompt.ts +294 -0
  101. package/server/cli/lib/theme.ts +240 -0
  102. package/server/cli/socket.ts +579 -0
  103. package/server/cli/transport-client.ts +50 -0
  104. package/server/cron/index.ts +137 -0
  105. package/server/cron/job.ts +31 -0
  106. package/server/cron/jobs/balance-sync.ts +436 -0
  107. package/server/cron/jobs/incoming-scan.ts +506 -0
  108. package/server/cron/jobs/native-price.ts +70 -0
  109. package/server/cron/jobs/orphan-cleanup.ts +40 -0
  110. package/server/cron/jobs/strategy-runner.ts +175 -0
  111. package/server/cron/scheduler.ts +125 -0
  112. package/server/index.ts +420 -0
  113. package/server/lib/adapters/factory.ts +119 -0
  114. package/server/lib/adapters/index.ts +19 -0
  115. package/server/lib/adapters/router.ts +297 -0
  116. package/server/lib/adapters/telegram.ts +645 -0
  117. package/server/lib/adapters/types.ts +89 -0
  118. package/server/lib/adapters/webhook.ts +95 -0
  119. package/server/lib/address.ts +49 -0
  120. package/server/lib/agent-auth/contracts.ts +1194 -0
  121. package/server/lib/agent-profiles.ts +419 -0
  122. package/server/lib/ai.ts +285 -0
  123. package/server/lib/api-registry/contracts.ts +86 -0
  124. package/server/lib/api-registry/validation.ts +172 -0
  125. package/server/lib/apikey-migration.ts +258 -0
  126. package/server/lib/app-installer.ts +505 -0
  127. package/server/lib/app-tokens.ts +247 -0
  128. package/server/lib/approval-link.ts +27 -0
  129. package/server/lib/auth.ts +314 -0
  130. package/server/lib/auto-execute.ts +160 -0
  131. package/server/lib/batch.ts +242 -0
  132. package/server/lib/cold.ts +1048 -0
  133. package/server/lib/config.ts +408 -0
  134. package/server/lib/credential-access-audit.ts +85 -0
  135. package/server/lib/credential-access-policy.ts +111 -0
  136. package/server/lib/credential-health.ts +343 -0
  137. package/server/lib/credential-import.ts +608 -0
  138. package/server/lib/credential-scope.ts +102 -0
  139. package/server/lib/credential-shares.ts +190 -0
  140. package/server/lib/credential-transport.ts +533 -0
  141. package/server/lib/credential-vault.ts +77 -0
  142. package/server/lib/credentials.ts +422 -0
  143. package/server/lib/crypto.ts +8 -0
  144. package/server/lib/db.ts +58 -0
  145. package/server/lib/defaults.ts +386 -0
  146. package/server/lib/dex/index.ts +80 -0
  147. package/server/lib/dex/relay.ts +235 -0
  148. package/server/lib/dex/types.ts +59 -0
  149. package/server/lib/dex/uniswap.ts +370 -0
  150. package/server/lib/diary.ts +34 -0
  151. package/server/lib/dont-ask-again-policy.ts +41 -0
  152. package/server/lib/e2e-agent/artifacts.ts +36 -0
  153. package/server/lib/e2e-agent/contracts.ts +112 -0
  154. package/server/lib/e2e-agent/validation.ts +135 -0
  155. package/server/lib/encrypt.ts +114 -0
  156. package/server/lib/error.ts +20 -0
  157. package/server/lib/events.ts +217 -0
  158. package/server/lib/feature-flags.ts +93 -0
  159. package/server/lib/hot.ts +357 -0
  160. package/server/lib/human-action-summary.ts +80 -0
  161. package/server/lib/key-fingerprint.ts +28 -0
  162. package/server/lib/logger.ts +340 -0
  163. package/server/lib/network.ts +137 -0
  164. package/server/lib/notifications.ts +230 -0
  165. package/server/lib/oauth2-refresh.ts +241 -0
  166. package/server/lib/oursecret.ts +71 -0
  167. package/server/lib/passkey-credential.ts +360 -0
  168. package/server/lib/passkey.ts +68 -0
  169. package/server/lib/permissions.ts +299 -0
  170. package/server/lib/pino.ts +24 -0
  171. package/server/lib/policy-preview.ts +138 -0
  172. package/server/lib/price.ts +338 -0
  173. package/server/lib/prices.ts +34 -0
  174. package/server/lib/project-scope.ts +297 -0
  175. package/server/lib/resolve-action.ts +328 -0
  176. package/server/lib/resolve.ts +36 -0
  177. package/server/lib/secret-gist-share.ts +296 -0
  178. package/server/lib/sessions.ts +634 -0
  179. package/server/lib/socket-path.ts +56 -0
  180. package/server/lib/solana/connection.ts +26 -0
  181. package/server/lib/solana/jupiter.ts +128 -0
  182. package/server/lib/solana/transfer.ts +108 -0
  183. package/server/lib/solana/wallet.ts +136 -0
  184. package/server/lib/strategy/emits.ts +21 -0
  185. package/server/lib/strategy/engine.ts +1305 -0
  186. package/server/lib/strategy/executor.ts +115 -0
  187. package/server/lib/strategy/hook-context.ts +159 -0
  188. package/server/lib/strategy/hooks.ts +990 -0
  189. package/server/lib/strategy/index.ts +28 -0
  190. package/server/lib/strategy/installer.ts +305 -0
  191. package/server/lib/strategy/loader.ts +256 -0
  192. package/server/lib/strategy/message.ts +237 -0
  193. package/server/lib/strategy/repository.ts +218 -0
  194. package/server/lib/strategy/session-logger.ts +693 -0
  195. package/server/lib/strategy/sources.ts +288 -0
  196. package/server/lib/strategy/state.ts +189 -0
  197. package/server/lib/strategy/templates.ts +403 -0
  198. package/server/lib/strategy/tick.ts +404 -0
  199. package/server/lib/strategy/types.ts +230 -0
  200. package/server/lib/swap.ts +3 -0
  201. package/server/lib/temp.ts +86 -0
  202. package/server/lib/token-metadata.ts +86 -0
  203. package/server/lib/token-safety.ts +200 -0
  204. package/server/lib/token-search.ts +444 -0
  205. package/server/lib/totp.ts +194 -0
  206. package/server/lib/transactions.ts +123 -0
  207. package/server/lib/transport.ts +84 -0
  208. package/server/lib/txhistory/decoder.ts +262 -0
  209. package/server/lib/txhistory/enricher.ts +652 -0
  210. package/server/lib/txhistory/index.ts +391 -0
  211. package/server/lib/txhistory/signatures.ts +59 -0
  212. package/server/lib/update-check.ts +35 -0
  213. package/server/lib/verified-summary.ts +414 -0
  214. package/server/lib/view-registry.ts +80 -0
  215. package/server/mcp/profile-policy.ts +30 -0
  216. package/server/mcp/server.ts +1589 -0
  217. package/server/mcp/tools.ts +276 -0
  218. package/server/middleware/auth.ts +119 -0
  219. package/server/middleware/requestLogger.ts +84 -0
  220. package/server/routes/actions.ts +539 -0
  221. package/server/routes/adapters.ts +711 -0
  222. package/server/routes/addressbook.ts +113 -0
  223. package/server/routes/ai.ts +34 -0
  224. package/server/routes/apikeys.ts +343 -0
  225. package/server/routes/apps.ts +601 -0
  226. package/server/routes/auth.ts +406 -0
  227. package/server/routes/backup.ts +404 -0
  228. package/server/routes/batch.ts +270 -0
  229. package/server/routes/bookmarks.ts +162 -0
  230. package/server/routes/credential-shares.ts +380 -0
  231. package/server/routes/credential-vaults.ts +159 -0
  232. package/server/routes/credentials.ts +1782 -0
  233. package/server/routes/dashboard.ts +97 -0
  234. package/server/routes/defaults.ts +124 -0
  235. package/server/routes/flags.ts +11 -0
  236. package/server/routes/fund.ts +225 -0
  237. package/server/routes/heartbeat.ts +375 -0
  238. package/server/routes/import.ts +364 -0
  239. package/server/routes/launch.ts +665 -0
  240. package/server/routes/lock.ts +54 -0
  241. package/server/routes/logs.ts +68 -0
  242. package/server/routes/nuke.ts +111 -0
  243. package/server/routes/passkey-credentials.ts +99 -0
  244. package/server/routes/passkey.ts +366 -0
  245. package/server/routes/portfolio.ts +217 -0
  246. package/server/routes/price.ts +63 -0
  247. package/server/routes/resolve.ts +31 -0
  248. package/server/routes/security.ts +45 -0
  249. package/server/routes/send-evm.ts +241 -0
  250. package/server/routes/send-solana.ts +281 -0
  251. package/server/routes/send.ts +178 -0
  252. package/server/routes/setup.ts +210 -0
  253. package/server/routes/strategy.ts +894 -0
  254. package/server/routes/swap-evm.ts +352 -0
  255. package/server/routes/swap-solana.ts +176 -0
  256. package/server/routes/swap.ts +356 -0
  257. package/server/routes/token.ts +247 -0
  258. package/server/routes/unlock.ts +467 -0
  259. package/server/routes/views.ts +41 -0
  260. package/server/routes/wallet-assets.ts +361 -0
  261. package/server/routes/wallet-transactions.ts +515 -0
  262. package/server/routes/wallet.ts +709 -0
  263. package/server/types.ts +146 -0
  264. package/shared/credential-field-schema.ts +248 -0
  265. package/skills/auramaxx/HEARTBEAT.md +78 -0
  266. package/skills/auramaxx/SKILL.md +745 -0
  267. package/skills/auramaxx/docs/AGENT_SETUP.md +155 -0
  268. package/skills/auramaxx/docs/API.md +127 -0
  269. package/skills/auramaxx/docs/AUTH.md +318 -0
  270. package/skills/auramaxx/docs/CLI.md +130 -0
  271. package/skills/auramaxx/docs/MCP.md +122 -0
  272. package/skills/auramaxx/docs/TROUBLESHOOTING.md +357 -0
  273. package/skills/auramaxx/docs/WORKSPACE.md +673 -0
  274. package/skills/auramaxx/docs/security.md +227 -0
  275. package/skills/task-lifecycle/SKILL.md +378 -0
  276. package/src/app/api/[...doc]/page.tsx +36 -0
  277. package/src/app/api/agent-requests/route.ts +30 -0
  278. package/src/app/api/apps/install/route.ts +132 -0
  279. package/src/app/api/apps/manifests/route.ts +16 -0
  280. package/src/app/api/apps/static/[...path]/route.ts +57 -0
  281. package/src/app/api/docs/plain/route.ts +74 -0
  282. package/src/app/api/events/route.ts +92 -0
  283. package/src/app/api/page.tsx +290 -0
  284. package/src/app/api/workspace/[id]/apps/[wid]/route.ts +119 -0
  285. package/src/app/api/workspace/[id]/apps/route.ts +81 -0
  286. package/src/app/api/workspace/[id]/export/route.ts +67 -0
  287. package/src/app/api/workspace/[id]/route.ts +168 -0
  288. package/src/app/api/workspace/auth.ts +40 -0
  289. package/src/app/api/workspace/config/route.ts +121 -0
  290. package/src/app/api/workspace/import/route.ts +127 -0
  291. package/src/app/api/workspace/route.ts +116 -0
  292. package/src/app/app-legacy-do-not-use/page.tsx +2245 -0
  293. package/src/app/apple-icon.png +0 -0
  294. package/src/app/approve/[actionId]/page.tsx +409 -0
  295. package/src/app/docs/DocsPageContent.tsx +269 -0
  296. package/src/app/docs/[...doc]/page.tsx +41 -0
  297. package/src/app/docs/page.tsx +38 -0
  298. package/src/app/favicon.ico +0 -0
  299. package/src/app/globals.css +819 -0
  300. package/src/app/health/page.tsx +5 -0
  301. package/src/app/hello/page.tsx +102 -0
  302. package/src/app/icon.png +0 -0
  303. package/src/app/layout.tsx +39 -0
  304. package/src/app/page.tsx +1964 -0
  305. package/src/app/privacy/page.tsx +63 -0
  306. package/src/app/providers.tsx +87 -0
  307. package/src/app/share/[token]/page.tsx +295 -0
  308. package/src/app/terms/page.tsx +80 -0
  309. package/src/components/ChainSelector.tsx +44 -0
  310. package/src/components/HumanActionBar.tsx +697 -0
  311. package/src/components/NotificationDrawer.tsx +387 -0
  312. package/src/components/PasskeyEnrollmentPrompt.tsx +235 -0
  313. package/src/components/apps/AgentKeysApp.tsx +490 -0
  314. package/src/components/apps/App.tsx +153 -0
  315. package/src/components/apps/AppGrid.tsx +15 -0
  316. package/src/components/apps/DetailedAddressDrawer.tsx +325 -0
  317. package/src/components/apps/DraggableApp.tsx +562 -0
  318. package/src/components/apps/IFrameApp.tsx +73 -0
  319. package/src/components/apps/LogsApp.tsx +360 -0
  320. package/src/components/apps/SendApp.tsx +394 -0
  321. package/src/components/apps/SetupWizardApp.tsx +1004 -0
  322. package/src/components/apps/SystemDefaultsApp.tsx +845 -0
  323. package/src/components/apps/ThirdPartyApp.tsx +428 -0
  324. package/src/components/apps/TokenApp.tsx +319 -0
  325. package/src/components/apps/TransactionsApp.tsx +438 -0
  326. package/src/components/apps/WalletDetailApp.tsx +1505 -0
  327. package/src/components/apps/index.ts +13 -0
  328. package/src/components/design-system/Button.tsx +88 -0
  329. package/src/components/design-system/ChainIndicator.tsx +65 -0
  330. package/src/components/design-system/ChainSelector.tsx +147 -0
  331. package/src/components/design-system/ConfirmationModal.tsx +107 -0
  332. package/src/components/design-system/ConfirmationPopover.tsx +81 -0
  333. package/src/components/design-system/DownloadButton.tsx +149 -0
  334. package/src/components/design-system/Drawer.tsx +133 -0
  335. package/src/components/design-system/FilterDropdown.tsx +183 -0
  336. package/src/components/design-system/ItemPicker.tsx +157 -0
  337. package/src/components/design-system/Modal.tsx +296 -0
  338. package/src/components/design-system/Popover.tsx +142 -0
  339. package/src/components/design-system/TextInput.tsx +85 -0
  340. package/src/components/design-system/Toggle.tsx +65 -0
  341. package/src/components/design-system/TyvekCollapsibleSection.tsx +55 -0
  342. package/src/components/design-system/index.ts +14 -0
  343. package/src/components/docs/ClientSideMarkdown.tsx +51 -0
  344. package/src/components/docs/DocsSearchBar.tsx +118 -0
  345. package/src/components/docs/DocsThemeToggle.tsx +38 -0
  346. package/src/components/docs/PersistentDocGroup.tsx +91 -0
  347. package/src/components/docs/ShareUrlButton.tsx +33 -0
  348. package/src/components/docs/SidebarScrollMemory.tsx +56 -0
  349. package/src/components/health/CredentialHealthDashboard.tsx +214 -0
  350. package/src/components/icons/ChainIcons.tsx +72 -0
  351. package/src/components/layout/AppStoreDrawer.tsx +369 -0
  352. package/src/components/layout/ContentArea.tsx +21 -0
  353. package/src/components/layout/CreateViewModal.tsx +88 -0
  354. package/src/components/layout/LeftRail.tsx +114 -0
  355. package/src/components/layout/TabBar.tsx +284 -0
  356. package/src/components/layout/WalletSidebar.tsx +1030 -0
  357. package/src/components/layout/index.ts +6 -0
  358. package/src/components/marketing/AuraMaxxSpecOverlay.tsx +653 -0
  359. package/src/components/marketing/DeviceMorphExperience.tsx +216 -0
  360. package/src/components/vault/ApiKeysConsole.tsx +1272 -0
  361. package/src/components/vault/AuditConsole.tsx +600 -0
  362. package/src/components/vault/CredentialDetail.tsx +625 -0
  363. package/src/components/vault/CredentialEmpty.tsx +55 -0
  364. package/src/components/vault/CredentialField.tsx +583 -0
  365. package/src/components/vault/CredentialForm.tsx +1484 -0
  366. package/src/components/vault/CredentialList.tsx +265 -0
  367. package/src/components/vault/CredentialRow.tsx +130 -0
  368. package/src/components/vault/CredentialShareModal.tsx +273 -0
  369. package/src/components/vault/CredentialVault.tsx +1662 -0
  370. package/src/components/vault/CredentialWalletWidget.tsx +103 -0
  371. package/src/components/vault/DocsConsole.tsx +113 -0
  372. package/src/components/vault/ImportCredentialsModal.tsx +578 -0
  373. package/src/components/vault/LargeTypeModal.tsx +88 -0
  374. package/src/components/vault/PasswordGenerator.tsx +232 -0
  375. package/src/components/vault/TOTPDisplay.tsx +108 -0
  376. package/src/components/vault/TotpSetupPanel.tsx +198 -0
  377. package/src/components/vault/VaultSidebar.tsx +881 -0
  378. package/src/components/vault/credentialFormName.ts +91 -0
  379. package/src/components/vault/hooks/useVaultKeyboardShortcuts.ts +69 -0
  380. package/src/components/vault/types.ts +56 -0
  381. package/src/context/AuthContext.tsx +365 -0
  382. package/src/context/PriceContext.tsx +113 -0
  383. package/src/context/ThemeContext.tsx +164 -0
  384. package/src/context/WebSocketContext.tsx +269 -0
  385. package/src/context/WorkspaceContext.tsx +668 -0
  386. package/src/hooks/index.ts +4 -0
  387. package/src/hooks/useAgentActions.ts +552 -0
  388. package/src/hooks/useBalance.ts +103 -0
  389. package/src/hooks/useBalances.ts +129 -0
  390. package/src/hooks/useTheme.ts +156 -0
  391. package/src/instrumentation.ts +12 -0
  392. package/src/lib/api-docs.ts +154 -0
  393. package/src/lib/api.ts +474 -0
  394. package/src/lib/app-loader.ts +148 -0
  395. package/src/lib/app-registry.ts +178 -0
  396. package/src/lib/app-sdk.ts +157 -0
  397. package/src/lib/audit-console-adapter.ts +151 -0
  398. package/src/lib/auth-client.ts +75 -0
  399. package/src/lib/config.ts +74 -0
  400. package/src/lib/credential-field-schema.ts +11 -0
  401. package/src/lib/crypto.ts +112 -0
  402. package/src/lib/db.ts +21 -0
  403. package/src/lib/docs.ts +544 -0
  404. package/src/lib/events.ts +363 -0
  405. package/src/lib/pino.ts +24 -0
  406. package/src/lib/theme-handlers.ts +168 -0
  407. package/src/lib/theme.ts +351 -0
  408. package/src/lib/tokenData.ts +378 -0
  409. package/src/lib/totp-import.ts +57 -0
  410. package/src/lib/vault-crypto.ts +129 -0
  411. package/src/lib/view-registry.ts +57 -0
  412. package/src/lib/websocket-server.ts +302 -0
  413. package/src/lib/websocket-setup.ts +79 -0
  414. package/src/lib/wordlist.ts +2050 -0
  415. package/src/lib/workspace-handlers.ts +285 -0
  416. package/start.sh +170 -0
  417. package/tailwind.config.ts +99 -0
  418. package/tsconfig.json +42 -0
@@ -0,0 +1,1662 @@
1
+ 'use client';
2
+
3
+ import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
4
+ import { Menu, Clock3, Search } from 'lucide-react';
5
+ import { api, Api, getWalletBaseUrl, unlockWallet } from '@/lib/api';
6
+ import { encryptPassword } from '@/lib/crypto';
7
+ import { CREDENTIAL_FIELD_SCHEMA } from '@/lib/credential-field-schema';
8
+ import { Modal, TextInput, Button, Drawer, FilterDropdown } from '@/components/design-system';
9
+ import { VaultSidebar } from './VaultSidebar';
10
+ import { CredentialList } from './CredentialList';
11
+ import { CredentialDetail } from './CredentialDetail';
12
+ import { CredentialEmpty } from './CredentialEmpty';
13
+ import { CredentialForm } from './CredentialForm';
14
+ import { ImportCredentialsModal } from './ImportCredentialsModal';
15
+ import { PasswordGenerator } from './PasswordGenerator';
16
+ import { AuditConsole } from './AuditConsole';
17
+ import { ApiKeysConsole } from './ApiKeysConsole';
18
+ import { useVaultKeyboardShortcuts } from './hooks/useVaultKeyboardShortcuts';
19
+ import { HumanActionBar } from '@/components/HumanActionBar';
20
+ import { useAgentActions } from '@/hooks/useAgentActions';
21
+ import { useWebSocket } from '@/context/WebSocketContext';
22
+ import { useAuth } from '@/context/AuthContext';
23
+ import {
24
+ WALLET_EVENTS,
25
+ type WalletEvent,
26
+ type CredentialAccessedData,
27
+ type CredentialChangedData,
28
+ } from '@/lib/events';
29
+ import type {
30
+ CredentialMeta,
31
+ CredentialLifecycleFilter,
32
+ CredentialWithLocation,
33
+ VaultInfo,
34
+ VaultFilters,
35
+ } from './types';
36
+
37
+ interface CredentialVaultProps {
38
+ onLock: () => void;
39
+ onSettings?: () => void;
40
+ }
41
+
42
+ type ViewportMode = 'desktop' | 'tablet' | 'mobile';
43
+ type VaultSurface = 'credentials' | 'audit' | 'apiKeys';
44
+ type CreateCredentialStart = 'api-key-form' | 'type-picker';
45
+ type CreatePrefill = {
46
+ vaultId?: string;
47
+ tags?: string[];
48
+ type?: 'login' | 'card' | 'note' | 'plain_note' | 'hot_wallet' | 'apikey' | 'oauth2' | 'ssh' | 'gpg' | 'custom';
49
+ };
50
+
51
+ type ParsedSearchQuery = {
52
+ terms: string[];
53
+ tagFilters: string[];
54
+ typeFilters: string[];
55
+ vaultFilters: string[];
56
+ fieldFilters: Array<{ key: string; value: string }>;
57
+ lifecycleFilters: Set<CredentialLifecycleFilter>;
58
+ favoriteOnly: boolean;
59
+ };
60
+
61
+ const DEFAULT_FILTERS: VaultFilters = {
62
+ vaultId: null,
63
+ category: 'all',
64
+ tag: null,
65
+ search: '',
66
+ favoritesOnly: false,
67
+ lifecycle: 'active',
68
+ };
69
+
70
+ const VAULT_MODE_OPTIONS: { value: 'linked' | 'independent'; label: string }[] = [
71
+ { value: 'linked', label: 'Child (inherits parent unlock)' },
72
+ { value: 'independent', label: 'Independent (separate password)' },
73
+ ];
74
+
75
+ const RECENT_ACCESS_STORAGE_KEY = 'auramaxx_recently_accessed_credentials';
76
+ const LATEST_ACCESS_STORAGE_KEY = 'auramaxx_credentials_latest_access_at';
77
+ const RECENT_ACCESS_MAX = 8;
78
+ const SEARCH_FIELD_PRIORITY = [
79
+ 'username',
80
+ 'url',
81
+ 'content',
82
+ 'key',
83
+ 'value',
84
+ 'cardholder',
85
+ 'brand',
86
+ 'last4',
87
+ 'token_endpoint',
88
+ 'scopes',
89
+ 'fingerprint',
90
+ ] as const;
91
+ const LIFECYCLE_TOKEN_MAP: Record<string, CredentialLifecycleFilter> = {
92
+ active: 'active',
93
+ archive: 'archive',
94
+ archived: 'archive',
95
+ deleted: 'recently_deleted',
96
+ recently_deleted: 'recently_deleted',
97
+ recentlydeleted: 'recently_deleted',
98
+ };
99
+
100
+ const FIELD_ALIAS_TO_CANONICAL = (() => {
101
+ const lookup = new Map<string, string>();
102
+ const schemaEntries = Object.values(CREDENTIAL_FIELD_SCHEMA);
103
+ for (const fields of schemaEntries) {
104
+ for (const field of fields) {
105
+ lookup.set(field.key.toLowerCase(), field.key);
106
+ for (const alias of field.aliases || []) {
107
+ lookup.set(alias.toLowerCase(), field.key);
108
+ }
109
+ }
110
+ }
111
+ return lookup;
112
+ })();
113
+
114
+ const DEFAULT_SEARCH_FIELD_KEYS = (() => {
115
+ const fromSchema = Array.from(FIELD_ALIAS_TO_CANONICAL.values());
116
+ const ordered = [...SEARCH_FIELD_PRIORITY, ...fromSchema];
117
+ const seen = new Set<string>();
118
+ const unique: string[] = [];
119
+ for (const key of ordered) {
120
+ const normalized = key.toLowerCase();
121
+ if (seen.has(normalized)) continue;
122
+ seen.add(normalized);
123
+ unique.push(key);
124
+ }
125
+ return unique.slice(0, 8);
126
+ })();
127
+
128
+ function getViewportMode(width: number): ViewportMode {
129
+ if (width < 768) return 'mobile';
130
+ if (width < 1280) return 'tablet';
131
+ return 'desktop';
132
+ }
133
+
134
+ function getVaultParentId(vault: VaultInfo): string | undefined {
135
+ return vault.parentVaultId || vault.linkedTo;
136
+ }
137
+
138
+ function collectVaultSubtreeIds(rootVaultId: string, vaults: VaultInfo[]): string[] {
139
+ const childrenByParent = new Map<string, VaultInfo[]>();
140
+ for (const vault of vaults) {
141
+ const parentVaultId = getVaultParentId(vault);
142
+ if (!parentVaultId) continue;
143
+ const bucket = childrenByParent.get(parentVaultId) || [];
144
+ bucket.push(vault);
145
+ childrenByParent.set(parentVaultId, bucket);
146
+ }
147
+
148
+ const orderedIds: string[] = [];
149
+ const queue: string[] = [rootVaultId];
150
+ const visited = new Set<string>();
151
+ while (queue.length > 0) {
152
+ const current = queue.shift()!;
153
+ if (visited.has(current)) continue;
154
+ visited.add(current);
155
+ orderedIds.push(current);
156
+ for (const child of childrenByParent.get(current) || []) {
157
+ queue.push(child.id);
158
+ }
159
+ }
160
+
161
+ return orderedIds;
162
+ }
163
+
164
+ function deriveCredentialLifecycle(credential: { location?: CredentialLifecycleFilter; archivedAt?: string; deletedAt?: string }): CredentialLifecycleFilter {
165
+ if (credential.location) return credential.location;
166
+ if (credential.deletedAt) return 'recently_deleted';
167
+ if (credential.archivedAt) return 'archive';
168
+ return 'active';
169
+ }
170
+
171
+ function credentialCreatedAtTimestamp(credential: { createdAt?: string }): number {
172
+ const createdAtMs = credential.createdAt ? Date.parse(credential.createdAt) : Number.NaN;
173
+ return Number.isFinite(createdAtMs) ? createdAtMs : 0;
174
+ }
175
+
176
+ function effectiveCredentialAccessTimestamp(
177
+ credential: { id: string; createdAt?: string },
178
+ latestAccessById: Record<string, number>,
179
+ ): number {
180
+ const latestAccess = latestAccessById[credential.id];
181
+ if (typeof latestAccess === 'number' && Number.isFinite(latestAccess) && latestAccess > 0) {
182
+ return latestAccess;
183
+ }
184
+ return credentialCreatedAtTimestamp(credential);
185
+ }
186
+
187
+ function buildAccessMapForCredentials(
188
+ credentials: Array<{ id: string; createdAt?: string }>,
189
+ previous: Record<string, number>,
190
+ ): Record<string, number> {
191
+ const next: Record<string, number> = {};
192
+ for (const credential of credentials) {
193
+ const known = previous[credential.id];
194
+ if (typeof known === 'number' && Number.isFinite(known) && known > 0) {
195
+ next[credential.id] = known;
196
+ continue;
197
+ }
198
+ next[credential.id] = credentialCreatedAtTimestamp(credential);
199
+ }
200
+ return next;
201
+ }
202
+
203
+ function normalizeToken(value: string): string {
204
+ return value.trim().toLowerCase();
205
+ }
206
+
207
+ function toSearchString(value: unknown): string {
208
+ if (value === null || value === undefined) return '';
209
+ if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
210
+ return String(value).toLowerCase();
211
+ }
212
+ if (Array.isArray(value)) {
213
+ return value.map(toSearchString).join(' ');
214
+ }
215
+ if (typeof value === 'object') {
216
+ return Object.values(value as Record<string, unknown>).map(toSearchString).join(' ');
217
+ }
218
+ return '';
219
+ }
220
+
221
+ function normalizeFieldKeyToken(rawKey: string): string {
222
+ const key = rawKey.trim().toLowerCase().replace(/\s+/g, '_');
223
+ return FIELD_ALIAS_TO_CANONICAL.get(key) || key;
224
+ }
225
+
226
+ function parseSearchQuery(raw: string): ParsedSearchQuery {
227
+ const parsed: ParsedSearchQuery = {
228
+ terms: [],
229
+ tagFilters: [],
230
+ typeFilters: [],
231
+ vaultFilters: [],
232
+ fieldFilters: [],
233
+ lifecycleFilters: new Set<CredentialLifecycleFilter>(),
234
+ favoriteOnly: false,
235
+ };
236
+
237
+ const tokens = raw
238
+ .trim()
239
+ .split(/\s+/)
240
+ .map((token) => token.trim())
241
+ .filter(Boolean);
242
+
243
+ for (const token of tokens) {
244
+ const normalized = normalizeToken(token);
245
+ if (normalized === 'favorite' || normalized === 'favourite' || normalized === 'fav') {
246
+ parsed.favoriteOnly = true;
247
+ continue;
248
+ }
249
+
250
+ const lifecycleFromBareToken = LIFECYCLE_TOKEN_MAP[normalized];
251
+ if (lifecycleFromBareToken) {
252
+ parsed.lifecycleFilters.add(lifecycleFromBareToken);
253
+ continue;
254
+ }
255
+
256
+ const separatorIndex = token.indexOf(':');
257
+ if (separatorIndex <= 0) {
258
+ parsed.terms.push(normalized);
259
+ continue;
260
+ }
261
+
262
+ const keyToken = normalizeToken(token.slice(0, separatorIndex));
263
+ const valueToken = token.slice(separatorIndex + 1).trim();
264
+ const normalizedValue = normalizeToken(valueToken);
265
+ if (!normalizedValue) continue;
266
+
267
+ if (keyToken === 'tag') {
268
+ parsed.tagFilters.push(normalizedValue);
269
+ continue;
270
+ }
271
+ if (keyToken === 'type') {
272
+ parsed.typeFilters.push(normalizedValue);
273
+ continue;
274
+ }
275
+ if (keyToken === 'vault') {
276
+ parsed.vaultFilters.push(normalizedValue);
277
+ continue;
278
+ }
279
+ if (keyToken === 'lifecycle' || keyToken === 'location') {
280
+ const lifecycleValue = LIFECYCLE_TOKEN_MAP[normalizedValue];
281
+ if (lifecycleValue) parsed.lifecycleFilters.add(lifecycleValue);
282
+ continue;
283
+ }
284
+ if (keyToken === 'favorite' || keyToken === 'fav') {
285
+ parsed.favoriteOnly = !['false', '0', 'no'].includes(normalizedValue);
286
+ continue;
287
+ }
288
+
289
+ parsed.fieldFilters.push({
290
+ key: normalizeFieldKeyToken(keyToken),
291
+ value: normalizedValue,
292
+ });
293
+ }
294
+
295
+ return parsed;
296
+ }
297
+
298
+ function credentialMatchesField(credential: CredentialMeta, fieldKey: string, needle: string): boolean {
299
+ const normalizedFieldKey = normalizeFieldKeyToken(fieldKey);
300
+ const entries = Object.entries(credential.meta || {});
301
+ for (const [rawKey, rawValue] of entries) {
302
+ const key = normalizeFieldKeyToken(rawKey);
303
+ if (key !== normalizedFieldKey) continue;
304
+ if (toSearchString(rawValue).includes(needle)) return true;
305
+ }
306
+ return false;
307
+ }
308
+
309
+ export const CredentialVault: React.FC<CredentialVaultProps> = ({
310
+ onLock,
311
+ onSettings,
312
+ }) => {
313
+ const { subscribe } = useWebSocket();
314
+
315
+ // Core state
316
+ const [credentials, setCredentials] = useState<CredentialWithLocation[]>([]);
317
+ const [vaults, setVaults] = useState<VaultInfo[]>([]);
318
+ const [selectedId, setSelectedId] = useState<string | null>(null);
319
+ const [filters, setFilters] = useState<VaultFilters>(DEFAULT_FILTERS);
320
+ const [loading, setLoading] = useState(true);
321
+ const [surface, setSurface] = useState<VaultSurface>('credentials');
322
+ // Persisted "latest accessed" values (updated on click/read).
323
+ const [latestAccessById, setLatestAccessById] = useState<Record<string, number>>({});
324
+ // Snapshot used by list ordering so rows do not jump while user is interacting.
325
+ const [latestAccessByIdForList, setLatestAccessByIdForList] = useState<Record<string, number>>({});
326
+ const [searchDockOpen, setSearchDockOpen] = useState(false);
327
+ const [searchDockFocused, setSearchDockFocused] = useState(false);
328
+
329
+ // Viewport + mobile interactions
330
+ const [viewportMode, setViewportMode] = useState<ViewportMode>(() => {
331
+ if (typeof window === 'undefined') return 'desktop';
332
+ return getViewportMode(window.innerWidth);
333
+ });
334
+ const { clearToken, setToken } = useAuth();
335
+ const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
336
+ const [mobileDetailOpen, setMobileDetailOpen] = useState(false);
337
+
338
+ // Modal flags
339
+ const [showCreateForm, setShowCreateForm] = useState(false);
340
+ const [createCredentialStart, setCreateCredentialStart] = useState<CreateCredentialStart>('api-key-form');
341
+ const [createPrefill, setCreatePrefill] = useState<CreatePrefill | null>(null);
342
+ const [editCredentialId, setEditCredentialId] = useState<string | null>(null);
343
+ const [showCreateVault, setShowCreateVault] = useState(false);
344
+ const [newVaultName, setNewVaultName] = useState('');
345
+ const [newVaultMode, setNewVaultMode] = useState<'linked' | 'independent'>('independent');
346
+ const [newVaultParentId, setNewVaultParentId] = useState('');
347
+ const [newVaultPassword, setNewVaultPassword] = useState('');
348
+ const [creatingVault, setCreatingVault] = useState(false);
349
+ const [showImportModal, setShowImportModal] = useState(false);
350
+ const [importTargetVaultId, setImportTargetVaultId] = useState('');
351
+ const [pendingImportVaultAutoSelect, setPendingImportVaultAutoSelect] = useState(false);
352
+ const [showGenerator, setShowGenerator] = useState(false);
353
+ const [unlockVaultTarget, setUnlockVaultTarget] = useState<VaultInfo | null>(null);
354
+ const [unlockVaultPassword, setUnlockVaultPassword] = useState('');
355
+ const [unlockingVault, setUnlockingVault] = useState(false);
356
+ const [unlockVaultError, setUnlockVaultError] = useState<string | null>(null);
357
+ const [deleteVaultTarget, setDeleteVaultTarget] = useState<VaultInfo | null>(null);
358
+ const [deletingVault, setDeletingVault] = useState(false);
359
+ const [deleteVaultError, setDeleteVaultError] = useState<string | null>(null);
360
+
361
+ // Agent actions (approvals + notifications)
362
+ const {
363
+ requests,
364
+ notifications,
365
+ dismissNotification,
366
+ resolveAction,
367
+ revokeToken = async () => false,
368
+ activeTokens = [],
369
+ inactiveTokens = [],
370
+ actionLoading,
371
+ } = useAgentActions({ autoFetch: true });
372
+
373
+ // Refs for keyboard navigation
374
+ const searchRef = useRef<HTMLInputElement>(null);
375
+ const searchDockRef = useRef<HTMLDivElement>(null);
376
+ const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
377
+ const searchDockCloseTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
378
+
379
+ const isMobile = viewportMode === 'mobile';
380
+ const isTablet = viewportMode === 'tablet';
381
+
382
+ // Fetch data
383
+ const fetchData = useCallback(async () => {
384
+ try {
385
+ const [activeRes, archiveRes, deletedRes, vaultRes] = await Promise.all([
386
+ api.get<{ success: boolean; credentials: CredentialMeta[] }>(Api.Wallet, '/credentials', {
387
+ location: 'active',
388
+ }),
389
+ api.get<{ success: boolean; credentials: CredentialMeta[] }>(Api.Wallet, '/credentials', {
390
+ location: 'archive',
391
+ }),
392
+ api.get<{ success: boolean; credentials: CredentialMeta[] }>(Api.Wallet, '/credentials', {
393
+ location: 'recently_deleted',
394
+ }),
395
+ api.get<{ success: boolean; vaults: VaultInfo[] }>(Api.Wallet, '/vaults/credential'),
396
+ ]);
397
+
398
+ const withLocation = (
399
+ source: { success: boolean; credentials?: CredentialMeta[] },
400
+ location: CredentialLifecycleFilter,
401
+ ): CredentialWithLocation[] => (source.success ? (source.credentials || []).map((credential) => ({
402
+ ...credential,
403
+ location,
404
+ })) : []);
405
+
406
+ const mergedCredentials = [
407
+ ...withLocation(activeRes, 'active'),
408
+ ...withLocation(archiveRes, 'archive'),
409
+ ...withLocation(deletedRes, 'recently_deleted'),
410
+ ];
411
+ setCredentials(mergedCredentials);
412
+ setLatestAccessById((previous) => buildAccessMapForCredentials(mergedCredentials, previous));
413
+ setLatestAccessByIdForList((previous) => buildAccessMapForCredentials(mergedCredentials, previous));
414
+ if (vaultRes.success) setVaults(vaultRes.vaults);
415
+ } catch (err) {
416
+ console.error('[CredentialVault] fetch error:', err);
417
+ } finally {
418
+ setLoading(false);
419
+ }
420
+ }, []);
421
+
422
+ useEffect(() => {
423
+ fetchData();
424
+ }, [fetchData]);
425
+
426
+ useEffect(() => {
427
+ if (typeof window === 'undefined') return;
428
+ try {
429
+ const hydrated: Record<string, number> = {};
430
+
431
+ const rawLatestAccess = window.localStorage.getItem(LATEST_ACCESS_STORAGE_KEY);
432
+ if (rawLatestAccess) {
433
+ const parsedLatestAccess = JSON.parse(rawLatestAccess);
434
+ if (parsedLatestAccess && typeof parsedLatestAccess === 'object' && !Array.isArray(parsedLatestAccess)) {
435
+ for (const [credentialId, value] of Object.entries(parsedLatestAccess as Record<string, unknown>)) {
436
+ if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
437
+ hydrated[credentialId] = value;
438
+ }
439
+ }
440
+ }
441
+ }
442
+
443
+ const rawLegacyRecent = window.localStorage.getItem(RECENT_ACCESS_STORAGE_KEY);
444
+ if (rawLegacyRecent) {
445
+ const parsedLegacyRecent = JSON.parse(rawLegacyRecent);
446
+ if (Array.isArray(parsedLegacyRecent)) {
447
+ const nowMs = Date.now();
448
+ parsedLegacyRecent
449
+ .filter((value): value is string => typeof value === 'string')
450
+ .slice(0, RECENT_ACCESS_MAX)
451
+ .forEach((credentialId, index) => {
452
+ if (!hydrated[credentialId]) {
453
+ hydrated[credentialId] = nowMs - index;
454
+ }
455
+ });
456
+ }
457
+ }
458
+
459
+ setLatestAccessById((previous) => ({ ...previous, ...hydrated }));
460
+ setLatestAccessByIdForList((previous) => ({ ...previous, ...hydrated }));
461
+ } catch {
462
+ // Ignore invalid local storage payloads.
463
+ }
464
+ }, []);
465
+
466
+ useEffect(() => {
467
+ if (typeof window === 'undefined') return;
468
+ const sanitizedEntries = Object.entries(latestAccessById).filter(([, value]) => (
469
+ typeof value === 'number' && Number.isFinite(value) && value > 0
470
+ ));
471
+ const persistedLatestAccess = Object.fromEntries(sanitizedEntries);
472
+ window.localStorage.setItem(LATEST_ACCESS_STORAGE_KEY, JSON.stringify(persistedLatestAccess));
473
+
474
+ const orderedIds = sanitizedEntries
475
+ .sort((a, b) => b[1] - a[1])
476
+ .slice(0, RECENT_ACCESS_MAX)
477
+ .map(([credentialId]) => credentialId);
478
+ window.localStorage.setItem(RECENT_ACCESS_STORAGE_KEY, JSON.stringify(orderedIds));
479
+ }, [latestAccessById]);
480
+
481
+ const pushRecentCredential = useCallback((credentialId: string, accessedAtMs = Date.now()) => {
482
+ setLatestAccessById((previous) => {
483
+ const normalizedAccessMs = Number.isFinite(accessedAtMs) && accessedAtMs > 0 ? accessedAtMs : Date.now();
484
+ if (previous[credentialId] === normalizedAccessMs) return previous;
485
+ return {
486
+ ...previous,
487
+ [credentialId]: normalizedAccessMs,
488
+ };
489
+ });
490
+ }, []);
491
+
492
+ const scheduleRealtimeRefresh = useCallback(() => {
493
+ if (refreshTimerRef.current) return;
494
+ refreshTimerRef.current = setTimeout(() => {
495
+ refreshTimerRef.current = null;
496
+ void fetchData();
497
+ }, 120);
498
+ }, [fetchData]);
499
+
500
+ useEffect(() => {
501
+ const unsubscribeChanged = subscribe(WALLET_EVENTS.CREDENTIAL_CHANGED, (event) => {
502
+ const data = (event as WalletEvent).data as CredentialChangedData;
503
+ if (!data?.credentialId) return;
504
+ scheduleRealtimeRefresh();
505
+ });
506
+
507
+ const unsubscribeAccessed = subscribe(WALLET_EVENTS.CREDENTIAL_ACCESSED, (event) => {
508
+ const data = (event as WalletEvent).data as CredentialAccessedData;
509
+ if (!data?.credentialId || data.allowed !== true) return;
510
+ pushRecentCredential(data.credentialId);
511
+ });
512
+
513
+ return () => {
514
+ unsubscribeChanged();
515
+ unsubscribeAccessed();
516
+ if (refreshTimerRef.current) {
517
+ clearTimeout(refreshTimerRef.current);
518
+ refreshTimerRef.current = null;
519
+ }
520
+ if (searchDockCloseTimerRef.current) {
521
+ clearTimeout(searchDockCloseTimerRef.current);
522
+ searchDockCloseTimerRef.current = null;
523
+ }
524
+ };
525
+ }, [scheduleRealtimeRefresh, subscribe, pushRecentCredential]);
526
+
527
+ useEffect(() => {
528
+ if (selectedId && !credentials.some((credential) => credential.id === selectedId)) {
529
+ setSelectedId(null);
530
+ setMobileDetailOpen(false);
531
+ }
532
+ }, [credentials, selectedId]);
533
+
534
+ useEffect(() => {
535
+ const handleResize = () => setViewportMode(getViewportMode(window.innerWidth));
536
+ handleResize();
537
+ window.addEventListener('resize', handleResize);
538
+ return () => window.removeEventListener('resize', handleResize);
539
+ }, []);
540
+
541
+ useEffect(() => {
542
+ if (!isMobile) {
543
+ setMobileSidebarOpen(false);
544
+ setMobileDetailOpen(false);
545
+ }
546
+ }, [isMobile]);
547
+
548
+ useEffect(() => {
549
+ if (!searchDockOpen) return;
550
+
551
+ const handleOutsideClick = (event: Event) => {
552
+ const target = event.target as Node | null;
553
+ if (!target) return;
554
+ if (searchDockRef.current?.contains(target)) return;
555
+
556
+ if (searchDockCloseTimerRef.current) {
557
+ clearTimeout(searchDockCloseTimerRef.current);
558
+ searchDockCloseTimerRef.current = null;
559
+ }
560
+
561
+ setSearchDockFocused(false);
562
+ setSearchDockOpen(false);
563
+ };
564
+
565
+ document.addEventListener('mousedown', handleOutsideClick);
566
+ document.addEventListener('touchstart', handleOutsideClick);
567
+
568
+ return () => {
569
+ document.removeEventListener('mousedown', handleOutsideClick);
570
+ document.removeEventListener('touchstart', handleOutsideClick);
571
+ };
572
+ }, [searchDockOpen]);
573
+
574
+ const primaryVaultId = useMemo(
575
+ () => vaults.find((vault) => vault.isPrimary)?.id || '',
576
+ [vaults],
577
+ );
578
+
579
+ const parentVaultOptions = useMemo(
580
+ () =>
581
+ vaults.map((vault) => ({
582
+ value: vault.id,
583
+ label: vault.name || (vault.isPrimary ? 'Primary' : `Vault ${vault.id.slice(0, 6)}`),
584
+ })),
585
+ [vaults],
586
+ );
587
+
588
+ useEffect(() => {
589
+ if (!showCreateVault) return;
590
+ setNewVaultParentId((current) => {
591
+ if (current && parentVaultOptions.some((option) => option.value === current)) {
592
+ return current;
593
+ }
594
+ return filters.vaultId || primaryVaultId || parentVaultOptions[0]?.value || '';
595
+ });
596
+ }, [filters.vaultId, parentVaultOptions, primaryVaultId, showCreateVault]);
597
+
598
+ useEffect(() => {
599
+ if (vaults.length === 0) {
600
+ setImportTargetVaultId('primary');
601
+ return;
602
+ }
603
+ const defaultImportVaultId = primaryVaultId || vaults[0].id || 'primary';
604
+ setImportTargetVaultId((current) => {
605
+ if (current && vaults.some((vault) => vault.id === current)) {
606
+ return current;
607
+ }
608
+ return defaultImportVaultId;
609
+ });
610
+ }, [primaryVaultId, vaults]);
611
+
612
+ // Derived data
613
+ const selectedVaultGroupIds = useMemo<Set<string> | null>(() => {
614
+ if (!filters.vaultId) return null;
615
+ const selectedVault = vaults.find((v) => v.id === filters.vaultId);
616
+ if (!selectedVault) return new Set([filters.vaultId]);
617
+ return new Set(collectVaultSubtreeIds(selectedVault.id, vaults));
618
+ }, [filters.vaultId, vaults]);
619
+
620
+ const applyVaultGroupFilter = useCallback((items: CredentialMeta[]): CredentialMeta[] => {
621
+ if (!selectedVaultGroupIds) return items;
622
+ return items.filter((credential) => selectedVaultGroupIds.has(credential.vaultId));
623
+ }, [selectedVaultGroupIds]);
624
+
625
+ const vaultNameById = useMemo(() => {
626
+ const map = new Map<string, string>();
627
+ for (const vault of vaults) {
628
+ map.set(vault.id, (vault.name || (vault.isPrimary ? 'Primary' : `Vault ${vault.id.slice(0, 6)}`)).toLowerCase());
629
+ }
630
+ return map;
631
+ }, [vaults]);
632
+
633
+ const searchState = useMemo(() => {
634
+ const parsed = parseSearchQuery(filters.search);
635
+ const lifecycleOverride = parsed.lifecycleFilters.size > 0;
636
+ const queryTerms = parsed.terms;
637
+
638
+ let base = applyVaultGroupFilter(credentials);
639
+ base = lifecycleOverride
640
+ ? base.filter((credential) => parsed.lifecycleFilters.has(deriveCredentialLifecycle(credential)))
641
+ : base.filter((credential) => deriveCredentialLifecycle(credential) === filters.lifecycle);
642
+
643
+ if (filters.category !== 'all') {
644
+ base = base.filter((credential) => credential.type === filters.category);
645
+ }
646
+
647
+ if (filters.favoritesOnly || parsed.favoriteOnly) {
648
+ base = base.filter((credential) => !!credential.meta.favorite);
649
+ }
650
+
651
+ if (filters.tag) {
652
+ const sidebarTag = normalizeToken(filters.tag);
653
+ base = base.filter((credential) => (credential.meta.tags || []).some((tag) => normalizeToken(tag).includes(sidebarTag)));
654
+ }
655
+
656
+ if (parsed.tagFilters.length > 0) {
657
+ base = base.filter((credential) => {
658
+ const tags = (credential.meta.tags || []).map((tag) => normalizeToken(tag));
659
+ return parsed.tagFilters.every((tagFilter) => tags.some((tag) => tag.includes(tagFilter)));
660
+ });
661
+ }
662
+
663
+ if (parsed.typeFilters.length > 0) {
664
+ base = base.filter((credential) => parsed.typeFilters.some((typeFilter) => normalizeToken(credential.type).includes(typeFilter)));
665
+ }
666
+
667
+ if (parsed.vaultFilters.length > 0) {
668
+ base = base.filter((credential) => {
669
+ const vaultLabel = vaultNameById.get(credential.vaultId) || normalizeToken(credential.vaultId);
670
+ return parsed.vaultFilters.every((vaultFilter) => (
671
+ vaultLabel.includes(vaultFilter) || normalizeToken(credential.vaultId).includes(vaultFilter)
672
+ ));
673
+ });
674
+ }
675
+
676
+ const preTextFiltered = base;
677
+ let result = base;
678
+
679
+ if (parsed.fieldFilters.length > 0) {
680
+ result = result.filter((credential) => parsed.fieldFilters.every((filter) => (
681
+ credentialMatchesField(credential, filter.key, filter.value)
682
+ )));
683
+ }
684
+
685
+ if (queryTerms.length > 0) {
686
+ result = result.filter((credential) => {
687
+ const lifecycle = deriveCredentialLifecycle(credential);
688
+ const tags = (credential.meta.tags || []).map((tag) => normalizeToken(tag)).join(' ');
689
+ const vaultLabel = vaultNameById.get(credential.vaultId) || normalizeToken(credential.vaultId);
690
+ const metaContent = toSearchString(credential.meta);
691
+ const searchable = [
692
+ normalizeToken(credential.name),
693
+ normalizeToken(credential.id),
694
+ normalizeToken(credential.type),
695
+ tags,
696
+ vaultLabel,
697
+ metaContent,
698
+ ].join(' ');
699
+
700
+ return queryTerms.every((term) => {
701
+ if (term === 'favorite' || term === 'favourite' || term === 'fav') return !!credential.meta.favorite;
702
+ const lifecycleFromTerm = LIFECYCLE_TOKEN_MAP[term];
703
+ if (lifecycleFromTerm) return lifecycle === lifecycleFromTerm;
704
+ return searchable.includes(term);
705
+ });
706
+ });
707
+ }
708
+
709
+ return {
710
+ parsed,
711
+ preTextFiltered,
712
+ results: result,
713
+ };
714
+ }, [credentials, filters, applyVaultGroupFilter, vaultNameById]);
715
+
716
+ const filteredCredentials = searchState.results;
717
+
718
+ const searchFieldSuggestions = useMemo(() => {
719
+ const rawQuery = filters.search.trim();
720
+ if (!rawQuery || rawQuery.includes(':')) return [] as string[];
721
+ if (filteredCredentials.length > 0) return [] as string[];
722
+
723
+ const normalizedQuery = normalizeToken(rawQuery);
724
+ const hasNameMatch = searchState.preTextFiltered.some((credential) => (
725
+ normalizeToken(credential.name).includes(normalizedQuery)
726
+ ));
727
+ if (hasNameMatch) return [] as string[];
728
+
729
+ return DEFAULT_SEARCH_FIELD_KEYS.map((fieldKey) => `${fieldKey}:${rawQuery}`);
730
+ }, [filters.search, filteredCredentials.length, searchState.preTextFiltered]);
731
+
732
+ const categoryCounts = useMemo(() => {
733
+ // Counts apply to vault-group filtered (not category/search filtered) credentials
734
+ let base = applyVaultGroupFilter(credentials).filter((credential) => deriveCredentialLifecycle(credential) === filters.lifecycle);
735
+ if (filters.tag) {
736
+ const tag = filters.tag;
737
+ base = base.filter((c) => c.meta.tags?.includes(tag));
738
+ }
739
+ if (filters.favoritesOnly) {
740
+ base = base.filter((c) => c.meta.favorite);
741
+ }
742
+ return {
743
+ all: base.length,
744
+ login: base.filter((c) => c.type === 'login').length,
745
+ card: base.filter((c) => c.type === 'card').length,
746
+ note: base.filter((c) => c.type === 'note').length,
747
+ plain_note: base.filter((c) => c.type === 'plain_note').length,
748
+ hot_wallet: base.filter((c) => c.type === 'hot_wallet').length,
749
+ api: base.filter((c) => c.type === 'api').length,
750
+ apikey: base.filter((c) => c.type === 'apikey').length,
751
+ custom: base.filter((c) => c.type === 'custom').length,
752
+ passkey: base.filter((c) => c.type === 'passkey').length,
753
+ oauth2: base.filter((c) => c.type === 'oauth2').length,
754
+ ssh: base.filter((c) => c.type === 'ssh').length,
755
+ gpg: base.filter((c) => c.type === 'gpg').length,
756
+ };
757
+ }, [credentials, filters.lifecycle, filters.tag, filters.favoritesOnly, applyVaultGroupFilter]);
758
+
759
+ const allTags = useMemo(() => {
760
+ const tagSet = new Set<string>();
761
+ applyVaultGroupFilter(credentials)
762
+ .filter((credential) => deriveCredentialLifecycle(credential) === filters.lifecycle)
763
+ .forEach((credential) => credential.meta.tags?.forEach((tag) => tagSet.add(tag)));
764
+ return Array.from(tagSet).sort();
765
+ }, [credentials, filters.lifecycle, applyVaultGroupFilter]);
766
+
767
+ const favoritesCount = useMemo(() => {
768
+ const base = applyVaultGroupFilter(credentials).filter((credential) => deriveCredentialLifecycle(credential) === filters.lifecycle);
769
+ return base.filter((c) => c.meta.favorite).length;
770
+ }, [credentials, filters.lifecycle, applyVaultGroupFilter]);
771
+
772
+ const selectedCredential = useMemo(
773
+ () => credentials.find((c) => c.id === selectedId) || null,
774
+ [credentials, selectedId],
775
+ );
776
+
777
+ const selectedVaultName = useMemo(() => {
778
+ if (!selectedCredential) return '';
779
+ const vault = vaults.find((v) => v.id === selectedCredential.vaultId);
780
+ if (vault) return vault.name || (vault.isPrimary ? 'Primary' : vault.id.slice(0, 8));
781
+ return selectedCredential.vaultId === 'primary'
782
+ ? 'Primary'
783
+ : selectedCredential.vaultId.slice(0, 8);
784
+ }, [selectedCredential, vaults]);
785
+ const selectedCredentialLifecycle = useMemo<CredentialLifecycleFilter>(
786
+ () => (selectedCredential ? deriveCredentialLifecycle(selectedCredential) : filters.lifecycle),
787
+ [selectedCredential, filters.lifecycle],
788
+ );
789
+
790
+ const recentlyAccessedCredentials = useMemo(
791
+ () => [...credentials]
792
+ .sort((a, b) => (
793
+ effectiveCredentialAccessTimestamp(b, latestAccessById)
794
+ - effectiveCredentialAccessTimestamp(a, latestAccessById)
795
+ ))
796
+ .slice(0, RECENT_ACCESS_MAX),
797
+ [credentials, latestAccessById],
798
+ );
799
+
800
+ const closeSearchDockSoon = useCallback((delayMs = 120) => {
801
+ if (searchDockCloseTimerRef.current) clearTimeout(searchDockCloseTimerRef.current);
802
+ searchDockCloseTimerRef.current = setTimeout(() => {
803
+ if (!searchDockFocused) setSearchDockOpen(false);
804
+ searchDockCloseTimerRef.current = null;
805
+ }, delayMs);
806
+ }, [searchDockFocused]);
807
+
808
+ const unlockVaultDisplayName = useMemo(() => {
809
+ if (!unlockVaultTarget) return 'Vault';
810
+ return unlockVaultTarget.name || (unlockVaultTarget.isPrimary ? 'Primary' : `Vault ${unlockVaultTarget.id.slice(0, 6)}`);
811
+ }, [unlockVaultTarget]);
812
+ const deleteVaultDisplayName = useMemo(() => {
813
+ if (!deleteVaultTarget) return 'Vault';
814
+ return deleteVaultTarget.name || (deleteVaultTarget.isPrimary ? 'Primary' : `Vault ${deleteVaultTarget.id.slice(0, 6)}`);
815
+ }, [deleteVaultTarget]);
816
+
817
+ const hasActiveFilters = useMemo(
818
+ () =>
819
+ filters.vaultId !== null ||
820
+ filters.category !== 'all' ||
821
+ filters.tag !== null ||
822
+ filters.search.trim() !== '' ||
823
+ filters.favoritesOnly,
824
+ [filters],
825
+ );
826
+
827
+ // Handlers
828
+ const handleFilterChange = useCallback((partial: Partial<VaultFilters>) => {
829
+ setFilters((prev) => ({ ...prev, ...partial }));
830
+ }, []);
831
+
832
+ const clearAllFilters = useCallback(() => {
833
+ setFilters(DEFAULT_FILTERS);
834
+ }, []);
835
+
836
+ const handleLock = useCallback(async () => {
837
+ try {
838
+ await api.post(Api.Wallet, '/lock', {});
839
+ } catch (err) {
840
+ console.error('[CredentialVault] lock error:', err);
841
+ }
842
+ clearToken();
843
+ onLock();
844
+ }, [onLock, clearToken]);
845
+
846
+ const handleOpenUnlockVault = useCallback((vault: VaultInfo) => {
847
+ setUnlockVaultTarget(vault);
848
+ setUnlockVaultPassword('');
849
+ setUnlockVaultError(null);
850
+ }, []);
851
+
852
+ const handleCloseUnlockVault = useCallback(() => {
853
+ setUnlockVaultTarget(null);
854
+ setUnlockVaultPassword('');
855
+ setUnlockVaultError(null);
856
+ }, []);
857
+
858
+ const handleUnlockVault = useCallback(async () => {
859
+ if (!unlockVaultTarget || !unlockVaultPassword) return;
860
+
861
+ setUnlockingVault(true);
862
+ setUnlockVaultError(null);
863
+ try {
864
+ const result = await unlockWallet(unlockVaultPassword, unlockVaultTarget.id);
865
+ if (result.token) {
866
+ setToken(result.token);
867
+ }
868
+ await fetchData();
869
+ setFilters((prev) => ({
870
+ ...prev,
871
+ vaultId: unlockVaultTarget.id,
872
+ lifecycle: 'active',
873
+ tag: null,
874
+ }));
875
+ handleCloseUnlockVault();
876
+ } catch (err) {
877
+ setUnlockVaultError((err as Error).message || 'Failed to unlock vault');
878
+ } finally {
879
+ setUnlockingVault(false);
880
+ }
881
+ }, [unlockVaultPassword, unlockVaultTarget, setToken, fetchData, handleCloseUnlockVault]);
882
+
883
+ const handleLockVault = useCallback(async (vaultId: string) => {
884
+ try {
885
+ await api.post(Api.Wallet, `/vaults/credential/${encodeURIComponent(vaultId)}/lock`, {});
886
+ await fetchData();
887
+ } catch (err) {
888
+ console.error('[CredentialVault] lock vault error:', err);
889
+ }
890
+ }, [fetchData]);
891
+
892
+ const handleOpenDeleteVault = useCallback((vault: VaultInfo) => {
893
+ setDeleteVaultTarget(vault);
894
+ setDeleteVaultError(null);
895
+ }, []);
896
+
897
+ const handleCloseDeleteVault = useCallback(() => {
898
+ if (deletingVault) return;
899
+ setDeleteVaultTarget(null);
900
+ setDeleteVaultError(null);
901
+ }, [deletingVault]);
902
+
903
+ const handleDeleteVault = useCallback(async () => {
904
+ if (!deleteVaultTarget) return;
905
+
906
+ setDeletingVault(true);
907
+ setDeleteVaultError(null);
908
+ try {
909
+ await api.delete(Api.Wallet, `/vaults/credential/${encodeURIComponent(deleteVaultTarget.id)}`);
910
+ setFilters((prev) => (prev.vaultId === deleteVaultTarget.id ? { ...prev, vaultId: null, lifecycle: 'active', tag: null } : prev));
911
+ if (selectedCredential?.vaultId === deleteVaultTarget.id) {
912
+ setSelectedId(null);
913
+ setMobileDetailOpen(false);
914
+ }
915
+ await fetchData();
916
+ setDeleteVaultTarget(null);
917
+ } catch (err) {
918
+ setDeleteVaultError((err as Error).message || 'Failed to delete vault');
919
+ } finally {
920
+ setDeletingVault(false);
921
+ }
922
+ }, [deleteVaultTarget, fetchData, selectedCredential]);
923
+
924
+ const handleDelete = useCallback(async () => {
925
+ if (!selectedId) return;
926
+ try {
927
+ const location = encodeURIComponent(selectedCredentialLifecycle);
928
+ await api.delete(Api.Wallet, `/credentials/${selectedId}?location=${location}`);
929
+ setSelectedId(null);
930
+ setMobileDetailOpen(false);
931
+ fetchData();
932
+ } catch (err) {
933
+ console.error('[CredentialVault] delete error:', err);
934
+ }
935
+ }, [selectedId, selectedCredentialLifecycle, fetchData]);
936
+
937
+ const handleRestore = useCallback(async () => {
938
+ if (!selectedId || selectedCredentialLifecycle === 'active') return;
939
+ try {
940
+ await api.post(Api.Wallet, `/credentials/${selectedId}/restore`, { from: selectedCredentialLifecycle });
941
+ setSelectedId(null);
942
+ setMobileDetailOpen(false);
943
+ fetchData();
944
+ } catch (err) {
945
+ console.error('[CredentialVault] restore error:', err);
946
+ }
947
+ }, [selectedId, selectedCredentialLifecycle, fetchData]);
948
+
949
+ const handleDuplicate = useCallback(async () => {
950
+ if (!selectedId) return;
951
+ try {
952
+ const res = await api.post<{ success: boolean; credential?: { id: string } }>(Api.Wallet, `/credentials/${selectedId}/duplicate`);
953
+ await fetchData();
954
+ if (res.credential) setSelectedId(res.credential.id);
955
+ } catch (err) {
956
+ console.error('[CredentialVault] duplicate error:', err);
957
+ }
958
+ }, [selectedId, fetchData]);
959
+
960
+ const resetCreateVaultDraft = useCallback(() => {
961
+ setNewVaultName('');
962
+ setNewVaultMode('independent');
963
+ setNewVaultParentId('');
964
+ setNewVaultPassword('');
965
+ }, []);
966
+
967
+ const handleCloseCreateVault = useCallback(() => {
968
+ setShowCreateVault(false);
969
+ setPendingImportVaultAutoSelect(false);
970
+ resetCreateVaultDraft();
971
+ }, [resetCreateVaultDraft]);
972
+
973
+ const handleCreateVault = useCallback(async () => {
974
+ if (!newVaultName.trim()) return;
975
+ if (newVaultMode === 'independent' && newVaultPassword.length < 8) return;
976
+ if (newVaultMode === 'linked' && !newVaultParentId) return;
977
+
978
+ setCreatingVault(true);
979
+ try {
980
+ const payload: Record<string, unknown> = {
981
+ name: newVaultName.trim(),
982
+ mode: newVaultMode,
983
+ };
984
+
985
+ if (newVaultMode === 'linked') {
986
+ payload.parentVaultId = newVaultParentId;
987
+ } else {
988
+ const connectRes = await api.get<{ publicKey: string }>(Api.Wallet, '/auth/connect');
989
+ payload.encrypted = await encryptPassword(newVaultPassword, connectRes.publicKey);
990
+ }
991
+
992
+ const createResult = await api.post<{ success: boolean; vault?: { id?: string } }>(
993
+ Api.Wallet,
994
+ '/vaults/credential',
995
+ payload,
996
+ );
997
+ const createdVaultId = createResult?.vault?.id;
998
+ if (pendingImportVaultAutoSelect && createdVaultId) {
999
+ setImportTargetVaultId(createdVaultId);
1000
+ }
1001
+ setShowCreateVault(false);
1002
+ setPendingImportVaultAutoSelect(false);
1003
+ resetCreateVaultDraft();
1004
+ await fetchData();
1005
+ } catch (err) {
1006
+ console.error('[CredentialVault] create vault error:', err);
1007
+ } finally {
1008
+ setCreatingVault(false);
1009
+ }
1010
+ }, [
1011
+ newVaultName,
1012
+ newVaultMode,
1013
+ newVaultParentId,
1014
+ newVaultPassword,
1015
+ pendingImportVaultAutoSelect,
1016
+ fetchData,
1017
+ resetCreateVaultDraft,
1018
+ ]);
1019
+
1020
+ const handleOpenCreateVault = useCallback((parentVaultId?: string) => {
1021
+ if (parentVaultId) {
1022
+ setNewVaultMode('linked');
1023
+ setNewVaultParentId(parentVaultId);
1024
+ } else {
1025
+ setNewVaultMode('independent');
1026
+ setNewVaultParentId('');
1027
+ setNewVaultPassword('');
1028
+ }
1029
+ setShowCreateVault(true);
1030
+ }, []);
1031
+
1032
+ const handleOpenImportModal = useCallback(() => {
1033
+ setImportTargetVaultId(primaryVaultId || vaults[0]?.id || 'primary');
1034
+ setShowImportModal(true);
1035
+ }, [primaryVaultId, vaults]);
1036
+
1037
+ const handleRequestCreateVaultForImport = useCallback(() => {
1038
+ setPendingImportVaultAutoSelect(true);
1039
+ handleOpenCreateVault();
1040
+ }, [handleOpenCreateVault]);
1041
+
1042
+ const handleOpenCreateCredential = useCallback((
1043
+ start: CreateCredentialStart = 'api-key-form',
1044
+ options?: { applyFilters?: boolean; prefillType?: CreatePrefill['type'] },
1045
+ ) => {
1046
+ const nextPrefill: CreatePrefill = {};
1047
+
1048
+ if (filters.vaultId) {
1049
+ nextPrefill.vaultId = filters.vaultId;
1050
+ }
1051
+
1052
+ if (options?.applyFilters) {
1053
+ if (filters.tag) nextPrefill.tags = [filters.tag];
1054
+ if (options.prefillType) {
1055
+ nextPrefill.type = options.prefillType;
1056
+ } else if (filters.category && filters.category !== 'all') {
1057
+ nextPrefill.type = filters.category as CreatePrefill['type'];
1058
+ }
1059
+ } else if (options?.prefillType) {
1060
+ nextPrefill.type = options.prefillType;
1061
+ }
1062
+
1063
+ setEditCredentialId(null);
1064
+ setCreateCredentialStart(start);
1065
+ setCreatePrefill(Object.keys(nextPrefill).length > 0 ? nextPrefill : null);
1066
+ setShowCreateForm(true);
1067
+ }, [filters]);
1068
+
1069
+ const handleFormSaved = useCallback(async (credentialId?: string) => {
1070
+ setShowCreateForm(false);
1071
+ setEditCredentialId(null);
1072
+ setCreatePrefill(null);
1073
+ await fetchData();
1074
+ if (credentialId) {
1075
+ setSelectedId(credentialId);
1076
+ pushRecentCredential(credentialId);
1077
+ if (isMobile) setMobileDetailOpen(true);
1078
+ }
1079
+ }, [fetchData, isMobile, pushRecentCredential]);
1080
+
1081
+ const handleSelectCredential = useCallback(
1082
+ (id: string) => {
1083
+ setSelectedId(id);
1084
+ pushRecentCredential(id);
1085
+ if (isMobile) {
1086
+ setMobileDetailOpen(true);
1087
+ }
1088
+ },
1089
+ [isMobile, pushRecentCredential],
1090
+ );
1091
+
1092
+ const handleApplySearchSuggestion = useCallback((query: string) => {
1093
+ handleFilterChange({ search: query });
1094
+ setSearchDockOpen(false);
1095
+ requestAnimationFrame(() => searchRef.current?.focus());
1096
+ }, [handleFilterChange]);
1097
+
1098
+ const handleSelectRecentFromDock = useCallback((credential: CredentialWithLocation) => {
1099
+ handleFilterChange({ search: credential.name });
1100
+ handleSelectCredential(credential.id);
1101
+ setSearchDockOpen(false);
1102
+ }, [handleFilterChange, handleSelectCredential]);
1103
+
1104
+ const quickSearchHints = useMemo(() => {
1105
+ if (filters.search.trim()) return [] as string[];
1106
+ return ['tag:work', 'type:login', 'vault:primary', 'favorite', 'archived', 'recently_deleted'];
1107
+ }, [filters.search]);
1108
+
1109
+ const showSearchDockPanel = (searchDockOpen || searchDockFocused) && filters.search.trim() === '';
1110
+
1111
+ // Keyboard shortcuts
1112
+ useVaultKeyboardShortcuts({
1113
+ filteredCredentials,
1114
+ selectedId,
1115
+ isMobile,
1116
+ searchRef,
1117
+ onCreateCredential: () => handleOpenCreateCredential('api-key-form'),
1118
+ setSelectedId,
1119
+ setMobileDetailOpen,
1120
+ });
1121
+
1122
+ if (loading) {
1123
+ return (
1124
+ <div className="h-screen w-screen flex items-center justify-center bg-[var(--color-background,#f4f4f5)] relative isolate vault-surface">
1125
+ <div className="absolute inset-0 pointer-events-none z-0 overflow-hidden">
1126
+ <div className="absolute inset-0 bg-grid-adaptive bg-[size:4rem_4rem] opacity-30" />
1127
+ <div className="absolute inset-0 tyvek-texture opacity-40 mix-blend-multiply" />
1128
+ </div>
1129
+ <div className="flex flex-col items-center relative z-10">
1130
+ <div className="w-6 h-6 border-2 border-[var(--color-border,#d4d4d8)] border-t-[var(--color-text,#0a0a0a)] animate-spin" />
1131
+ <div className="mt-4 font-mono text-[10px] text-[var(--color-text-muted,#6b7280)] tracking-widest">
1132
+ LOADING VAULT
1133
+ </div>
1134
+ </div>
1135
+ </div>
1136
+ );
1137
+ }
1138
+
1139
+ return (
1140
+ <div className="relative isolate h-screen w-screen overflow-hidden flex flex-col bg-[var(--color-background,#f4f4f5)] vault-surface">
1141
+ {/* Background — sterile tyvek field (matches /app) */}
1142
+ <div className="absolute inset-0 pointer-events-none z-0 overflow-hidden">
1143
+ <div className="absolute inset-0 bg-grid-adaptive bg-[size:4rem_4rem] opacity-30" />
1144
+ <div className="absolute inset-0 tyvek-texture opacity-40 mix-blend-multiply" />
1145
+ <div className="absolute bottom-[5%] right-[5%] opacity-[0.03] select-none">
1146
+ <div className="text-[12vw] font-black leading-none text-[var(--color-text,#0a0a0a)] font-mono tracking-tighter text-right">AURAMAXX</div>
1147
+ </div>
1148
+ <div className="absolute top-10 left-[200px] w-24 h-24 border-l-4 border-t-4 border-[var(--color-text,#0a0a0a)] opacity-10">
1149
+ <div className="absolute top-2 left-2 w-3 h-3 bg-[var(--color-text,#0a0a0a)]" />
1150
+ </div>
1151
+ <div className="absolute bottom-10 right-10 w-24 h-24 border-r-4 border-b-4 border-[var(--color-text,#0a0a0a)] opacity-10 flex items-end justify-end">
1152
+ <div className="absolute bottom-2 right-2 w-3 h-3 bg-[var(--color-text,#0a0a0a)]" />
1153
+ </div>
1154
+ </div>
1155
+
1156
+ {/* Main content area */}
1157
+ <div className="relative z-10 flex-1 flex overflow-hidden">
1158
+ {/* Desktop sidebar */}
1159
+ {!isMobile && !isTablet && (
1160
+ <VaultSidebar
1161
+ vaults={vaults}
1162
+ filters={filters}
1163
+ categoryCounts={categoryCounts}
1164
+ tags={allTags}
1165
+ favoritesCount={favoritesCount}
1166
+ onFilterChange={handleFilterChange}
1167
+ onLock={handleLock}
1168
+ onLockVault={handleLockVault}
1169
+ onCreateCredential={(start, prefillType) => handleOpenCreateCredential(
1170
+ start === 'type-picker' ? 'type-picker' : 'api-key-form',
1171
+ prefillType ? { prefillType } : undefined,
1172
+ )}
1173
+ onCreateVault={handleOpenCreateVault}
1174
+ mode="desktop"
1175
+ notifications={notifications}
1176
+ onDismissNotification={dismissNotification}
1177
+ pendingActionCount={notifications.filter((n) => n.status === 'pending' && n.type !== 'notify').length}
1178
+ surface={surface}
1179
+ onSurfaceChange={setSurface}
1180
+ onSettings={onSettings}
1181
+ onDeleteVault={handleOpenDeleteVault}
1182
+ />
1183
+ )}
1184
+
1185
+ {/* Tablet: thin strip with logo + hamburger */}
1186
+ {isTablet && (
1187
+ <div className="h-full w-12 shrink-0 border-r border-[var(--color-border,#d4d4d8)] bg-[var(--color-surface,#f4f4f2)] flex flex-col items-center pt-3 gap-3 relative z-10">
1188
+ <img src="/logo.webp" alt="Aura" className="w-6 h-6 object-contain" />
1189
+ <button
1190
+ type="button"
1191
+ onClick={() => setMobileSidebarOpen(true)}
1192
+ className="flex items-center justify-center w-7 h-7 text-[var(--color-text-muted,#6b7280)] hover:text-[var(--color-text,#0a0a0a)] hover:bg-[var(--color-background-alt,#f4f4f5)] transition-colors rounded"
1193
+ aria-label="Open sidebar"
1194
+ title="Open sidebar"
1195
+ >
1196
+ <Menu size={14} />
1197
+ </button>
1198
+ </div>
1199
+ )}
1200
+
1201
+ {surface === 'audit' ? (
1202
+ <div className="flex-1 h-full overflow-hidden">
1203
+ <AuditConsole />
1204
+ </div>
1205
+ ) : surface === 'apiKeys' ? (
1206
+ <div className="flex-1 h-full overflow-hidden">
1207
+ <ApiKeysConsole
1208
+ requests={requests}
1209
+ activeTokens={activeTokens}
1210
+ inactiveTokens={inactiveTokens}
1211
+ actionLoading={actionLoading}
1212
+ onResolveAction={resolveAction}
1213
+ onRevokeToken={revokeToken}
1214
+ vaults={vaults}
1215
+ />
1216
+ </div>
1217
+ ) : (
1218
+ <>
1219
+ {/* Credential list */}
1220
+ <CredentialList
1221
+ credentials={filteredCredentials}
1222
+ latestAccessById={latestAccessByIdForList}
1223
+ selectedId={selectedId}
1224
+ searchQuery={filters.search}
1225
+ onSearchChange={(search) => handleFilterChange({ search })}
1226
+ onSelect={handleSelectCredential}
1227
+ onAdd={() => handleOpenCreateCredential('api-key-form')}
1228
+ onCreateWithFilter={hasActiveFilters ? () => handleOpenCreateCredential('api-key-form', { applyFilters: true }) : undefined}
1229
+ canAdd={filters.lifecycle === 'active'}
1230
+ onImport={handleOpenImportModal}
1231
+ canImport={filters.lifecycle === 'active'}
1232
+ onOpenGenerator={() => setShowGenerator(true)}
1233
+ onClearFilters={clearAllFilters}
1234
+ hasActiveFilters={hasActiveFilters}
1235
+ fieldSearchSuggestions={searchFieldSuggestions}
1236
+ onApplySearchSuggestion={handleApplySearchSuggestion}
1237
+ searchInputRef={searchRef}
1238
+ showSearch={false}
1239
+ className={
1240
+ isMobile
1241
+ ? 'flex-1 h-full flex flex-col pb-14 min-w-0'
1242
+ : isTablet
1243
+ ? 'w-[300px] h-full flex flex-col pb-14 border-r border-[var(--color-border,#d4d4d8)]'
1244
+ : 'w-[300px] h-full flex flex-col pb-14 border-r border-[var(--color-border,#d4d4d8)]'
1245
+ }
1246
+ leadingAction={
1247
+ isMobile ? (
1248
+ <Button
1249
+ variant="ghost"
1250
+ size="sm"
1251
+ icon={<Menu size={12} />}
1252
+ onClick={() => setMobileSidebarOpen(true)}
1253
+ className="!px-1.5 !h-8 !w-8"
1254
+ aria-label="Open sidebar"
1255
+ title="Open sidebar"
1256
+ />
1257
+ ) : undefined
1258
+ }
1259
+ />
1260
+
1261
+ {/* Detail panel (desktop + tablet) */}
1262
+ {!isMobile && (
1263
+ <div className="flex-1 h-full overflow-y-auto pb-14">
1264
+ {selectedCredential ? (
1265
+ <CredentialDetail
1266
+ credential={selectedCredential}
1267
+ vaultName={selectedVaultName}
1268
+ lifecycle={selectedCredentialLifecycle}
1269
+ onEdit={() => setEditCredentialId(selectedCredential.id)}
1270
+ onDelete={handleDelete}
1271
+ onRestore={handleRestore}
1272
+ onDuplicate={handleDuplicate}
1273
+ />
1274
+ ) : (
1275
+ <CredentialEmpty
1276
+ variant={
1277
+ filters.lifecycle !== 'active'
1278
+ ? 'empty-lifecycle'
1279
+ : credentials.filter((credential) => deriveCredentialLifecycle(credential) === 'active').length === 0
1280
+ ? 'empty-vault'
1281
+ : 'no-selection'
1282
+ }
1283
+ onAdd={filters.lifecycle === 'active' ? () => handleOpenCreateCredential('api-key-form') : undefined}
1284
+ />
1285
+ )}
1286
+ </div>
1287
+ )}
1288
+ </>
1289
+ )}
1290
+
1291
+ {surface === 'credentials' && (
1292
+ <div
1293
+ className={`absolute bottom-0 z-30 border-t border-[var(--color-border,#d4d4d8)] bg-[var(--color-surface,#ffffff)]/95 backdrop-blur-sm ${
1294
+ isMobile
1295
+ ? 'left-0 right-0 w-full'
1296
+ : `${isTablet ? 'left-12' : 'left-[200px]'} right-0`
1297
+ }`}
1298
+ >
1299
+ <div className="w-full px-3 py-2.5">
1300
+ <div ref={searchDockRef} className="relative w-full">
1301
+ {showSearchDockPanel && (
1302
+ <div
1303
+ data-testid="search-dock-panel"
1304
+ className="absolute bottom-full left-0 w-full mb-1 bg-[var(--color-surface,#ffffff)] border border-[var(--color-border-focus,#0a0a0a)] shadow-mech max-h-56 overflow-y-auto z-20"
1305
+ >
1306
+ {recentlyAccessedCredentials.length > 0 && (
1307
+ <>
1308
+ <div className="px-3 py-2 font-mono text-[9px] uppercase tracking-widest text-[var(--color-text-muted,#6b7280)] border-b border-[var(--color-border-muted,#e5e7eb)] flex items-center gap-1.5">
1309
+ <Clock3 size={11} />
1310
+ Recently Accessed
1311
+ </div>
1312
+ {recentlyAccessedCredentials.map((credential) => (
1313
+ <button
1314
+ key={credential.id}
1315
+ type="button"
1316
+ className="w-full text-left px-4 py-2.5 text-[var(--color-text-muted,#6b7280)] hover:text-[var(--color-text,#0a0a0a)] hover:bg-[var(--color-background-alt,#f4f4f5)] font-mono text-xs border-b border-[var(--color-border-muted,#e5e5e5)] last:border-0"
1317
+ onMouseDown={(event) => {
1318
+ event.preventDefault();
1319
+ handleSelectRecentFromDock(credential);
1320
+ }}
1321
+ >
1322
+ <div className="text-[var(--color-text,#0a0a0a)] truncate">{credential.name}</div>
1323
+ <div className="text-[9px] uppercase tracking-wider truncate">
1324
+ {vaultNameById.get(credential.vaultId) || credential.vaultId} · {deriveCredentialLifecycle(credential)}
1325
+ </div>
1326
+ </button>
1327
+ ))}
1328
+ </>
1329
+ )}
1330
+
1331
+ {recentlyAccessedCredentials.length === 0 && (
1332
+ <div>
1333
+ <div className="px-3 py-2 font-mono text-[9px] uppercase tracking-widest text-[var(--color-text-muted,#6b7280)] border-b border-[var(--color-border-muted,#e5e7eb)]">
1334
+ Try Structured Search
1335
+ </div>
1336
+ {quickSearchHints.map((hint) => (
1337
+ <button
1338
+ key={hint}
1339
+ type="button"
1340
+ className="w-full text-left px-4 py-2.5 text-[var(--color-text-muted,#6b7280)] hover:text-[var(--color-text,#0a0a0a)] hover:bg-[var(--color-background-alt,#f4f4f5)] font-mono text-xs border-b border-[var(--color-border-muted,#e5e5e5)] last:border-0 flex items-center gap-2 group/item"
1341
+ onMouseDown={(event) => {
1342
+ event.preventDefault();
1343
+ handleApplySearchSuggestion(hint);
1344
+ }}
1345
+ >
1346
+ <div className="w-1.5 h-1.5 flex-shrink-0 bg-[var(--color-border,#d4d4d8)] group-hover/item:bg-[var(--color-text,#0a0a0a)]" />
1347
+ <span className="min-w-0 flex-1 truncate">{hint}</span>
1348
+ </button>
1349
+ ))}
1350
+ </div>
1351
+ )}
1352
+ </div>
1353
+ )}
1354
+
1355
+ <TextInput
1356
+ compact
1357
+ leftElement={<Search size={12} />}
1358
+ placeholder="Search..."
1359
+ value={filters.search}
1360
+ onChange={(event) => handleFilterChange({ search: event.target.value })}
1361
+ inputRef={searchRef}
1362
+ onFocus={() => {
1363
+ setSearchDockFocused(true);
1364
+ }}
1365
+ onClick={() => {
1366
+ if (searchDockCloseTimerRef.current) {
1367
+ clearTimeout(searchDockCloseTimerRef.current);
1368
+ searchDockCloseTimerRef.current = null;
1369
+ }
1370
+ setSearchDockOpen(true);
1371
+ }}
1372
+ onBlur={() => {
1373
+ setSearchDockFocused(false);
1374
+ closeSearchDockSoon();
1375
+ }}
1376
+ />
1377
+ </div>
1378
+ </div>
1379
+ </div>
1380
+ )}
1381
+ </div>
1382
+
1383
+ {/* Agent action approval footer */}
1384
+ <HumanActionBar requests={requests} resolveAction={resolveAction} actionLoading={actionLoading} />
1385
+
1386
+ {/* Mobile detail drawer */}
1387
+ {isMobile && (
1388
+ <Drawer
1389
+ isOpen={mobileDetailOpen && selectedCredential != null}
1390
+ onClose={() => setMobileDetailOpen(false)}
1391
+ title={selectedCredential?.name || 'Credential'}
1392
+ subtitle={selectedVaultName || 'Credential_Detail'}
1393
+ width="full"
1394
+ >
1395
+ {selectedCredential ? (
1396
+ <>
1397
+ <Button
1398
+ variant="ghost"
1399
+ size="sm"
1400
+ onClick={() => setMobileDetailOpen(false)}
1401
+ className="mb-3"
1402
+ >
1403
+ BACK
1404
+ </Button>
1405
+ <CredentialDetail
1406
+ credential={selectedCredential}
1407
+ vaultName={selectedVaultName}
1408
+ lifecycle={selectedCredentialLifecycle}
1409
+ onEdit={() => setEditCredentialId(selectedCredential.id)}
1410
+ onDelete={handleDelete}
1411
+ onRestore={handleRestore}
1412
+ onDuplicate={handleDuplicate}
1413
+ />
1414
+ </>
1415
+ ) : (
1416
+ <CredentialEmpty
1417
+ variant={
1418
+ filters.lifecycle !== 'active'
1419
+ ? 'empty-lifecycle'
1420
+ : credentials.length === 0
1421
+ ? 'empty-vault'
1422
+ : 'no-selection'
1423
+ }
1424
+ onAdd={filters.lifecycle === 'active' ? () => handleOpenCreateCredential('api-key-form') : undefined}
1425
+ />
1426
+ )}
1427
+ </Drawer>
1428
+ )}
1429
+
1430
+ {/* Mobile / tablet sidebar overlay */}
1431
+ {(isMobile || isTablet) && mobileSidebarOpen && (
1432
+ <div className="absolute inset-0 z-40">
1433
+ <button
1434
+ type="button"
1435
+ aria-label="Close sidebar"
1436
+ onClick={() => setMobileSidebarOpen(false)}
1437
+ className="absolute inset-0 bg-[var(--color-text,#0a0a0a)]/20"
1438
+ />
1439
+ <div className="absolute left-0 top-0 h-full">
1440
+ <VaultSidebar
1441
+ vaults={vaults}
1442
+ filters={filters}
1443
+ categoryCounts={categoryCounts}
1444
+ tags={allTags}
1445
+ favoritesCount={favoritesCount}
1446
+ onFilterChange={handleFilterChange}
1447
+ onLock={handleLock}
1448
+ onLockVault={handleLockVault}
1449
+ onCreateCredential={(start, prefillType) => handleOpenCreateCredential(
1450
+ start === 'type-picker' ? 'type-picker' : 'api-key-form',
1451
+ prefillType ? { prefillType } : undefined,
1452
+ )}
1453
+ onCreateVault={handleOpenCreateVault}
1454
+ mode="mobile"
1455
+ onNavigate={() => setMobileSidebarOpen(false)}
1456
+ notifications={notifications}
1457
+ onDismissNotification={dismissNotification}
1458
+ pendingActionCount={notifications.filter((n) => n.status === 'pending' && n.type !== 'notify').length}
1459
+ surface={surface}
1460
+ onSurfaceChange={setSurface}
1461
+ onSettings={onSettings}
1462
+ onDeleteVault={handleOpenDeleteVault}
1463
+ />
1464
+ </div>
1465
+ </div>
1466
+ )}
1467
+
1468
+ {/* Delete vault modal */}
1469
+ <Modal
1470
+ isOpen={deleteVaultTarget != null}
1471
+ onClose={handleCloseDeleteVault}
1472
+ title={`Delete ${deleteVaultDisplayName}?`}
1473
+ size="sm"
1474
+ footer={(
1475
+ <div className="flex gap-2 justify-end">
1476
+ <Button
1477
+ variant="secondary"
1478
+ size="sm"
1479
+ onClick={handleCloseDeleteVault}
1480
+ disabled={deletingVault}
1481
+ >
1482
+ CANCEL
1483
+ </Button>
1484
+ <Button
1485
+ variant="danger"
1486
+ size="sm"
1487
+ onClick={handleDeleteVault}
1488
+ loading={deletingVault}
1489
+ >
1490
+ DELETE
1491
+ </Button>
1492
+ </div>
1493
+ )}
1494
+ >
1495
+ <div className="space-y-3">
1496
+ <div className="text-[10px] text-[var(--color-text-muted,#6b7280)]">
1497
+ This will permanently delete the vault and its credentials.
1498
+ </div>
1499
+ {deleteVaultError && (
1500
+ <div className="text-[10px] text-[var(--color-danger,#ef4444)] border border-[var(--color-danger,#ef4444)]/30 bg-[var(--color-danger,#ef4444)]/10 px-3 py-2">
1501
+ {deleteVaultError}
1502
+ </div>
1503
+ )}
1504
+ </div>
1505
+ </Modal>
1506
+
1507
+ {/* Unlock vault modal */}
1508
+ <Modal
1509
+ isOpen={unlockVaultTarget != null}
1510
+ onClose={handleCloseUnlockVault}
1511
+ title={`Unlock ${unlockVaultDisplayName}`}
1512
+ size="sm"
1513
+ footer={(
1514
+ <div className="flex gap-2 justify-end">
1515
+ <Button
1516
+ variant="secondary"
1517
+ size="sm"
1518
+ onClick={handleCloseUnlockVault}
1519
+ >
1520
+ CANCEL
1521
+ </Button>
1522
+ <Button
1523
+ size="sm"
1524
+ onClick={handleUnlockVault}
1525
+ loading={unlockingVault}
1526
+ disabled={!unlockVaultPassword}
1527
+ >
1528
+ UNLOCK
1529
+ </Button>
1530
+ </div>
1531
+ )}
1532
+ >
1533
+ <div className="space-y-4">
1534
+ <TextInput
1535
+ label="Vault Password"
1536
+ type="password"
1537
+ placeholder="Enter vault password"
1538
+ value={unlockVaultPassword}
1539
+ onChange={(e) => setUnlockVaultPassword(e.target.value)}
1540
+ autoFocus
1541
+ />
1542
+ {unlockVaultError && (
1543
+ <div className="text-[10px] text-[var(--color-danger,#ef4444)] border border-[var(--color-danger,#ef4444)]/30 bg-[var(--color-danger,#ef4444)]/10 px-3 py-2">
1544
+ {unlockVaultError}
1545
+ </div>
1546
+ )}
1547
+ </div>
1548
+ </Modal>
1549
+
1550
+ {/* Create / Edit credential modal */}
1551
+ <CredentialForm
1552
+ isOpen={showCreateForm || !!editCredentialId}
1553
+ onClose={() => {
1554
+ setShowCreateForm(false);
1555
+ setEditCredentialId(null);
1556
+ setCreatePrefill(null);
1557
+ }}
1558
+ onSaved={handleFormSaved}
1559
+ editCredentialId={editCredentialId ?? undefined}
1560
+ vaults={vaults}
1561
+ createStartStep={createCredentialStart === 'type-picker' ? 'type' : 'form'}
1562
+ createStartType={createCredentialStart === 'type-picker' ? undefined : 'apikey'}
1563
+ createPrefill={createPrefill ?? undefined}
1564
+ />
1565
+
1566
+ {/* Import credentials modal */}
1567
+ <ImportCredentialsModal
1568
+ isOpen={showImportModal}
1569
+ onClose={() => setShowImportModal(false)}
1570
+ onComplete={fetchData}
1571
+ vaults={vaults}
1572
+ selectedVaultId={importTargetVaultId}
1573
+ onSelectedVaultIdChange={setImportTargetVaultId}
1574
+ onAddVault={handleRequestCreateVaultForImport}
1575
+ walletBaseUrl={getWalletBaseUrl()}
1576
+ />
1577
+
1578
+ {/* Password generator modal */}
1579
+ <PasswordGenerator
1580
+ isOpen={showGenerator}
1581
+ onClose={() => setShowGenerator(false)}
1582
+ onUse={(password) => {
1583
+ setShowGenerator(false);
1584
+ navigator.clipboard.writeText(password).catch(() => {});
1585
+ }}
1586
+ />
1587
+
1588
+ {/* Create vault modal */}
1589
+ <Modal
1590
+ isOpen={showCreateVault}
1591
+ onClose={handleCloseCreateVault}
1592
+ title="New Vault"
1593
+ size="md"
1594
+ footer={(
1595
+ <div className="flex gap-2 justify-end">
1596
+ <Button
1597
+ variant="secondary"
1598
+ size="sm"
1599
+ onClick={handleCloseCreateVault}
1600
+ >
1601
+ CANCEL
1602
+ </Button>
1603
+ <Button
1604
+ size="sm"
1605
+ onClick={handleCreateVault}
1606
+ loading={creatingVault}
1607
+ disabled={
1608
+ !newVaultName.trim()
1609
+ || (newVaultMode === 'linked' && !newVaultParentId)
1610
+ || (newVaultMode === 'independent' && newVaultPassword.length < 8)
1611
+ }
1612
+ >
1613
+ CREATE
1614
+ </Button>
1615
+ </div>
1616
+ )}
1617
+ >
1618
+ <div className="space-y-4">
1619
+ <TextInput
1620
+ label="Vault Name"
1621
+ placeholder="e.g. Work, Personal"
1622
+ value={newVaultName}
1623
+ onChange={(e) => setNewVaultName(e.target.value)}
1624
+ autoFocus
1625
+ />
1626
+ <FilterDropdown
1627
+ label="Vault Type"
1628
+ options={VAULT_MODE_OPTIONS}
1629
+ value={newVaultMode}
1630
+ onChange={(value) => {
1631
+ const mode = value as 'linked' | 'independent';
1632
+ setNewVaultMode(mode);
1633
+ if (mode !== 'independent') {
1634
+ setNewVaultPassword('');
1635
+ }
1636
+ }}
1637
+ compact
1638
+ />
1639
+ {newVaultMode === 'linked' && (
1640
+ <FilterDropdown
1641
+ label="Parent Vault"
1642
+ options={parentVaultOptions}
1643
+ value={newVaultParentId}
1644
+ onChange={setNewVaultParentId}
1645
+ disabled={parentVaultOptions.length === 0}
1646
+ compact
1647
+ />
1648
+ )}
1649
+ {newVaultMode === 'independent' && (
1650
+ <TextInput
1651
+ label="Vault Password"
1652
+ type="password"
1653
+ placeholder="At least 8 characters"
1654
+ value={newVaultPassword}
1655
+ onChange={(e) => setNewVaultPassword(e.target.value)}
1656
+ />
1657
+ )}
1658
+ </div>
1659
+ </Modal>
1660
+ </div>
1661
+ );
1662
+ };