auramaxx 1.0.0-alpha.4

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 (363) hide show
  1. package/LICENSE +26 -0
  2. package/README.md +112 -0
  3. package/bin/aurawallet.js +121 -0
  4. package/docs/ADAPTERS.md +467 -0
  5. package/docs/API.md +2679 -0
  6. package/docs/APPS.md +198 -0
  7. package/docs/ARCHITECTURE.md +350 -0
  8. package/docs/AUTH.md +698 -0
  9. package/docs/BEST-PRACTICES.md +121 -0
  10. package/docs/CLI.md +61 -0
  11. package/docs/DEVELOPING-APPS.md +452 -0
  12. package/docs/EXTENSION.md +97 -0
  13. package/docs/JOBS.md +33 -0
  14. package/docs/MCP.md +76 -0
  15. package/docs/PROTOCOL.md +142 -0
  16. package/docs/SETUP.md +219 -0
  17. package/docs/WORKSPACE.md +672 -0
  18. package/docs/agent-auth.md +63 -0
  19. package/docs/aura-file.md +48 -0
  20. package/docs/credentials.md +53 -0
  21. package/docs/external/getting-started.md +65 -0
  22. package/docs/external/overview.md +45 -0
  23. package/docs/external/use-cases.md +48 -0
  24. package/docs/external/why-aura.md +35 -0
  25. package/docs/jobs/connect-agent.md +77 -0
  26. package/docs/jobs/migrate-from-dotenv.md +79 -0
  27. package/docs/jobs/recover-from-lockout.md +72 -0
  28. package/docs/jobs/secure-ci.md +63 -0
  29. package/docs/oauth2.md +42 -0
  30. package/docs/passkeys.md +60 -0
  31. package/docs/security.md +540 -0
  32. package/docs/specs/aura-open-protocol.md +61 -0
  33. package/docs/specs/aura-provider-plugin.md +24 -0
  34. package/docs/specs/aura-registry-model.md +31 -0
  35. package/docs/specs/fixtures/invalid-bad-key.aura +1 -0
  36. package/docs/specs/fixtures/invalid-bad-unicode-escape.aura +1 -0
  37. package/docs/specs/fixtures/invalid-duplicate-key.aura +2 -0
  38. package/docs/specs/fixtures/valid-basic.aura +4 -0
  39. package/docs/specs/fixtures/valid-provider-ref.aura +1 -0
  40. package/docs/specs/fixtures/valid-quoted-escapes.aura +2 -0
  41. package/docs/templates/RELEASE_NOTES_TEMPLATE.md +22 -0
  42. package/docs/totp.md +40 -0
  43. package/docs/wallet/AI.md +508 -0
  44. package/docs/wallet/DEVELOPING-STRATEGIES.md +713 -0
  45. package/docs/wallet/README.md +47 -0
  46. package/docs/wallet/STRATEGY.md +89 -0
  47. package/next.config.ts +21 -0
  48. package/package.json +151 -0
  49. package/postcss.config.mjs +8 -0
  50. package/prisma/migrations/20260214170000_baseline/migration.sql +511 -0
  51. package/prisma/migrations/20260216214537_add_passkey_model/migration.sql +18 -0
  52. package/prisma/migrations/20260217150500_add_credential_access_audit/migration.sql +31 -0
  53. package/prisma/migrations/migration_lock.toml +3 -0
  54. package/prisma/schema.prisma +447 -0
  55. package/public/logo-chevron.svg +31 -0
  56. package/public/logo-concentric.svg +31 -0
  57. package/public/logo-crosshatch.svg +39 -0
  58. package/public/logo-dashed.svg +39 -0
  59. package/public/logo-horizontal.svg +31 -0
  60. package/public/logo-m56.svg +64 -0
  61. package/public/logo.webp +0 -0
  62. package/scripts/add-app.js +245 -0
  63. package/scripts/init.sh +57 -0
  64. package/scripts/migrate-apikeys-to-credentials.ts +35 -0
  65. package/scripts/sandbox-agent-flow.sh +235 -0
  66. package/scripts/sandbox.sh +175 -0
  67. package/scripts/validate-job-docs.mjs +125 -0
  68. package/server/abi/SwapHelper.json +438 -0
  69. package/server/cli/approval.ts +447 -0
  70. package/server/cli/commands/app.ts +204 -0
  71. package/server/cli/commands/cron.ts +24 -0
  72. package/server/cli/commands/doctor.ts +1007 -0
  73. package/server/cli/commands/env.ts +456 -0
  74. package/server/cli/commands/init.ts +752 -0
  75. package/server/cli/commands/mcp.ts +125 -0
  76. package/server/cli/commands/restore.ts +314 -0
  77. package/server/cli/commands/shell-hook.ts +468 -0
  78. package/server/cli/commands/start.ts +62 -0
  79. package/server/cli/commands/status.ts +59 -0
  80. package/server/cli/commands/stop.ts +14 -0
  81. package/server/cli/commands/token.ts +180 -0
  82. package/server/cli/commands/unlock.ts +49 -0
  83. package/server/cli/commands/vault.ts +417 -0
  84. package/server/cli/index.ts +328 -0
  85. package/server/cli/lib/aura-parser.ts +64 -0
  86. package/server/cli/lib/credential-create.ts +74 -0
  87. package/server/cli/lib/credential-resolve.ts +254 -0
  88. package/server/cli/lib/dotenv-migrate.ts +116 -0
  89. package/server/cli/lib/dotenv-parser.ts +146 -0
  90. package/server/cli/lib/http.ts +91 -0
  91. package/server/cli/lib/init-steps.ts +76 -0
  92. package/server/cli/lib/local-agent-trust.ts +45 -0
  93. package/server/cli/lib/process.ts +136 -0
  94. package/server/cli/lib/prompt.ts +85 -0
  95. package/server/cli/lib/theme.ts +240 -0
  96. package/server/cli/socket.ts +570 -0
  97. package/server/cli/transport-client.ts +50 -0
  98. package/server/cron/index.ts +137 -0
  99. package/server/cron/job.ts +31 -0
  100. package/server/cron/jobs/balance-sync.ts +436 -0
  101. package/server/cron/jobs/incoming-scan.ts +506 -0
  102. package/server/cron/jobs/native-price.ts +70 -0
  103. package/server/cron/jobs/orphan-cleanup.ts +40 -0
  104. package/server/cron/jobs/strategy-runner.ts +175 -0
  105. package/server/cron/scheduler.ts +125 -0
  106. package/server/index.ts +406 -0
  107. package/server/lib/adapters/factory.ts +110 -0
  108. package/server/lib/adapters/index.ts +19 -0
  109. package/server/lib/adapters/router.ts +297 -0
  110. package/server/lib/adapters/telegram.ts +645 -0
  111. package/server/lib/adapters/types.ts +89 -0
  112. package/server/lib/adapters/webhook.ts +95 -0
  113. package/server/lib/address.ts +49 -0
  114. package/server/lib/agent-auth/contracts.ts +1194 -0
  115. package/server/lib/agent-profiles.ts +328 -0
  116. package/server/lib/ai.ts +285 -0
  117. package/server/lib/api-registry/contracts.ts +86 -0
  118. package/server/lib/api-registry/validation.ts +172 -0
  119. package/server/lib/apikey-migration.ts +189 -0
  120. package/server/lib/app-installer.ts +505 -0
  121. package/server/lib/app-tokens.ts +247 -0
  122. package/server/lib/auth.ts +314 -0
  123. package/server/lib/batch.ts +242 -0
  124. package/server/lib/cold.ts +874 -0
  125. package/server/lib/config.ts +381 -0
  126. package/server/lib/credential-access-audit.ts +85 -0
  127. package/server/lib/credential-access-policy.ts +110 -0
  128. package/server/lib/credential-health.ts +343 -0
  129. package/server/lib/credential-import.ts +487 -0
  130. package/server/lib/credential-scope.ts +87 -0
  131. package/server/lib/credential-shares.ts +190 -0
  132. package/server/lib/credential-transport.ts +342 -0
  133. package/server/lib/credential-vault.ts +77 -0
  134. package/server/lib/credentials.ts +333 -0
  135. package/server/lib/crypto.ts +8 -0
  136. package/server/lib/db.ts +15 -0
  137. package/server/lib/defaults.ts +366 -0
  138. package/server/lib/dex/index.ts +80 -0
  139. package/server/lib/dex/relay.ts +235 -0
  140. package/server/lib/dex/types.ts +59 -0
  141. package/server/lib/dex/uniswap.ts +370 -0
  142. package/server/lib/e2e-agent/artifacts.ts +36 -0
  143. package/server/lib/e2e-agent/contracts.ts +112 -0
  144. package/server/lib/e2e-agent/validation.ts +135 -0
  145. package/server/lib/encrypt.ts +128 -0
  146. package/server/lib/error.ts +20 -0
  147. package/server/lib/events.ts +205 -0
  148. package/server/lib/hot.ts +357 -0
  149. package/server/lib/key-fingerprint.ts +28 -0
  150. package/server/lib/logger.ts +331 -0
  151. package/server/lib/network.ts +137 -0
  152. package/server/lib/notifications.ts +219 -0
  153. package/server/lib/oauth2-refresh.ts +241 -0
  154. package/server/lib/oursecret.ts +54 -0
  155. package/server/lib/passkey-credential.ts +360 -0
  156. package/server/lib/passkey.ts +68 -0
  157. package/server/lib/permissions.ts +248 -0
  158. package/server/lib/pino.ts +24 -0
  159. package/server/lib/policy-preview.ts +138 -0
  160. package/server/lib/price.ts +338 -0
  161. package/server/lib/prices.ts +34 -0
  162. package/server/lib/project-scope.ts +239 -0
  163. package/server/lib/resolve-action.ts +427 -0
  164. package/server/lib/resolve.ts +36 -0
  165. package/server/lib/sessions.ts +632 -0
  166. package/server/lib/solana/connection.ts +26 -0
  167. package/server/lib/solana/jupiter.ts +128 -0
  168. package/server/lib/solana/transfer.ts +108 -0
  169. package/server/lib/solana/wallet.ts +136 -0
  170. package/server/lib/strategy/emits.ts +21 -0
  171. package/server/lib/strategy/engine.ts +1305 -0
  172. package/server/lib/strategy/executor.ts +115 -0
  173. package/server/lib/strategy/hook-context.ts +158 -0
  174. package/server/lib/strategy/hooks.ts +990 -0
  175. package/server/lib/strategy/index.ts +28 -0
  176. package/server/lib/strategy/installer.ts +305 -0
  177. package/server/lib/strategy/loader.ts +256 -0
  178. package/server/lib/strategy/message.ts +235 -0
  179. package/server/lib/strategy/repository.ts +218 -0
  180. package/server/lib/strategy/session-logger.ts +693 -0
  181. package/server/lib/strategy/sources.ts +288 -0
  182. package/server/lib/strategy/state.ts +189 -0
  183. package/server/lib/strategy/templates.ts +403 -0
  184. package/server/lib/strategy/tick.ts +404 -0
  185. package/server/lib/strategy/types.ts +230 -0
  186. package/server/lib/swap.ts +3 -0
  187. package/server/lib/temp.ts +86 -0
  188. package/server/lib/token-metadata.ts +86 -0
  189. package/server/lib/token-safety.ts +200 -0
  190. package/server/lib/token-search.ts +444 -0
  191. package/server/lib/totp.ts +194 -0
  192. package/server/lib/transactions.ts +123 -0
  193. package/server/lib/transport.ts +75 -0
  194. package/server/lib/txhistory/decoder.ts +262 -0
  195. package/server/lib/txhistory/enricher.ts +652 -0
  196. package/server/lib/txhistory/index.ts +391 -0
  197. package/server/lib/txhistory/signatures.ts +59 -0
  198. package/server/lib/verified-summary.ts +421 -0
  199. package/server/mcp/profile-policy.ts +30 -0
  200. package/server/mcp/server.ts +619 -0
  201. package/server/mcp/tools.ts +523 -0
  202. package/server/middleware/auth.ts +119 -0
  203. package/server/middleware/requestLogger.ts +84 -0
  204. package/server/routes/actions.ts +459 -0
  205. package/server/routes/adapters.ts +703 -0
  206. package/server/routes/addressbook.ts +113 -0
  207. package/server/routes/ai.ts +34 -0
  208. package/server/routes/apikeys.ts +295 -0
  209. package/server/routes/apps.ts +601 -0
  210. package/server/routes/auth.ts +457 -0
  211. package/server/routes/backup.ts +340 -0
  212. package/server/routes/batch.ts +270 -0
  213. package/server/routes/bookmarks.ts +162 -0
  214. package/server/routes/credential-shares.ts +198 -0
  215. package/server/routes/credential-vaults.ts +154 -0
  216. package/server/routes/credentials.ts +1290 -0
  217. package/server/routes/dashboard.ts +71 -0
  218. package/server/routes/defaults.ts +124 -0
  219. package/server/routes/fund.ts +229 -0
  220. package/server/routes/import.ts +352 -0
  221. package/server/routes/launch.ts +665 -0
  222. package/server/routes/lock.ts +54 -0
  223. package/server/routes/logs.ts +68 -0
  224. package/server/routes/nuke.ts +111 -0
  225. package/server/routes/passkey-credentials.ts +99 -0
  226. package/server/routes/passkey.ts +346 -0
  227. package/server/routes/portfolio.ts +217 -0
  228. package/server/routes/price.ts +63 -0
  229. package/server/routes/resolve.ts +31 -0
  230. package/server/routes/security.ts +45 -0
  231. package/server/routes/send-evm.ts +241 -0
  232. package/server/routes/send-solana.ts +281 -0
  233. package/server/routes/send.ts +178 -0
  234. package/server/routes/setup.ts +210 -0
  235. package/server/routes/strategy.ts +894 -0
  236. package/server/routes/swap-evm.ts +353 -0
  237. package/server/routes/swap-solana.ts +177 -0
  238. package/server/routes/swap.ts +356 -0
  239. package/server/routes/token.ts +247 -0
  240. package/server/routes/unlock.ts +403 -0
  241. package/server/routes/wallet-assets.ts +361 -0
  242. package/server/routes/wallet-transactions.ts +515 -0
  243. package/server/routes/wallet.ts +710 -0
  244. package/server/types.ts +146 -0
  245. package/skills/aurawallet/SKILL.md +739 -0
  246. package/skills/aurawallet-setup/SKILL.md +74 -0
  247. package/skills/security-review/SKILL.md +148 -0
  248. package/src/app/api/agent-requests/route.ts +30 -0
  249. package/src/app/api/apps/install/route.ts +126 -0
  250. package/src/app/api/apps/manifests/route.ts +16 -0
  251. package/src/app/api/apps/static/[...path]/route.ts +57 -0
  252. package/src/app/api/events/route.ts +92 -0
  253. package/src/app/api/page.tsx +212 -0
  254. package/src/app/api/workspace/[id]/apps/[wid]/route.ts +119 -0
  255. package/src/app/api/workspace/[id]/apps/route.ts +81 -0
  256. package/src/app/api/workspace/[id]/export/route.ts +67 -0
  257. package/src/app/api/workspace/[id]/route.ts +168 -0
  258. package/src/app/api/workspace/auth.ts +34 -0
  259. package/src/app/api/workspace/config/route.ts +106 -0
  260. package/src/app/api/workspace/import/route.ts +127 -0
  261. package/src/app/api/workspace/route.ts +116 -0
  262. package/src/app/app/page.tsx +2122 -0
  263. package/src/app/apple-icon.png +0 -0
  264. package/src/app/docs/page.tsx +178 -0
  265. package/src/app/favicon.ico +0 -0
  266. package/src/app/globals.css +572 -0
  267. package/src/app/health/page.tsx +5 -0
  268. package/src/app/hello/page.tsx +15 -0
  269. package/src/app/icon.png +0 -0
  270. package/src/app/layout.tsx +34 -0
  271. package/src/app/page.tsx +986 -0
  272. package/src/app/providers.tsx +90 -0
  273. package/src/app/share/[token]/page.tsx +295 -0
  274. package/src/components/ChainSelector.tsx +144 -0
  275. package/src/components/HumanActionBar.tsx +695 -0
  276. package/src/components/NotificationDrawer.tsx +129 -0
  277. package/src/components/apps/AgentKeysApp.tsx +490 -0
  278. package/src/components/apps/App.tsx +153 -0
  279. package/src/components/apps/AppGrid.tsx +15 -0
  280. package/src/components/apps/DetailedAddressDrawer.tsx +325 -0
  281. package/src/components/apps/DraggableApp.tsx +562 -0
  282. package/src/components/apps/IFrameApp.tsx +73 -0
  283. package/src/components/apps/LogsApp.tsx +360 -0
  284. package/src/components/apps/SendApp.tsx +394 -0
  285. package/src/components/apps/SetupWizardApp.tsx +1004 -0
  286. package/src/components/apps/SystemDefaultsApp.tsx +845 -0
  287. package/src/components/apps/ThirdPartyApp.tsx +428 -0
  288. package/src/components/apps/TokenApp.tsx +319 -0
  289. package/src/components/apps/TransactionsApp.tsx +438 -0
  290. package/src/components/apps/WalletDetailApp.tsx +1505 -0
  291. package/src/components/apps/index.ts +13 -0
  292. package/src/components/design-system/Button.tsx +53 -0
  293. package/src/components/design-system/ChainIndicator.tsx +65 -0
  294. package/src/components/design-system/ChainSelector.tsx +137 -0
  295. package/src/components/design-system/ConfirmationModal.tsx +106 -0
  296. package/src/components/design-system/ConfirmationPopover.tsx +81 -0
  297. package/src/components/design-system/Drawer.tsx +123 -0
  298. package/src/components/design-system/FilterDropdown.tsx +72 -0
  299. package/src/components/design-system/Modal.tsx +206 -0
  300. package/src/components/design-system/Popover.tsx +142 -0
  301. package/src/components/design-system/TextInput.tsx +85 -0
  302. package/src/components/design-system/Toggle.tsx +58 -0
  303. package/src/components/design-system/index.ts +11 -0
  304. package/src/components/docs/DocsThemeToggle.tsx +49 -0
  305. package/src/components/health/CredentialHealthDashboard.tsx +214 -0
  306. package/src/components/icons/ChainIcons.tsx +72 -0
  307. package/src/components/layout/AppStoreDrawer.tsx +369 -0
  308. package/src/components/layout/ContentArea.tsx +21 -0
  309. package/src/components/layout/TabBar.tsx +278 -0
  310. package/src/components/layout/WalletSidebar.tsx +1033 -0
  311. package/src/components/layout/index.ts +4 -0
  312. package/src/components/marketing/AuraWalletSpecOverlay.tsx +635 -0
  313. package/src/components/marketing/DeviceMorphExperience.tsx +216 -0
  314. package/src/components/vault/ApiKeysConsole.tsx +1080 -0
  315. package/src/components/vault/AuditConsole.tsx +584 -0
  316. package/src/components/vault/CredentialDetail.tsx +455 -0
  317. package/src/components/vault/CredentialEmpty.tsx +55 -0
  318. package/src/components/vault/CredentialField.tsx +361 -0
  319. package/src/components/vault/CredentialForm.tsx +1212 -0
  320. package/src/components/vault/CredentialList.tsx +165 -0
  321. package/src/components/vault/CredentialRow.tsx +97 -0
  322. package/src/components/vault/CredentialShareModal.tsx +178 -0
  323. package/src/components/vault/CredentialVault.tsx +754 -0
  324. package/src/components/vault/CredentialWalletWidget.tsx +103 -0
  325. package/src/components/vault/ImportCredentialsModal.tsx +515 -0
  326. package/src/components/vault/LargeTypeModal.tsx +64 -0
  327. package/src/components/vault/PasswordGenerator.tsx +224 -0
  328. package/src/components/vault/TOTPDisplay.tsx +123 -0
  329. package/src/components/vault/VaultSidebar.tsx +413 -0
  330. package/src/components/vault/types.ts +54 -0
  331. package/src/context/AuthContext.tsx +337 -0
  332. package/src/context/PriceContext.tsx +113 -0
  333. package/src/context/ThemeContext.tsx +164 -0
  334. package/src/context/WebSocketContext.tsx +269 -0
  335. package/src/context/WorkspaceContext.tsx +668 -0
  336. package/src/hooks/index.ts +3 -0
  337. package/src/hooks/useAgentActions.ts +368 -0
  338. package/src/hooks/useBalance.ts +103 -0
  339. package/src/hooks/useBalances.ts +129 -0
  340. package/src/instrumentation.ts +12 -0
  341. package/src/lib/api.ts +449 -0
  342. package/src/lib/app-loader.ts +148 -0
  343. package/src/lib/app-registry.ts +178 -0
  344. package/src/lib/app-sdk.ts +157 -0
  345. package/src/lib/audit-console-adapter.ts +151 -0
  346. package/src/lib/auth-client.ts +75 -0
  347. package/src/lib/config.ts +74 -0
  348. package/src/lib/crypto.ts +112 -0
  349. package/src/lib/db.ts +21 -0
  350. package/src/lib/docs.ts +390 -0
  351. package/src/lib/events.ts +361 -0
  352. package/src/lib/pino.ts +24 -0
  353. package/src/lib/theme-handlers.ts +168 -0
  354. package/src/lib/theme.ts +351 -0
  355. package/src/lib/tokenData.ts +378 -0
  356. package/src/lib/vault-crypto.ts +129 -0
  357. package/src/lib/websocket-server.ts +302 -0
  358. package/src/lib/websocket-setup.ts +79 -0
  359. package/src/lib/wordlist.ts +2050 -0
  360. package/src/lib/workspace-handlers.ts +285 -0
  361. package/start.sh +80 -0
  362. package/tailwind.config.ts +99 -0
  363. package/tsconfig.json +42 -0
@@ -0,0 +1,1212 @@
1
+ 'use client';
2
+
3
+ import React, { useState, useEffect, useCallback, useRef } from 'react';
4
+ import { Key, Eye, Loader2, CreditCard, FileText, RefreshCw } from 'lucide-react';
5
+ import { Button, TextInput, FilterDropdown, Modal, Toggle } from '@/components/design-system';
6
+ import { api, Api } from '@/lib/api';
7
+ import { decryptCredentialPayload } from '@/lib/vault-crypto';
8
+ import { PasswordGenerator } from './PasswordGenerator';
9
+ import type { VaultInfo, CredentialMeta, WalletLinkMetaV1 } from './types';
10
+
11
+ interface CredentialFormProps {
12
+ isOpen: boolean;
13
+ onClose: () => void;
14
+ onSaved: () => void;
15
+ editCredentialId?: string;
16
+ vaults: VaultInfo[];
17
+ }
18
+
19
+ type FormType = 'login' | 'card' | 'note' | 'apikey' | 'oauth2' | 'ssh' | 'gpg';
20
+
21
+ const TYPE_OPTIONS = [
22
+ { value: 'login', label: 'Login' },
23
+ { value: 'card', label: 'Card' },
24
+ { value: 'note', label: 'Note' },
25
+ { value: 'apikey', label: 'API Key' },
26
+ { value: 'oauth2', label: 'OAuth2' },
27
+ { value: 'ssh', label: 'SSH Key' },
28
+ { value: 'gpg', label: 'GPG Key' },
29
+ ];
30
+
31
+ const TYPE_CARDS: Array<{
32
+ value: FormType;
33
+ label: string;
34
+ description: string;
35
+ icon: React.ComponentType<{ size?: number; className?: string }>;
36
+ }> = [
37
+ {
38
+ value: 'login',
39
+ label: 'Login',
40
+ description: 'Websites and app accounts',
41
+ icon: Key,
42
+ },
43
+ {
44
+ value: 'card',
45
+ label: 'Credit Card',
46
+ description: 'Payment cards and details',
47
+ icon: CreditCard,
48
+ },
49
+ {
50
+ value: 'note',
51
+ label: 'Secure Note',
52
+ description: 'Private notes and secrets',
53
+ icon: FileText,
54
+ },
55
+ {
56
+ value: 'apikey',
57
+ label: 'API Key',
58
+ description: 'Simple key/value secret',
59
+ icon: Key,
60
+ },
61
+ {
62
+ value: 'oauth2',
63
+ label: 'OAuth2',
64
+ description: 'Auto-refreshing OAuth2 tokens',
65
+ icon: RefreshCw,
66
+ },
67
+ {
68
+ value: 'ssh',
69
+ label: 'SSH Key',
70
+ description: 'Private/public keypair for SSH',
71
+ icon: Key,
72
+ },
73
+ {
74
+ value: 'gpg',
75
+ label: 'GPG Key',
76
+ description: 'Armored key material and metadata',
77
+ icon: FileText,
78
+ },
79
+ ];
80
+
81
+ const BRAND_OPTIONS = [
82
+ { value: 'visa', label: 'Visa' },
83
+ { value: 'mastercard', label: 'Mastercard' },
84
+ { value: 'amex', label: 'Amex' },
85
+ { value: 'discover', label: 'Discover' },
86
+ { value: 'other', label: 'Other' },
87
+ ];
88
+
89
+ const normalizeForRank = (value: string) => value.trim().toLowerCase();
90
+
91
+ const toTimestamp = (updatedAt?: string, createdAt?: string) => {
92
+ const updated = updatedAt ? Date.parse(updatedAt) : Number.NaN;
93
+ if (Number.isFinite(updated)) return updated;
94
+ const created = createdAt ? Date.parse(createdAt) : Number.NaN;
95
+ if (Number.isFinite(created)) return created;
96
+ return 0;
97
+ };
98
+
99
+ export const CredentialForm: React.FC<CredentialFormProps> = ({
100
+ isOpen,
101
+ onClose,
102
+ onSaved,
103
+ editCredentialId,
104
+ vaults,
105
+ }) => {
106
+ const [type, setType] = useState<FormType>('login');
107
+ const [createStep, setCreateStep] = useState<'type' | 'form'>('type');
108
+ const [name, setName] = useState('');
109
+ const [vaultId, setVaultId] = useState('');
110
+ const [favorite, setFavorite] = useState(false);
111
+ const [tags, setTags] = useState<string[]>([]);
112
+ const [tagInput, setTagInput] = useState('');
113
+
114
+ // Login fields
115
+ const [url, setUrl] = useState('');
116
+ const [username, setUsername] = useState('');
117
+ const [password, setPassword] = useState('');
118
+ const [loginNotes, setLoginNotes] = useState('');
119
+ const [totpSecret, setTotpSecret] = useState('');
120
+
121
+ // Card fields
122
+ const [cardholder, setCardholder] = useState('');
123
+ const [brand, setBrand] = useState('visa');
124
+ const [cardNumber, setCardNumber] = useState('');
125
+ const [expiry, setExpiry] = useState('');
126
+ const [cvv, setCvv] = useState('');
127
+ const [billingZip, setBillingZip] = useState('');
128
+ const [cardNotes, setCardNotes] = useState('');
129
+
130
+ // Note fields
131
+ const [noteContent, setNoteContent] = useState('');
132
+
133
+ // API key fields
134
+ const [apiKeyName, setApiKeyName] = useState('');
135
+ const [apiKeyValue, setApiKeyValue] = useState('');
136
+
137
+ // OAuth2 fields
138
+ const [oauth2TokenEndpoint, setOauth2TokenEndpoint] = useState('');
139
+ const [oauth2ClientId, setOauth2ClientId] = useState('');
140
+ const [oauth2ClientSecret, setOauth2ClientSecret] = useState('');
141
+ const [oauth2AccessToken, setOauth2AccessToken] = useState('');
142
+ const [oauth2RefreshToken, setOauth2RefreshToken] = useState('');
143
+ const [oauth2Scopes, setOauth2Scopes] = useState('');
144
+ const [oauth2AuthMethod, setOauth2AuthMethod] = useState('client_secret_post');
145
+ const [oauth2ExpiresAt, setOauth2ExpiresAt] = useState('');
146
+
147
+
148
+ // SSH fields
149
+ const [sshPublicKey, setSshPublicKey] = useState('');
150
+ const [sshPrivateKey, setSshPrivateKey] = useState('');
151
+ const [sshPassphrase, setSshPassphrase] = useState('');
152
+ const [sshHostsInput, setSshHostsInput] = useState('');
153
+ const [sshKeyType, setSshKeyType] = useState('');
154
+
155
+ // GPG fields
156
+ const [gpgPublicKey, setGpgPublicKey] = useState('');
157
+ const [gpgPrivateKey, setGpgPrivateKey] = useState('');
158
+ const [gpgKeyId, setGpgKeyId] = useState('');
159
+ const [gpgUidEmail, setGpgUidEmail] = useState('');
160
+ const [gpgExpiresAt, setGpgExpiresAt] = useState('');
161
+
162
+ // Preserve any existing wallet link metadata during edits.
163
+ const [walletLink, setWalletLink] = useState<WalletLinkMetaV1 | null>(null);
164
+
165
+ const [saving, setSaving] = useState(false);
166
+ const [error, setError] = useState<string | null>(null);
167
+ const [showPasswordGen, setShowPasswordGen] = useState(false);
168
+ const [showPasswordActions, setShowPasswordActions] = useState(false);
169
+ const [typeAdvancedOpen, setTypeAdvancedOpen] = useState<Record<FormType, boolean>>({
170
+ login: false,
171
+ card: false,
172
+ note: false,
173
+ apikey: false,
174
+ oauth2: false,
175
+ ssh: false,
176
+ gpg: false,
177
+ });
178
+ const [showGlobalAdvanced, setShowGlobalAdvanced] = useState(false);
179
+ const [showUsernameSuggestions, setShowUsernameSuggestions] = useState(false);
180
+ const [usernameSuggestions, setUsernameSuggestions] = useState<string[]>([]);
181
+ const [tagSuggestions, setTagSuggestions] = useState<string[]>([]);
182
+ const [revealingField, setRevealingField] = useState<string | null>(null);
183
+ const [sensitiveFieldCache, setSensitiveFieldCache] = useState<Record<string, string> | null>(null);
184
+ const [dirtySensitiveFields, setDirtySensitiveFields] = useState<Record<string, boolean>>({});
185
+ const passwordActionsRef = useRef<HTMLDivElement | null>(null);
186
+
187
+ const isEdit = !!editCredentialId;
188
+
189
+ const setSensitiveFieldValue = useCallback((fieldKey: string, value: string) => {
190
+ switch (fieldKey) {
191
+ case 'password':
192
+ setPassword(value);
193
+ break;
194
+ case 'notes':
195
+ if (type === 'login') setLoginNotes(value);
196
+ if (type === 'card') setCardNotes(value);
197
+ break;
198
+ case 'number':
199
+ setCardNumber(value);
200
+ break;
201
+ case 'expiry':
202
+ setExpiry(value);
203
+ break;
204
+ case 'cvv':
205
+ setCvv(value);
206
+ break;
207
+ case 'content':
208
+ setNoteContent(value);
209
+ break;
210
+ case 'value':
211
+ setApiKeyValue(value);
212
+ break;
213
+ case 'access_token':
214
+ setOauth2AccessToken(value);
215
+ break;
216
+ case 'refresh_token':
217
+ setOauth2RefreshToken(value);
218
+ break;
219
+ case 'client_id':
220
+ setOauth2ClientId(value);
221
+ break;
222
+ case 'client_secret':
223
+ setOauth2ClientSecret(value);
224
+ break;
225
+ case 'private_key':
226
+ if (type === 'ssh') setSshPrivateKey(value);
227
+ if (type === 'gpg') setGpgPrivateKey(value);
228
+ break;
229
+ case 'passphrase':
230
+ setSshPassphrase(value);
231
+ break;
232
+ default:
233
+ break;
234
+ }
235
+ }, [type]);
236
+
237
+ const markSensitiveDirty = useCallback((fieldKey: string) => {
238
+ setDirtySensitiveFields((prev) => ({ ...prev, [fieldKey]: true }));
239
+ }, []);
240
+
241
+ // Default vault
242
+ useEffect(() => {
243
+ if (vaults.length > 0 && !vaultId) {
244
+ const primary = vaults.find((v) => v.isPrimary);
245
+ setVaultId(primary?.id || vaults[0].id);
246
+ }
247
+ }, [vaults, vaultId]);
248
+
249
+ // Load existing credential for editing
250
+ const loadCredential = useCallback(async () => {
251
+ if (!editCredentialId) return;
252
+ try {
253
+ const res = await api.get<{ success: boolean; credential: CredentialMeta }>(Api.Wallet, `/credentials/${editCredentialId}`);
254
+ const cred = res.credential;
255
+ setName(cred.name);
256
+ setType(cred.type as FormType);
257
+ setVaultId(cred.vaultId);
258
+ setFavorite(cred.meta.favorite || false);
259
+ setTags(cred.meta.tags || []);
260
+ const existingWalletLink = cred.meta.walletLink as WalletLinkMetaV1 | undefined;
261
+ setWalletLink(existingWalletLink?.walletAddress && (existingWalletLink.tier === 'hot' || existingWalletLink.tier === 'cold') ? existingWalletLink : null);
262
+
263
+ if (cred.type === 'login') {
264
+ setUrl(cred.meta.url || '');
265
+ setUsername(cred.meta.username || '');
266
+ } else if (cred.type === 'card') {
267
+ setCardholder(cred.meta.cardholder || '');
268
+ setBrand(cred.meta.brand || 'visa');
269
+ setBillingZip((cred.meta.billing_zip as string) || '');
270
+ } else if (cred.type === 'apikey') {
271
+ setApiKeyName((cred.meta.key as string) || '');
272
+ } else if (cred.type === 'oauth2') {
273
+ setOauth2TokenEndpoint((cred.meta.token_endpoint as string) || '');
274
+ setOauth2Scopes((cred.meta.scopes as string) || '');
275
+ setOauth2AuthMethod((cred.meta.auth_method as string) || 'client_secret_post');
276
+ setOauth2ExpiresAt(
277
+ typeof cred.meta.expires_at === 'number'
278
+ ? String(cred.meta.expires_at)
279
+ : typeof cred.meta.expires_at === 'string'
280
+ ? cred.meta.expires_at
281
+ : '',
282
+ );
283
+
284
+ } else if (cred.type === 'ssh') {
285
+ setSshPublicKey((cred.meta.public_key as string) || '');
286
+ setSshHostsInput(Array.isArray(cred.meta.hosts) ? (cred.meta.hosts as string[]).join('\n') : '');
287
+ setSshKeyType((cred.meta.key_type as string) || '');
288
+ } else if (cred.type === 'gpg') {
289
+ setGpgPublicKey((cred.meta.public_key as string) || '');
290
+ setGpgKeyId((cred.meta.key_id as string) || '');
291
+ setGpgUidEmail((cred.meta.uid_email as string) || '');
292
+ setGpgExpiresAt((cred.meta.expires_at as string) || '');
293
+ }
294
+ } catch (err) {
295
+ setError(err instanceof Error ? err.message : 'Failed to load credential');
296
+ }
297
+ }, [editCredentialId]);
298
+
299
+ const getSensitiveFieldMap = useCallback(async (): Promise<Record<string, string>> => {
300
+ if (!isEdit || !editCredentialId) return {};
301
+ if (sensitiveFieldCache) return sensitiveFieldCache;
302
+
303
+ const res = await api.post<{ encrypted: string }>(Api.Wallet, `/credentials/${editCredentialId}/read`);
304
+ const plaintext = await decryptCredentialPayload(res.encrypted);
305
+ const parsed = JSON.parse(plaintext) as { fields?: Array<{ key: string; value: string }> };
306
+ const fieldMap: Record<string, string> = {};
307
+
308
+ (parsed.fields || []).forEach((field) => {
309
+ fieldMap[field.key] = field.value;
310
+ });
311
+
312
+ setSensitiveFieldCache(fieldMap);
313
+ return fieldMap;
314
+ }, [isEdit, editCredentialId, sensitiveFieldCache]);
315
+
316
+ const handleRevealField = useCallback(async (fieldKey: string) => {
317
+ if (!isEdit) return;
318
+ setRevealingField(fieldKey);
319
+ setError(null);
320
+ try {
321
+ const fieldMap = await getSensitiveFieldMap();
322
+ setSensitiveFieldValue(fieldKey, fieldMap[fieldKey] || '');
323
+ } catch (err) {
324
+ setError(err instanceof Error ? err.message : 'Decryption failed -- try re-unlocking');
325
+ } finally {
326
+ setRevealingField(null);
327
+ }
328
+ }, [getSensitiveFieldMap, isEdit, setSensitiveFieldValue]);
329
+
330
+ useEffect(() => {
331
+ if (isOpen) {
332
+ setCreateStep(isEdit ? 'form' : 'type');
333
+ setTypeAdvancedOpen({ login: false, card: false, note: false, apikey: false, oauth2: false, ssh: false, gpg: false });
334
+ setShowGlobalAdvanced(false);
335
+ setShowPasswordActions(false);
336
+ }
337
+ }, [isOpen, isEdit]);
338
+
339
+ useEffect(() => {
340
+ if (!isOpen || isEdit || type !== 'oauth2' || oauth2ExpiresAt) return;
341
+ setOauth2ExpiresAt(String(Math.floor(Date.now() / 1000) + 60 * 60));
342
+ }, [isOpen, isEdit, type, oauth2ExpiresAt]);
343
+
344
+ useEffect(() => {
345
+ if (!isOpen) return;
346
+
347
+ const loadSuggestions = async () => {
348
+ try {
349
+ const res = await api.get<{ success: boolean; credentials: CredentialMeta[] }>(Api.Wallet, '/credentials');
350
+ const creds = res.credentials || [];
351
+
352
+ const loginCreds = creds.filter((c) => c.type === 'login');
353
+ const usernameStats = new Map<string, { raw: string; frequency: number; recent: number }>();
354
+
355
+ loginCreds.forEach((credential) => {
356
+ const raw = String(credential.meta.username || '').trim();
357
+ if (!raw) return;
358
+ const normalized = normalizeForRank(raw);
359
+ const timestamp = toTimestamp(credential.updatedAt, credential.createdAt);
360
+ const existing = usernameStats.get(normalized);
361
+ if (!existing) {
362
+ usernameStats.set(normalized, { raw, frequency: 1, recent: timestamp });
363
+ return;
364
+ }
365
+ existing.frequency += 1;
366
+ if (timestamp > existing.recent) {
367
+ existing.recent = timestamp;
368
+ existing.raw = raw;
369
+ }
370
+ });
371
+
372
+ const usernames = Array.from(usernameStats.entries())
373
+ .sort((a, b) => {
374
+ if (b[1].recent !== a[1].recent) return b[1].recent - a[1].recent;
375
+ if (b[1].frequency !== a[1].frequency) return b[1].frequency - a[1].frequency;
376
+ return a[0].localeCompare(b[0]);
377
+ })
378
+ .map(([, value]) => value.raw)
379
+ .slice(0, 20);
380
+
381
+ const tagStats = new Map<string, { frequency: number; recent: number }>();
382
+ creds.forEach((credential) => {
383
+ const timestamp = toTimestamp(credential.updatedAt, credential.createdAt);
384
+ const uniqueTags = new Set((Array.isArray(credential.meta.tags) ? credential.meta.tags : [])
385
+ .map((t) => String(t).trim())
386
+ .filter(Boolean));
387
+ uniqueTags.forEach((tag) => {
388
+ const normalized = normalizeForRank(tag);
389
+ const existing = tagStats.get(normalized);
390
+ if (!existing) {
391
+ tagStats.set(normalized, { frequency: 1, recent: timestamp });
392
+ return;
393
+ }
394
+ existing.frequency += 1;
395
+ if (timestamp > existing.recent) existing.recent = timestamp;
396
+ });
397
+ });
398
+
399
+ const tagsFromVault = Array.from(tagStats.entries())
400
+ .sort((a, b) => {
401
+ if (b[1].recent !== a[1].recent) return b[1].recent - a[1].recent;
402
+ if (b[1].frequency !== a[1].frequency) return b[1].frequency - a[1].frequency;
403
+ return a[0].localeCompare(b[0]);
404
+ })
405
+ .map(([tag]) => tag)
406
+ .slice(0, 30);
407
+
408
+ setUsernameSuggestions(usernames);
409
+ setTagSuggestions(tagsFromVault);
410
+ } catch {
411
+ setUsernameSuggestions([]);
412
+ setTagSuggestions([]);
413
+ }
414
+ };
415
+
416
+ void loadSuggestions();
417
+ }, [isOpen]);
418
+
419
+ useEffect(() => {
420
+ if (isOpen && editCredentialId) {
421
+ setSensitiveFieldCache(null);
422
+ setDirtySensitiveFields({});
423
+ loadCredential();
424
+ }
425
+ }, [isOpen, editCredentialId, loadCredential]);
426
+
427
+ useEffect(() => {
428
+ if (!isOpen) {
429
+ setType('login');
430
+ setCreateStep('type');
431
+ setName('');
432
+ setVaultId('');
433
+ setFavorite(false);
434
+ setTags([]);
435
+ setTagInput('');
436
+ setUrl('');
437
+ setUsername('');
438
+ setPassword('');
439
+ setLoginNotes('');
440
+ setCardholder('');
441
+ setBrand('visa');
442
+ setCardNumber('');
443
+ setExpiry('');
444
+ setCvv('');
445
+ setBillingZip('');
446
+ setCardNotes('');
447
+ setNoteContent('');
448
+ setApiKeyName('');
449
+ setApiKeyValue('');
450
+ setOauth2TokenEndpoint('');
451
+ setOauth2ClientId('');
452
+ setOauth2ClientSecret('');
453
+ setOauth2AccessToken('');
454
+ setOauth2RefreshToken('');
455
+ setOauth2Scopes('');
456
+ setOauth2AuthMethod('client_secret_post');
457
+ setOauth2ExpiresAt('');
458
+ setSshPublicKey('');
459
+ setSshPrivateKey('');
460
+ setSshPassphrase('');
461
+ setSshHostsInput('');
462
+ setSshKeyType('');
463
+ setGpgPublicKey('');
464
+ setGpgPrivateKey('');
465
+ setGpgKeyId('');
466
+ setGpgUidEmail('');
467
+ setGpgExpiresAt('');
468
+ setWalletLink(null);
469
+ setError(null);
470
+ setSaving(false);
471
+ setShowPasswordActions(false);
472
+ setRevealingField(null);
473
+ setSensitiveFieldCache(null);
474
+ setDirtySensitiveFields({});
475
+ setTypeAdvancedOpen({ login: false, card: false, note: false, apikey: false, oauth2: false, ssh: false, gpg: false });
476
+ setShowGlobalAdvanced(false);
477
+ setShowUsernameSuggestions(false);
478
+ }
479
+ }, [isOpen]);
480
+
481
+ useEffect(() => {
482
+ if (type !== 'login') {
483
+ setShowPasswordActions(false);
484
+ }
485
+ }, [type]);
486
+
487
+ const vaultOptions = vaults.map((v) => ({
488
+ value: v.id,
489
+ label: v.name || (v.isPrimary ? 'Primary' : v.id.slice(0, 8)),
490
+ }));
491
+
492
+ const handleTagKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
493
+ if (e.key === 'Enter') {
494
+ e.preventDefault();
495
+ const tag = tagInput.trim();
496
+ if (tag && !tags.includes(tag)) {
497
+ setTags([...tags, tag]);
498
+ }
499
+ setTagInput('');
500
+ }
501
+ };
502
+
503
+ const removeTag = (tag: string) => {
504
+ setTags(tags.filter((t) => t !== tag));
505
+ };
506
+
507
+ const visibleTagSuggestions = tagSuggestions
508
+ .filter((tag) => !tags.includes(tag))
509
+ .filter((tag) => !tagInput.trim() || tag.toLowerCase().includes(tagInput.trim().toLowerCase()))
510
+ .slice(0, 6);
511
+
512
+ const buildPayload = () => {
513
+ const resolvedName = name.trim() || (type === 'apikey' ? apiKeyName.trim() : '');
514
+ const fields: { key: string; value: string; type: string; sensitive: boolean }[] = [];
515
+ let meta: Record<string, unknown> = { tags, favorite };
516
+
517
+ switch (type) {
518
+ case 'login':
519
+ if (url) fields.push({ key: 'url', value: url, type: 'text', sensitive: false });
520
+ fields.push({ key: 'username', value: username, type: 'text', sensitive: false });
521
+ fields.push({ key: 'password', value: password, type: 'secret', sensitive: true });
522
+ fields.push({ key: 'notes', value: loginNotes, type: 'text', sensitive: true });
523
+ if (totpSecret.trim()) {
524
+ fields.push({ key: 'totp', value: totpSecret.trim().replace(/\s+/g, '').toUpperCase(), type: 'secret', sensitive: true });
525
+ }
526
+ meta = { ...meta, url, username };
527
+ break;
528
+ case 'card':
529
+ fields.push({ key: 'cardholder', value: cardholder, type: 'text', sensitive: false });
530
+ fields.push({ key: 'number', value: cardNumber, type: 'text', sensitive: true });
531
+ fields.push({ key: 'cvv', value: cvv, type: 'secret', sensitive: true });
532
+ fields.push({ key: 'expiry', value: expiry, type: 'text', sensitive: true });
533
+ fields.push({ key: 'notes', value: cardNotes, type: 'text', sensitive: true });
534
+ meta = { ...meta, brand, cardholder, last4: cardNumber.slice(-4), billing_zip: billingZip };
535
+ break;
536
+ case 'note':
537
+ fields.push({ key: 'content', value: noteContent, type: 'text', sensitive: true });
538
+ break;
539
+ case 'apikey':
540
+ fields.push({ key: 'key', value: apiKeyName, type: 'text', sensitive: false });
541
+ fields.push({ key: 'value', value: apiKeyValue, type: 'secret', sensitive: true });
542
+ meta = { ...meta, key: apiKeyName };
543
+ break;
544
+ case 'oauth2': {
545
+ const expiresAtSeconds = Number.parseInt(oauth2ExpiresAt, 10);
546
+ fields.push({ key: 'access_token', value: oauth2AccessToken, type: 'secret', sensitive: true });
547
+ fields.push({ key: 'refresh_token', value: oauth2RefreshToken, type: 'secret', sensitive: true });
548
+ fields.push({ key: 'client_id', value: oauth2ClientId, type: 'secret', sensitive: true });
549
+ fields.push({ key: 'client_secret', value: oauth2ClientSecret, type: 'secret', sensitive: true });
550
+ meta = {
551
+ ...meta,
552
+ token_endpoint: oauth2TokenEndpoint,
553
+ scopes: oauth2Scopes,
554
+ auth_method: oauth2AuthMethod,
555
+ expires_at: Number.isFinite(expiresAtSeconds) ? expiresAtSeconds : null,
556
+ };
557
+ break;
558
+ }
559
+
560
+ case 'ssh': {
561
+ const hosts = sshHostsInput
562
+ .split(/\n|,/)
563
+ .map((host) => host.trim())
564
+ .filter(Boolean);
565
+ fields.push({ key: 'private_key', value: sshPrivateKey, type: 'secret', sensitive: true });
566
+ if (sshPassphrase.trim()) fields.push({ key: 'passphrase', value: sshPassphrase, type: 'secret', sensitive: true });
567
+ if (sshPublicKey.trim()) fields.push({ key: 'public_key', value: sshPublicKey, type: 'text', sensitive: false });
568
+ meta = { ...meta, public_key: sshPublicKey, hosts, key_type: sshKeyType || undefined };
569
+ break;
570
+ }
571
+ case 'gpg':
572
+ fields.push({ key: 'private_key', value: gpgPrivateKey, type: 'secret', sensitive: true });
573
+ if (gpgPublicKey.trim()) fields.push({ key: 'public_key', value: gpgPublicKey, type: 'text', sensitive: false });
574
+ meta = { ...meta, public_key: gpgPublicKey, key_id: gpgKeyId, uid_email: gpgUidEmail, expires_at: gpgExpiresAt || undefined };
575
+ break;
576
+ }
577
+
578
+ if (walletLink) {
579
+ meta = { ...meta, walletLink };
580
+ }
581
+
582
+ return { vaultId, type, name: resolvedName, fields, meta };
583
+ };
584
+
585
+ const handleSave = async () => {
586
+ const hasHumanName = !!name.trim();
587
+ const canDeriveApiKeyName = type === 'apikey' && !!apiKeyName.trim();
588
+ if (!hasHumanName && !canDeriveApiKeyName) {
589
+ setError('Name is required');
590
+ return;
591
+ }
592
+ if (type === 'login' && !isEdit && !password.trim()) {
593
+ setError('Password is required for login');
594
+ return;
595
+ }
596
+ if (type === 'apikey' && (!apiKeyName.trim() || !apiKeyValue.trim())) {
597
+ setError('Key and value are required for API key');
598
+ return;
599
+ }
600
+ if (type === 'ssh' && !sshPrivateKey.trim() && !isEdit) {
601
+ setError('SSH private key is required');
602
+ return;
603
+ }
604
+ if (type === 'gpg' && !gpgPrivateKey.trim() && !isEdit) {
605
+ setError('GPG private key is required');
606
+ return;
607
+ }
608
+ if (type === 'oauth2') {
609
+ if (!oauth2TokenEndpoint.trim()) {
610
+ setError('OAuth2 requires a token endpoint. Add a valid token endpoint URL.');
611
+ return;
612
+ }
613
+
614
+ const expiresAtNumber = Number.parseInt(oauth2ExpiresAt, 10);
615
+ if (!Number.isFinite(expiresAtNumber) || expiresAtNumber <= 0) {
616
+ setError('OAuth2 expiry is invalid. Enter a positive unix timestamp in seconds.');
617
+ return;
618
+ }
619
+ }
620
+ setSaving(true);
621
+ setError(null);
622
+
623
+ try {
624
+ const payload = buildPayload();
625
+
626
+ if (isEdit) {
627
+ // Only submit changed sensitive fields for edit.
628
+ const editFields = payload.fields.filter((f) => !f.sensitive || !!dirtySensitiveFields[f.key]);
629
+ await api.put(Api.Wallet, `/credentials/${editCredentialId}`, {
630
+ ...payload,
631
+ fields: editFields,
632
+ });
633
+ } else {
634
+ await api.post(Api.Wallet, '/credentials', payload);
635
+ }
636
+
637
+ onSaved();
638
+ onClose();
639
+ } catch (err) {
640
+ setError(err instanceof Error ? err.message : 'Failed to save credential');
641
+ } finally {
642
+ setSaving(false);
643
+ }
644
+ };
645
+
646
+ const textareaClassName =
647
+ 'w-full h-24 bg-[var(--color-background-alt,#f4f4f5)] border border-[var(--color-border,#d4d4d8)] font-mono text-sm p-3 resize-none focus:border-[var(--color-border-focus,#0a0a0a)] outline-none text-[var(--color-text,#0a0a0a)] placeholder-[var(--color-text-muted,#6b7280)]';
648
+
649
+ const selectedTypeLabel = TYPE_OPTIONS.find((opt) => opt.value === type)?.label || 'Login';
650
+
651
+ return (
652
+ <>
653
+ <Modal
654
+ isOpen={isOpen}
655
+ onClose={onClose}
656
+ title={isEdit ? 'Edit Credential' : createStep === 'type' ? 'Choose Item Type' : 'New Credential'}
657
+ subtitle="Credential_Form"
658
+ size="lg"
659
+ >
660
+ {!isEdit && createStep === 'type' ? (
661
+ <div className="space-y-4">
662
+ <p className="font-mono text-[10px] uppercase tracking-widest text-[var(--color-text-muted,#6b7280)]">
663
+ Select what you want to add
664
+ </p>
665
+ <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-2">
666
+ {TYPE_CARDS.map(({ value, label, description, icon: Icon }) => (
667
+ <button
668
+ key={value}
669
+ type="button"
670
+ data-testid={`type-card-${value}`}
671
+ onClick={() => {
672
+ setType(value);
673
+ setError(null);
674
+ setCreateStep('form');
675
+ }}
676
+ className="text-left border border-[var(--color-border,#d4d4d8)] bg-[var(--color-background-alt,#f4f4f5)] hover:border-[var(--color-border-focus,#0a0a0a)] transition-colors px-3 py-3"
677
+ >
678
+ <div className="w-8 h-8 mb-2 border border-[var(--color-border,#d4d4d8)] bg-[var(--color-surface,#ffffff)] flex items-center justify-center">
679
+ <Icon size={14} className="text-[var(--color-text,#0a0a0a)]" />
680
+ </div>
681
+ <div className="font-mono text-[10px] font-bold uppercase tracking-widest text-[var(--color-text,#0a0a0a)]">
682
+ {label}
683
+ </div>
684
+ <div className="mt-1 font-mono text-[9px] text-[var(--color-text-muted,#6b7280)]">
685
+ {description}
686
+ </div>
687
+ </button>
688
+ ))}
689
+ </div>
690
+ <div className="flex justify-end gap-2 pt-1">
691
+ <Button variant="secondary" size="sm" onClick={onClose}>
692
+ Cancel
693
+ </Button>
694
+ </div>
695
+ </div>
696
+ ) : (
697
+ <div data-testid="credential-form-layout" className="space-y-4 gap-4">
698
+ {/* Top row: type + name (+ vault in create) */}
699
+ {isEdit ? (
700
+ <div className="flex gap-3">
701
+ <div className="w-32 flex-shrink-0">
702
+ <FilterDropdown
703
+ options={TYPE_OPTIONS}
704
+ value={type}
705
+ onChange={(v) => setType(v as FormType)}
706
+ label="Type"
707
+ />
708
+ </div>
709
+ <div className="flex-1">
710
+ <TextInput
711
+ label="Name"
712
+ value={name}
713
+ onChange={(e) => setName(e.target.value)}
714
+ placeholder="Credential name"
715
+ required
716
+ />
717
+ </div>
718
+ </div>
719
+ ) : (
720
+ <div className="space-y-2">
721
+ <div className="flex items-center justify-between px-1">
722
+ <span className="font-mono text-[9px] font-bold uppercase tracking-widest text-[var(--color-text-muted,#6b7280)]">
723
+ Type: {selectedTypeLabel}
724
+ </span>
725
+ <button
726
+ type="button"
727
+ onClick={() => setCreateStep('type')}
728
+ className="font-mono text-[8px] uppercase tracking-widest text-[var(--color-text-muted,#6b7280)] hover:text-[var(--color-text,#0a0a0a)] transition-colors"
729
+ >
730
+ Change
731
+ </button>
732
+ </div>
733
+ <div data-testid="create-name-vault-row" className="flex flex-col gap-3 sm:flex-row sm:items-end">
734
+ <div className="flex-1">
735
+ <TextInput
736
+ label="Name"
737
+ value={name}
738
+ onChange={(e) => setName(e.target.value)}
739
+ placeholder="Credential name"
740
+ required
741
+ />
742
+ </div>
743
+ {vaultOptions.length > 0 && (
744
+ <div className="sm:w-48 sm:flex-shrink-0">
745
+ <FilterDropdown
746
+ options={vaultOptions}
747
+ value={vaultId}
748
+ onChange={setVaultId}
749
+ label="Vault"
750
+ />
751
+ </div>
752
+ )}
753
+ </div>
754
+ </div>
755
+ )}
756
+
757
+ {/* Vault selector */}
758
+ {isEdit && vaultOptions.length > 0 && (
759
+ <FilterDropdown
760
+ options={vaultOptions}
761
+ value={vaultId}
762
+ onChange={setVaultId}
763
+ label="Vault"
764
+ />
765
+ )}
766
+
767
+ {/* Type-specific fields */}
768
+ {type === 'login' && (
769
+ <div className="space-y-3">
770
+ <TextInput
771
+ label="Username"
772
+ value={username}
773
+ onChange={(e) => setUsername(e.target.value)}
774
+ onFocus={() => setShowUsernameSuggestions(true)}
775
+ onBlur={() => setTimeout(() => setShowUsernameSuggestions(false), 50)}
776
+ placeholder="user@example.com"
777
+ list="login-username-suggestions"
778
+ />
779
+ {showUsernameSuggestions && usernameSuggestions.length > 0 && (
780
+ <datalist id="login-username-suggestions">
781
+ {usernameSuggestions.map((suggestion) => (
782
+ <option key={suggestion} value={suggestion} />
783
+ ))}
784
+ </datalist>
785
+ )}
786
+
787
+ <TextInput
788
+ label="Website"
789
+ value={url}
790
+ onChange={(e) => setUrl(e.target.value)}
791
+ placeholder="https://example.com"
792
+ />
793
+
794
+ <div className="relative">
795
+ <TextInput
796
+ label="Password"
797
+ type="password"
798
+ value={password}
799
+ onChange={(e) => {
800
+ setPassword(e.target.value);
801
+ if (isEdit) markSensitiveDirty('password');
802
+ }}
803
+ onFocus={() => setShowPasswordActions(true)}
804
+ onClick={() => setShowPasswordActions(true)}
805
+ onBlur={(e) => {
806
+ const nextTarget = e.relatedTarget as Node | null;
807
+ if (nextTarget && passwordActionsRef.current?.contains(nextTarget)) return;
808
+ setShowPasswordActions(false);
809
+ }}
810
+ placeholder={isEdit ? '\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022' : ''}
811
+ rightElement={
812
+ isEdit ? (
813
+ <button
814
+ type="button"
815
+ onClick={() => { void handleRevealField('password'); }}
816
+ aria-label="Reveal Password"
817
+ className="text-[var(--color-text-muted,#6b7280)] hover:text-[var(--color-text,#0a0a0a)] transition-colors"
818
+ >
819
+ {revealingField === 'password' ? <Loader2 size={11} className="animate-spin" /> : <Eye size={12} />}
820
+ </button>
821
+ ) : undefined
822
+ }
823
+ />
824
+ {showPasswordActions && (
825
+ <div
826
+ ref={passwordActionsRef}
827
+ className="absolute right-0 top-full mt-1 z-20 min-w-[13rem] border border-[var(--color-border,#d4d4d8)] bg-[var(--color-surface,#ffffff)] shadow-[2px_2px_0_rgba(10,10,10,0.08)]"
828
+ >
829
+ <button
830
+ type="button"
831
+ onMouseDown={(e) => e.preventDefault()}
832
+ onClick={() => {
833
+ setShowPasswordGen(true);
834
+ setShowPasswordActions(false);
835
+ }}
836
+ className="w-full text-left px-3 py-2 font-mono text-[9px] uppercase tracking-widest text-[var(--color-text,#0a0a0a)] hover:bg-[var(--color-background-alt,#f4f4f5)]"
837
+ >
838
+ Generate New Password
839
+ </button>
840
+ </div>
841
+ )}
842
+ </div>
843
+
844
+ <button
845
+ type="button"
846
+ onClick={() => setTypeAdvancedOpen((prev) => ({ ...prev, login: !prev.login }))}
847
+ className="font-mono text-[9px] uppercase tracking-widest text-[var(--color-text-muted,#6b7280)] hover:text-[var(--color-text,#0a0a0a)] text-left"
848
+ >
849
+ {typeAdvancedOpen.login ? 'Hide Advanced Fields' : 'Show Advanced Fields'}
850
+ </button>
851
+
852
+ {typeAdvancedOpen.login && (
853
+ <div className="space-y-3 border border-[var(--color-border,#d4d4d8)] p-3 bg-[var(--color-background-alt,#f4f4f5)]">
854
+ <div>
855
+ <div className="flex items-center justify-between mb-1.5 px-1">
856
+ <label className="block font-mono text-[9px] font-bold uppercase tracking-widest text-[var(--color-text-muted,#6b7280)]">
857
+ Notes
858
+ </label>
859
+ {isEdit && (
860
+ <button
861
+ type="button"
862
+ onClick={() => { void handleRevealField('notes'); }}
863
+ aria-label="Reveal Notes"
864
+ className="font-mono text-[8px] uppercase tracking-widest text-[var(--color-text-muted,#6b7280)] hover:text-[var(--color-text,#0a0a0a)] transition-colors inline-flex items-center gap-1"
865
+ >
866
+ {revealingField === 'notes' ? <Loader2 size={9} className="animate-spin" /> : <Eye size={9} />}
867
+ Reveal
868
+ </button>
869
+ )}
870
+ </div>
871
+ <textarea
872
+ className={textareaClassName}
873
+ value={loginNotes}
874
+ onChange={(e) => {
875
+ setLoginNotes(e.target.value);
876
+ if (isEdit) markSensitiveDirty('notes');
877
+ }}
878
+ placeholder={isEdit ? '\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022' : ''}
879
+ />
880
+ </div>
881
+ <TextInput
882
+ label="TOTP Secret (2FA)"
883
+ value={totpSecret}
884
+ onChange={(e) => setTotpSecret(e.target.value)}
885
+ placeholder="Base32 secret (e.g. JBSWY3DPEHPK3PXP)"
886
+ type="password"
887
+ />
888
+ </div>
889
+ )}
890
+ </div>
891
+ )}
892
+
893
+ {type === 'card' && (
894
+ <div className="space-y-3">
895
+ <TextInput
896
+ label="Cardholder"
897
+ value={cardholder}
898
+ onChange={(e) => setCardholder(e.target.value)}
899
+ placeholder="John Doe"
900
+ />
901
+ <TextInput
902
+ label="Card Number"
903
+ value={cardNumber}
904
+ onChange={(e) => {
905
+ setCardNumber(e.target.value);
906
+ if (isEdit) markSensitiveDirty('number');
907
+ }}
908
+ placeholder={isEdit ? '\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022' : ''}
909
+ />
910
+ <div className="flex gap-3">
911
+ <div className="flex-1">
912
+ <TextInput
913
+ label="Expiry"
914
+ value={expiry}
915
+ onChange={(e) => {
916
+ setExpiry(e.target.value);
917
+ if (isEdit) markSensitiveDirty('expiry');
918
+ }}
919
+ placeholder="MM/YY"
920
+ />
921
+ </div>
922
+ <div className="flex-1">
923
+ <TextInput
924
+ label="CVV"
925
+ type="password"
926
+ value={cvv}
927
+ onChange={(e) => {
928
+ setCvv(e.target.value);
929
+ if (isEdit) markSensitiveDirty('cvv');
930
+ }}
931
+ placeholder={isEdit ? '\u2022\u2022\u2022\u2022' : ''}
932
+ />
933
+ </div>
934
+ </div>
935
+ <button
936
+ type="button"
937
+ onClick={() => setTypeAdvancedOpen((prev) => ({ ...prev, card: !prev.card }))}
938
+ className="font-mono text-[9px] uppercase tracking-widest text-[var(--color-text-muted,#6b7280)] hover:text-[var(--color-text,#0a0a0a)] text-left"
939
+ >
940
+ {typeAdvancedOpen.card ? 'Hide Advanced Fields' : 'Show Advanced Fields'}
941
+ </button>
942
+ {typeAdvancedOpen.card && (
943
+ <div className="space-y-3 border border-[var(--color-border,#d4d4d8)] p-3 bg-[var(--color-background-alt,#f4f4f5)]">
944
+ <FilterDropdown options={BRAND_OPTIONS} value={brand} onChange={setBrand} label="Brand" />
945
+ <TextInput label="Billing ZIP" value={billingZip} onChange={(e) => setBillingZip(e.target.value)} placeholder="12345" />
946
+ </div>
947
+ )}
948
+ </div>
949
+ )}
950
+
951
+ {type === 'note' && (
952
+ <div>
953
+ <div className="flex items-center justify-between mb-1.5 px-1">
954
+ <label className="block font-mono text-[9px] font-bold uppercase tracking-widest text-[var(--color-text-muted,#6b7280)]">
955
+ Content
956
+ </label>
957
+ {isEdit && (
958
+ <button
959
+ type="button"
960
+ onClick={() => { void handleRevealField('content'); }}
961
+ aria-label="Reveal Content"
962
+ className="font-mono text-[8px] uppercase tracking-widest text-[var(--color-text-muted,#6b7280)] hover:text-[var(--color-text,#0a0a0a)] transition-colors inline-flex items-center gap-1"
963
+ >
964
+ {revealingField === 'content' ? <Loader2 size={9} className="animate-spin" /> : <Eye size={9} />}
965
+ Reveal
966
+ </button>
967
+ )}
968
+ </div>
969
+ <textarea
970
+ className={textareaClassName.replace('h-24', 'h-48')}
971
+ value={noteContent}
972
+ onChange={(e) => {
973
+ setNoteContent(e.target.value);
974
+ if (isEdit) markSensitiveDirty('content');
975
+ }}
976
+ placeholder={isEdit ? '\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022' : ''}
977
+ />
978
+ </div>
979
+ )}
980
+
981
+ {type === 'apikey' && (
982
+ <div className="space-y-3">
983
+ <p className="font-mono text-[10px] text-[var(--color-text-muted,#6b7280)]">
984
+ Minimal flow: key name + key value. If Name is blank, key name becomes the credential title.
985
+ </p>
986
+ <TextInput
987
+ label="Key"
988
+ value={apiKeyName}
989
+ onChange={(e) => setApiKeyName(e.target.value)}
990
+ placeholder="Service key name"
991
+ />
992
+ <TextInput
993
+ label="Value"
994
+ type="password"
995
+ value={apiKeyValue}
996
+ onChange={(e) => {
997
+ setApiKeyValue(e.target.value);
998
+ if (isEdit) markSensitiveDirty('value');
999
+ }}
1000
+ placeholder={isEdit ? '\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022' : ''}
1001
+ rightElement={
1002
+ isEdit ? (
1003
+ <button
1004
+ type="button"
1005
+ onClick={() => { void handleRevealField('value'); }}
1006
+ aria-label="Reveal Value"
1007
+ className="text-[var(--color-text-muted,#6b7280)] hover:text-[var(--color-text,#0a0a0a)] transition-colors"
1008
+ >
1009
+ {revealingField === 'value' ? <Loader2 size={11} className="animate-spin" /> : <Eye size={12} />}
1010
+ </button>
1011
+ ) : undefined
1012
+ }
1013
+ />
1014
+ </div>
1015
+ )}
1016
+
1017
+ {type === 'oauth2' && (
1018
+ <div className="space-y-3">
1019
+ <TextInput label="Token Endpoint" value={oauth2TokenEndpoint} onChange={(e) => setOauth2TokenEndpoint(e.target.value)} placeholder="https://accounts.google.com/o/oauth2/token" />
1020
+ <TextInput
1021
+ label="Access Token"
1022
+ type="password"
1023
+ value={oauth2AccessToken}
1024
+ onChange={(e) => {
1025
+ setOauth2AccessToken(e.target.value);
1026
+ if (isEdit) markSensitiveDirty('access_token');
1027
+ }}
1028
+ placeholder={isEdit ? '\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022' : 'Optional — will be fetched on first use'}
1029
+ />
1030
+ <TextInput label="Expires At (unix seconds)" type="text" value={oauth2ExpiresAt} onChange={(e) => setOauth2ExpiresAt(e.target.value)} placeholder={String(Math.floor(Date.now() / 1000) + 60 * 60)} />
1031
+
1032
+ <button
1033
+ type="button"
1034
+ onClick={() => setTypeAdvancedOpen((prev) => ({ ...prev, oauth2: !prev.oauth2 }))}
1035
+ className="font-mono text-[9px] uppercase tracking-widest text-[var(--color-text-muted,#6b7280)] hover:text-[var(--color-text,#0a0a0a)] text-left"
1036
+ >
1037
+ {typeAdvancedOpen.oauth2 ? 'Hide Advanced OAuth2 Fields' : 'Show Advanced OAuth2 Fields'}
1038
+ </button>
1039
+
1040
+ {typeAdvancedOpen.oauth2 && (
1041
+ <div className="space-y-3 border border-[var(--color-border,#d4d4d8)] p-3 bg-[var(--color-background-alt,#f4f4f5)]">
1042
+ <TextInput
1043
+ label="Client ID"
1044
+ value={oauth2ClientId}
1045
+ onChange={(e) => {
1046
+ setOauth2ClientId(e.target.value);
1047
+ if (isEdit) markSensitiveDirty('client_id');
1048
+ }}
1049
+ placeholder="your-client-id"
1050
+ />
1051
+ <TextInput
1052
+ label="Client Secret"
1053
+ type="password"
1054
+ value={oauth2ClientSecret}
1055
+ onChange={(e) => {
1056
+ setOauth2ClientSecret(e.target.value);
1057
+ if (isEdit) markSensitiveDirty('client_secret');
1058
+ }}
1059
+ placeholder={isEdit ? '\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022' : ''}
1060
+ />
1061
+ <TextInput
1062
+ label="Refresh Token"
1063
+ type="password"
1064
+ value={oauth2RefreshToken}
1065
+ onChange={(e) => {
1066
+ setOauth2RefreshToken(e.target.value);
1067
+ if (isEdit) markSensitiveDirty('refresh_token');
1068
+ }}
1069
+ placeholder={isEdit ? '\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022' : ''}
1070
+ />
1071
+ <TextInput label="Scopes" value={oauth2Scopes} onChange={(e) => setOauth2Scopes(e.target.value)} placeholder="read write (space-separated)" />
1072
+ <FilterDropdown
1073
+ options={[
1074
+ { value: 'client_secret_post', label: 'Client Secret (POST body)' },
1075
+ { value: 'client_secret_basic', label: 'Client Secret (Basic Auth)' },
1076
+ ]}
1077
+ value={oauth2AuthMethod}
1078
+ onChange={setOauth2AuthMethod}
1079
+ label="Auth Method"
1080
+ />
1081
+ </div>
1082
+ )}
1083
+ </div>
1084
+ )}
1085
+
1086
+
1087
+ {type === 'ssh' && (
1088
+ <div className="space-y-3">
1089
+ <TextInput label="Public Key (optional)" value={sshPublicKey} onChange={(e) => setSshPublicKey(e.target.value)} placeholder="ssh-ed25519 AAAA..." />
1090
+ <div>
1091
+ <label className="block font-mono text-[9px] font-bold uppercase tracking-widest text-[var(--color-text-muted,#6b7280)]">Private Key</label>
1092
+ <textarea className={textareaClassName.replace('h-24', 'h-40')} value={sshPrivateKey} onChange={(e) => { setSshPrivateKey(e.target.value); if (isEdit) markSensitiveDirty('private_key'); }} placeholder={isEdit ? '••••••••' : '-----BEGIN OPENSSH PRIVATE KEY-----'} />
1093
+ </div>
1094
+ <TextInput label="Passphrase (optional)" type="password" value={sshPassphrase} onChange={(e) => { setSshPassphrase(e.target.value); if (isEdit) markSensitiveDirty('passphrase'); }} />
1095
+ <div>
1096
+ <label className="block font-mono text-[9px] font-bold uppercase tracking-widest text-[var(--color-text-muted,#6b7280)]">Associated Hosts (comma or newline)</label>
1097
+ <textarea className={textareaClassName} value={sshHostsInput} onChange={(e) => setSshHostsInput(e.target.value)} placeholder="github.com
1098
+ prod.example.com" />
1099
+ </div>
1100
+ </div>
1101
+ )}
1102
+
1103
+ {type === 'gpg' && (
1104
+ <div className="space-y-3">
1105
+ <div>
1106
+ <label className="block font-mono text-[9px] font-bold uppercase tracking-widest text-[var(--color-text-muted,#6b7280)]">Private Key (armored)</label>
1107
+ <textarea className={textareaClassName.replace('h-24', 'h-40')} value={gpgPrivateKey} onChange={(e) => { setGpgPrivateKey(e.target.value); if (isEdit) markSensitiveDirty('private_key'); }} placeholder={isEdit ? '••••••••' : '-----BEGIN PGP PRIVATE KEY BLOCK-----'} />
1108
+ </div>
1109
+ <div>
1110
+ <label className="block font-mono text-[9px] font-bold uppercase tracking-widest text-[var(--color-text-muted,#6b7280)]">Public Key (optional)</label>
1111
+ <textarea className={textareaClassName} value={gpgPublicKey} onChange={(e) => setGpgPublicKey(e.target.value)} placeholder="-----BEGIN PGP PUBLIC KEY BLOCK-----" />
1112
+ </div>
1113
+ <TextInput label="Key ID (optional)" value={gpgKeyId} onChange={(e) => setGpgKeyId(e.target.value)} />
1114
+ <TextInput label="UID Email (optional)" value={gpgUidEmail} onChange={(e) => setGpgUidEmail(e.target.value)} />
1115
+ <TextInput label="Expires At (optional)" value={gpgExpiresAt} onChange={(e) => setGpgExpiresAt(e.target.value)} placeholder="2027-01-01" />
1116
+ </div>
1117
+ )}
1118
+
1119
+ <button
1120
+ type="button"
1121
+ onClick={() => setShowGlobalAdvanced((prev) => !prev)}
1122
+ className="font-mono text-[9px] uppercase tracking-widest text-[var(--color-text-muted,#6b7280)] hover:text-[var(--color-text,#0a0a0a)] text-left"
1123
+ >
1124
+ {showGlobalAdvanced ? 'Hide Global Advanced' : 'Show Global Advanced'}
1125
+ </button>
1126
+
1127
+ {showGlobalAdvanced && (
1128
+ <div className="space-y-4 border border-[var(--color-border,#d4d4d8)] p-3 bg-[var(--color-background-alt,#f4f4f5)]">
1129
+ {/* Tags */}
1130
+ <div>
1131
+ <TextInput
1132
+ label="Tags"
1133
+ value={tagInput}
1134
+ onChange={(e) => setTagInput(e.target.value)}
1135
+ onKeyDown={handleTagKeyDown}
1136
+ placeholder="Add tag and press Enter"
1137
+ />
1138
+ {visibleTagSuggestions.length > 0 && (
1139
+ <div className="flex flex-wrap gap-1 mt-1">
1140
+ {visibleTagSuggestions.map((suggestedTag) => (
1141
+ <button
1142
+ key={suggestedTag}
1143
+ type="button"
1144
+ onClick={() => {
1145
+ if (!tags.includes(suggestedTag)) setTags([...tags, suggestedTag]);
1146
+ setTagInput('');
1147
+ }}
1148
+ className="font-mono text-[9px] px-2 py-0.5 border border-[var(--color-border,#d4d4d8)] hover:border-[var(--color-border-focus,#0a0a0a)]"
1149
+ >
1150
+ + {suggestedTag}
1151
+ </button>
1152
+ ))}
1153
+ </div>
1154
+ )}
1155
+ {tags.length > 0 && (
1156
+ <div className="flex flex-wrap gap-1 mt-2">
1157
+ {tags.map((tag) => (
1158
+ <span
1159
+ key={tag}
1160
+ className="inline-flex items-center gap-1 bg-[var(--color-accent,#ccff00)]/10 text-[var(--color-text,#0a0a0a)] text-[9px] font-mono px-2 py-0.5"
1161
+ >
1162
+ {tag}
1163
+ <button
1164
+ type="button"
1165
+ onClick={() => removeTag(tag)}
1166
+ className="text-[var(--color-text-muted,#6b7280)] hover:text-[var(--color-text,#0a0a0a)] ml-0.5"
1167
+ >
1168
+ x
1169
+ </button>
1170
+ </span>
1171
+ ))}
1172
+ </div>
1173
+ )}
1174
+ </div>
1175
+
1176
+ <Toggle size="sm" checked={favorite} onChange={setFavorite} label="Favorite" />
1177
+ </div>
1178
+ )}
1179
+
1180
+ {/* Error */}
1181
+ {error && (
1182
+ <div className="font-mono text-[10px] text-[var(--color-danger,#ef4444)] bg-[var(--color-danger,#ef4444)]/5 border border-[var(--color-danger,#ef4444)]/20 px-3 py-2">
1183
+ {error}
1184
+ </div>
1185
+ )}
1186
+
1187
+ {/* Actions */}
1188
+ <div className="flex justify-end gap-2 pt-1">
1189
+ <Button variant="secondary" size="sm" onClick={onClose}>
1190
+ Cancel
1191
+ </Button>
1192
+ <Button variant="primary" size="sm" onClick={handleSave} loading={saving}>
1193
+ Save
1194
+ </Button>
1195
+ </div>
1196
+ </div>
1197
+ )}
1198
+ </Modal>
1199
+
1200
+ {/* Password Generator */}
1201
+ <PasswordGenerator
1202
+ isOpen={showPasswordGen}
1203
+ onClose={() => setShowPasswordGen(false)}
1204
+ onUse={(pw) => {
1205
+ setPassword(pw);
1206
+ if (isEdit) markSensitiveDirty('password');
1207
+ setShowPasswordGen(false);
1208
+ }}
1209
+ />
1210
+ </>
1211
+ );
1212
+ };