@wopr-network/platform-ui-core 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (543) hide show
  1. package/.env.paperclip +18 -0
  2. package/.env.wopr +18 -0
  3. package/README.md +36 -0
  4. package/biome.json +52 -0
  5. package/next.config.ts +45 -0
  6. package/package.json +84 -0
  7. package/postcss.config.mjs +7 -0
  8. package/public/file.svg +1 -0
  9. package/public/globe.svg +1 -0
  10. package/public/window.svg +1 -0
  11. package/src/__tests__/__snapshots__/layout-snapshots.test.tsx.snap +741 -0
  12. package/src/__tests__/account-page-redirect.test.tsx +73 -0
  13. package/src/__tests__/account-switcher.test.tsx +85 -0
  14. package/src/__tests__/activity-page.test.tsx +176 -0
  15. package/src/__tests__/add-payment-method-dialog.test.tsx +160 -0
  16. package/src/__tests__/admin-api.test.ts +244 -0
  17. package/src/__tests__/admin-gpu-api.test.ts +188 -0
  18. package/src/__tests__/admin-guard.test.tsx +79 -0
  19. package/src/__tests__/admin-marketplace-api.test.ts +179 -0
  20. package/src/__tests__/admin-middleware.test.ts +157 -0
  21. package/src/__tests__/admin-tenant-table.test.tsx +95 -0
  22. package/src/__tests__/affiliate-dashboard.test.tsx +178 -0
  23. package/src/__tests__/api-401-redirect.test.ts +78 -0
  24. package/src/__tests__/api-client.test.ts +316 -0
  25. package/src/__tests__/api-config.test.ts +89 -0
  26. package/src/__tests__/api-control-instance.test.ts +69 -0
  27. package/src/__tests__/api-fleet-resources.test.ts +52 -0
  28. package/src/__tests__/api-fleet-trpc.test.ts +252 -0
  29. package/src/__tests__/api-get-instance-config.test.ts +41 -0
  30. package/src/__tests__/api-null-guards.test.ts +244 -0
  31. package/src/__tests__/api-rename-instance.test.ts +60 -0
  32. package/src/__tests__/api-update-instance-config.test.ts +60 -0
  33. package/src/__tests__/audit-log-table-pagination.test.tsx +136 -0
  34. package/src/__tests__/auth-client.test.ts +87 -0
  35. package/src/__tests__/auth-password-reset.test.tsx +435 -0
  36. package/src/__tests__/auth-redirect.test.tsx +60 -0
  37. package/src/__tests__/auth.test.tsx +269 -0
  38. package/src/__tests__/auto-topup-card.test.tsx +257 -0
  39. package/src/__tests__/backups-tab.test.tsx +221 -0
  40. package/src/__tests__/billing-byok-callout.test.tsx +76 -0
  41. package/src/__tests__/billing-layout-nav-hidden.test.tsx +47 -0
  42. package/src/__tests__/billing-payment-org-invoices.test.tsx +123 -0
  43. package/src/__tests__/billing.test.tsx +509 -0
  44. package/src/__tests__/bot-settings/resources-tab.test.tsx +119 -0
  45. package/src/__tests__/bot-settings/storage-tab.test.tsx +80 -0
  46. package/src/__tests__/bot-settings/vps-info-panel.test.tsx +108 -0
  47. package/src/__tests__/bot-settings/vps-upgrade-card.test.tsx +52 -0
  48. package/src/__tests__/bot-settings-data-control.test.ts +49 -0
  49. package/src/__tests__/bot-settings-restart.test.tsx +149 -0
  50. package/src/__tests__/bot-settings.test.tsx +678 -0
  51. package/src/__tests__/brand.test.ts +335 -0
  52. package/src/__tests__/buy-credits-panel.test.tsx +249 -0
  53. package/src/__tests__/buy-crypto-credits-panel.test.tsx +178 -0
  54. package/src/__tests__/capability-conflicts.test.ts +88 -0
  55. package/src/__tests__/capability-resolver.test.tsx +173 -0
  56. package/src/__tests__/changeset-detail.test.tsx +156 -0
  57. package/src/__tests__/channel-setup-logger.test.ts +22 -0
  58. package/src/__tests__/channel-setup-toast.test.tsx +60 -0
  59. package/src/__tests__/channel-wizard.test.tsx +505 -0
  60. package/src/__tests__/chat/ambient-dot.test.tsx +35 -0
  61. package/src/__tests__/chat/chat-input.test.tsx +78 -0
  62. package/src/__tests__/chat/chat-message.test.tsx +45 -0
  63. package/src/__tests__/chat/chat-panel.test.tsx +111 -0
  64. package/src/__tests__/chat/chat-widget.test.tsx +82 -0
  65. package/src/__tests__/chat-store.test.ts +87 -0
  66. package/src/__tests__/command-center.test.tsx +246 -0
  67. package/src/__tests__/compliance-retention-edit.test.tsx +134 -0
  68. package/src/__tests__/coupon-input.test.tsx +119 -0
  69. package/src/__tests__/create-instance.test.tsx +96 -0
  70. package/src/__tests__/create-org-wizard.test.tsx +200 -0
  71. package/src/__tests__/credit-balance.test.tsx +103 -0
  72. package/src/__tests__/credits.test.tsx +376 -0
  73. package/src/__tests__/csrf-middleware.test.ts +198 -0
  74. package/src/__tests__/degraded-state-banner.test.tsx +130 -0
  75. package/src/__tests__/dividend-calculator.test.tsx +20 -0
  76. package/src/__tests__/dividend-stats.test.tsx +64 -0
  77. package/src/__tests__/dividend.test.tsx +169 -0
  78. package/src/__tests__/dockerfile.test.ts +110 -0
  79. package/src/__tests__/email-verification-banner.test.tsx +64 -0
  80. package/src/__tests__/env-example.test.ts +25 -0
  81. package/src/__tests__/error-boundaries.test.tsx +64 -0
  82. package/src/__tests__/fetch-pricing.test.ts +121 -0
  83. package/src/__tests__/field-oauth.test.tsx +302 -0
  84. package/src/__tests__/fixtures/mock-manifests-data.js +372 -0
  85. package/src/__tests__/fixtures/mock-manifests.ts +24 -0
  86. package/src/__tests__/fleet-health-timestamp.test.tsx +101 -0
  87. package/src/__tests__/fleet-health-update.test.tsx +83 -0
  88. package/src/__tests__/format-credit.test.ts +58 -0
  89. package/src/__tests__/gpu-dashboard.test.tsx +236 -0
  90. package/src/__tests__/hosted-usage-date-range.test.tsx +54 -0
  91. package/src/__tests__/instance-detail.test.tsx +571 -0
  92. package/src/__tests__/instance-list.test.tsx +230 -0
  93. package/src/__tests__/landing-hero.test.tsx +27 -0
  94. package/src/__tests__/landing-nav.test.tsx +24 -0
  95. package/src/__tests__/layout-snapshots.test.tsx +167 -0
  96. package/src/__tests__/logger.test.ts +54 -0
  97. package/src/__tests__/login-page-redirect.test.tsx +142 -0
  98. package/src/__tests__/manifest-validation.test.ts +126 -0
  99. package/src/__tests__/marketplace-admin.test.tsx +151 -0
  100. package/src/__tests__/marketplace.test.tsx +609 -0
  101. package/src/__tests__/merge-api-rates.test.ts +70 -0
  102. package/src/__tests__/middleware.test.ts +690 -0
  103. package/src/__tests__/network-page.test.tsx +100 -0
  104. package/src/__tests__/next-config-headers.test.ts +28 -0
  105. package/src/__tests__/not-found.test.tsx +26 -0
  106. package/src/__tests__/notifications.test.tsx +128 -0
  107. package/src/__tests__/oauth-buttons.test.tsx +101 -0
  108. package/src/__tests__/oauth-error-mapping.test.tsx +97 -0
  109. package/src/__tests__/observability.test.tsx +541 -0
  110. package/src/__tests__/onboarding-data.test.ts +363 -0
  111. package/src/__tests__/onboarding-page.test.tsx +113 -0
  112. package/src/__tests__/onboarding-store.test.ts +121 -0
  113. package/src/__tests__/org-billing-api.test.tsx +70 -0
  114. package/src/__tests__/org-billing-null-guards.test.ts +64 -0
  115. package/src/__tests__/org-billing-page.test.tsx +124 -0
  116. package/src/__tests__/plugin-definition.test.ts +43 -0
  117. package/src/__tests__/plugin-install-flow.test.tsx +535 -0
  118. package/src/__tests__/plugin-registry.test.tsx +475 -0
  119. package/src/__tests__/plugin-setup/setup-chat-panel.test.ts +142 -0
  120. package/src/__tests__/plugin-setup/use-plugin-setup-chat.test.ts +49 -0
  121. package/src/__tests__/plugin-tool-definitions.test.ts +51 -0
  122. package/src/__tests__/plugin-tool-sync.test.ts +59 -0
  123. package/src/__tests__/portfolio-chart.test.tsx +24 -0
  124. package/src/__tests__/pricing.test.tsx +107 -0
  125. package/src/__tests__/promotion-form.test.tsx +180 -0
  126. package/src/__tests__/promotions-list.test.tsx +194 -0
  127. package/src/__tests__/provider-key-api.test.ts +134 -0
  128. package/src/__tests__/resend-verification-button.test.tsx +104 -0
  129. package/src/__tests__/sanitize-redirect-url.test.ts +47 -0
  130. package/src/__tests__/secrets-audit-pagination.test.tsx +139 -0
  131. package/src/__tests__/settings.test.tsx +937 -0
  132. package/src/__tests__/setup-checklist.test.tsx +274 -0
  133. package/src/__tests__/setup.ts +82 -0
  134. package/src/__tests__/smoke.test.tsx +10 -0
  135. package/src/__tests__/snapshot-api.test.ts +104 -0
  136. package/src/__tests__/status-api.test.ts +46 -0
  137. package/src/__tests__/status-badge.test.tsx +33 -0
  138. package/src/__tests__/status-colors.test.ts +83 -0
  139. package/src/__tests__/status-page.test.tsx +86 -0
  140. package/src/__tests__/step-superpowers.test.tsx +218 -0
  141. package/src/__tests__/story-sections.test.tsx +24 -0
  142. package/src/__tests__/superpower-content-sanitize.test.tsx +87 -0
  143. package/src/__tests__/superpower-content.test.tsx +44 -0
  144. package/src/__tests__/suspension-banner.test.tsx +140 -0
  145. package/src/__tests__/tenant-context.test.tsx +146 -0
  146. package/src/__tests__/tenant-keys-api.test.ts +114 -0
  147. package/src/__tests__/tenant-table-pagination.test.tsx +124 -0
  148. package/src/__tests__/terminal-log-cleanup.test.tsx +51 -0
  149. package/src/__tests__/terminal-sequence.test.tsx +28 -0
  150. package/src/__tests__/transaction-history.test.tsx +325 -0
  151. package/src/__tests__/trpc-types.test.ts +102 -0
  152. package/src/__tests__/use-capability-meta.test.ts +161 -0
  153. package/src/__tests__/use-chat.test.ts +616 -0
  154. package/src/__tests__/use-has-org.test.ts +44 -0
  155. package/src/__tests__/use-image-status.test.ts +77 -0
  156. package/src/__tests__/use-pagination-params.test.ts +88 -0
  157. package/src/__tests__/use-plugin-setup-chat-stale-closure.test.ts +53 -0
  158. package/src/__tests__/use-webmcp.test.ts +119 -0
  159. package/src/__tests__/validate-elevenlabs-key.test.ts +95 -0
  160. package/src/__tests__/validate-redirect-url.test.ts +61 -0
  161. package/src/__tests__/verify-page.test.tsx +140 -0
  162. package/src/__tests__/verify-redirect.test.tsx +41 -0
  163. package/src/__tests__/verify-result-banner.test.tsx +66 -0
  164. package/src/__tests__/webmcp-feature-detect.test.ts +54 -0
  165. package/src/__tests__/webmcp-hook.test.tsx +72 -0
  166. package/src/__tests__/webmcp-marketplace-onboarding-tools.test.ts +185 -0
  167. package/src/__tests__/webmcp-register.test.ts +103 -0
  168. package/src/__tests__/webmcp-set-provider.test.ts +47 -0
  169. package/src/__tests__/webmcp-tools.test.ts +348 -0
  170. package/src/app/(auth)/error.tsx +72 -0
  171. package/src/app/(auth)/forgot-password/page.tsx +137 -0
  172. package/src/app/(auth)/layout.tsx +14 -0
  173. package/src/app/(auth)/loading.tsx +26 -0
  174. package/src/app/(auth)/login/page.tsx +188 -0
  175. package/src/app/(auth)/reset-password/page.tsx +169 -0
  176. package/src/app/(auth)/signup/page.tsx +309 -0
  177. package/src/app/(dashboard)/billing/credits/page.tsx +209 -0
  178. package/src/app/(dashboard)/billing/error.tsx +72 -0
  179. package/src/app/(dashboard)/billing/layout.tsx +73 -0
  180. package/src/app/(dashboard)/billing/loading.tsx +41 -0
  181. package/src/app/(dashboard)/billing/payment/page.tsx +639 -0
  182. package/src/app/(dashboard)/billing/plans/page.tsx +58 -0
  183. package/src/app/(dashboard)/billing/referrals/page.tsx +7 -0
  184. package/src/app/(dashboard)/billing/usage/hosted/page.tsx +348 -0
  185. package/src/app/(dashboard)/billing/usage/page.tsx +663 -0
  186. package/src/app/(dashboard)/changesets/[id]/changeset-detail-client.tsx +400 -0
  187. package/src/app/(dashboard)/changesets/[id]/error.tsx +57 -0
  188. package/src/app/(dashboard)/changesets/[id]/loading.tsx +23 -0
  189. package/src/app/(dashboard)/changesets/[id]/page.tsx +10 -0
  190. package/src/app/(dashboard)/changesets/error.tsx +72 -0
  191. package/src/app/(dashboard)/changesets/page.tsx +10 -0
  192. package/src/app/(dashboard)/chat/page.tsx +74 -0
  193. package/src/app/(dashboard)/dashboard/bots/[id]/settings/page.tsx +10 -0
  194. package/src/app/(dashboard)/dashboard/network/page.tsx +97 -0
  195. package/src/app/(dashboard)/dashboard/page.tsx +13 -0
  196. package/src/app/(dashboard)/error.tsx +72 -0
  197. package/src/app/(dashboard)/layout.tsx +113 -0
  198. package/src/app/(dashboard)/loading.tsx +27 -0
  199. package/src/app/(dashboard)/marketplace/[plugin]/page.tsx +548 -0
  200. package/src/app/(dashboard)/marketplace/error.tsx +72 -0
  201. package/src/app/(dashboard)/marketplace/loading.tsx +27 -0
  202. package/src/app/(dashboard)/marketplace/page.tsx +268 -0
  203. package/src/app/(dashboard)/not-found.tsx +46 -0
  204. package/src/app/(dashboard)/onboarding/page.tsx +267 -0
  205. package/src/app/(dashboard)/settings/account/page.tsx +132 -0
  206. package/src/app/(dashboard)/settings/activity/page.tsx +280 -0
  207. package/src/app/(dashboard)/settings/api-keys/page.tsx +530 -0
  208. package/src/app/(dashboard)/settings/brain/page.tsx +412 -0
  209. package/src/app/(dashboard)/settings/error.tsx +72 -0
  210. package/src/app/(dashboard)/settings/layout.tsx +114 -0
  211. package/src/app/(dashboard)/settings/loading.tsx +31 -0
  212. package/src/app/(dashboard)/settings/notifications/page.tsx +216 -0
  213. package/src/app/(dashboard)/settings/org/page.tsx +617 -0
  214. package/src/app/(dashboard)/settings/profile/page.tsx +510 -0
  215. package/src/app/(dashboard)/settings/providers/page.tsx +842 -0
  216. package/src/app/(dashboard)/settings/secrets/page.tsx +658 -0
  217. package/src/app/(dashboard)/settings/security/page.tsx +1133 -0
  218. package/src/app/admin/accounting/loading.tsx +32 -0
  219. package/src/app/admin/accounting/page.tsx +5 -0
  220. package/src/app/admin/affiliates/loading.tsx +32 -0
  221. package/src/app/admin/affiliates/page.tsx +5 -0
  222. package/src/app/admin/audit/loading.tsx +32 -0
  223. package/src/app/admin/audit/page.tsx +5 -0
  224. package/src/app/admin/billing-health/loading.tsx +17 -0
  225. package/src/app/admin/billing-health/page.tsx +10 -0
  226. package/src/app/admin/compliance/page.tsx +5 -0
  227. package/src/app/admin/error.tsx +72 -0
  228. package/src/app/admin/gpu/loading.tsx +38 -0
  229. package/src/app/admin/gpu/page.tsx +5 -0
  230. package/src/app/admin/incidents/page.tsx +10 -0
  231. package/src/app/admin/inference/loading.tsx +32 -0
  232. package/src/app/admin/inference/page.tsx +5 -0
  233. package/src/app/admin/layout.tsx +44 -0
  234. package/src/app/admin/loading.tsx +32 -0
  235. package/src/app/admin/marketplace/loading.tsx +32 -0
  236. package/src/app/admin/marketplace/page.tsx +5 -0
  237. package/src/app/admin/migrations/loading.tsx +22 -0
  238. package/src/app/admin/migrations/page.tsx +5 -0
  239. package/src/app/admin/onboarding/loading.tsx +18 -0
  240. package/src/app/admin/onboarding/page.tsx +5 -0
  241. package/src/app/admin/promotions/[id]/edit/loading.tsx +16 -0
  242. package/src/app/admin/promotions/[id]/edit/page.tsx +56 -0
  243. package/src/app/admin/promotions/[id]/loading.tsx +15 -0
  244. package/src/app/admin/promotions/[id]/page.tsx +311 -0
  245. package/src/app/admin/promotions/loading.tsx +21 -0
  246. package/src/app/admin/promotions/new/loading.tsx +16 -0
  247. package/src/app/admin/promotions/new/page.tsx +12 -0
  248. package/src/app/admin/promotions/page.tsx +266 -0
  249. package/src/app/admin/rate-overrides/loading.tsx +17 -0
  250. package/src/app/admin/rate-overrides/page.tsx +290 -0
  251. package/src/app/admin/roles/loading.tsx +27 -0
  252. package/src/app/admin/roles/page.tsx +5 -0
  253. package/src/app/admin/tenants/loading.tsx +32 -0
  254. package/src/app/admin/tenants/page.tsx +5 -0
  255. package/src/app/apple-icon.tsx +32 -0
  256. package/src/app/auth/callback/[provider]/page.tsx +104 -0
  257. package/src/app/auth/verify/page.tsx +224 -0
  258. package/src/app/channels/error.tsx +72 -0
  259. package/src/app/channels/loading.tsx +29 -0
  260. package/src/app/channels/page.tsx +262 -0
  261. package/src/app/channels/setup/[plugin]/page.tsx +136 -0
  262. package/src/app/error.tsx +72 -0
  263. package/src/app/favicon.ico +0 -0
  264. package/src/app/fleet/error.tsx +72 -0
  265. package/src/app/fleet/health/page.tsx +9 -0
  266. package/src/app/fleet/layout.tsx +14 -0
  267. package/src/app/fleet/loading.tsx +33 -0
  268. package/src/app/fleet/page.tsx +5 -0
  269. package/src/app/global-error.tsx +96 -0
  270. package/src/app/globals.css +251 -0
  271. package/src/app/icon.svg +4 -0
  272. package/src/app/instances/[id]/instance-detail-client.tsx +1298 -0
  273. package/src/app/instances/[id]/page.tsx +10 -0
  274. package/src/app/instances/error.tsx +72 -0
  275. package/src/app/instances/instance-list-client.tsx +540 -0
  276. package/src/app/instances/loading.tsx +33 -0
  277. package/src/app/instances/new/create-instance-client.tsx +377 -0
  278. package/src/app/instances/new/page.tsx +9 -0
  279. package/src/app/instances/page.tsx +9 -0
  280. package/src/app/layout.tsx +83 -0
  281. package/src/app/not-found.tsx +38 -0
  282. package/src/app/og/route.tsx +50 -0
  283. package/src/app/page.tsx +39 -0
  284. package/src/app/plugins/error.tsx +72 -0
  285. package/src/app/plugins/layout.tsx +14 -0
  286. package/src/app/plugins/loading.tsx +30 -0
  287. package/src/app/plugins/page.tsx +555 -0
  288. package/src/app/pricing/error.tsx +72 -0
  289. package/src/app/pricing/loading.tsx +25 -0
  290. package/src/app/pricing/page.tsx +20 -0
  291. package/src/app/privacy/page.tsx +406 -0
  292. package/src/app/robots.ts +9 -0
  293. package/src/app/sitemap.ts +11 -0
  294. package/src/app/status/error.tsx +72 -0
  295. package/src/app/status/loading.tsx +21 -0
  296. package/src/app/status/page.tsx +20 -0
  297. package/src/app/terms/page.tsx +414 -0
  298. package/src/components/account-switcher.tsx +82 -0
  299. package/src/components/admin/accounting-dashboard.tsx +190 -0
  300. package/src/components/admin/admin-guard.tsx +36 -0
  301. package/src/components/admin/admin-nav.tsx +71 -0
  302. package/src/components/admin/affiliate-dashboard.tsx +564 -0
  303. package/src/components/admin/audit-log-table.tsx +336 -0
  304. package/src/components/admin/billing-health-dashboard.test.tsx +40 -0
  305. package/src/components/admin/billing-health-dashboard.tsx +416 -0
  306. package/src/components/admin/bulk-actions-bar.test.tsx +92 -0
  307. package/src/components/admin/bulk-actions-bar.tsx +80 -0
  308. package/src/components/admin/bulk-export-dialog.test.tsx +75 -0
  309. package/src/components/admin/bulk-export-dialog.tsx +189 -0
  310. package/src/components/admin/bulk-grant-dialog.test.tsx +81 -0
  311. package/src/components/admin/bulk-grant-dialog.tsx +147 -0
  312. package/src/components/admin/bulk-preview-dialog.test.tsx +72 -0
  313. package/src/components/admin/bulk-preview-dialog.tsx +106 -0
  314. package/src/components/admin/bulk-reactivate-dialog.test.tsx +51 -0
  315. package/src/components/admin/bulk-reactivate-dialog.tsx +55 -0
  316. package/src/components/admin/bulk-select-all-banner.test.tsx +36 -0
  317. package/src/components/admin/bulk-select-all-banner.tsx +44 -0
  318. package/src/components/admin/bulk-suspend-dialog.test.tsx +77 -0
  319. package/src/components/admin/bulk-suspend-dialog.tsx +129 -0
  320. package/src/components/admin/bulk-undo-toast.test.tsx +66 -0
  321. package/src/components/admin/bulk-undo-toast.tsx +121 -0
  322. package/src/components/admin/compliance-dashboard.tsx +1341 -0
  323. package/src/components/admin/gpu-dashboard.tsx +552 -0
  324. package/src/components/admin/grant-credits-dialog.tsx +121 -0
  325. package/src/components/admin/incident-dashboard.test.tsx +44 -0
  326. package/src/components/admin/incident-dashboard.tsx +717 -0
  327. package/src/components/admin/inference-dashboard.tsx +415 -0
  328. package/src/components/admin/marketplace-admin.tsx +765 -0
  329. package/src/components/admin/migrations-dashboard.tsx +404 -0
  330. package/src/components/admin/onboarding-dashboard.tsx +422 -0
  331. package/src/components/admin/promotions/promotion-form.tsx +440 -0
  332. package/src/components/admin/roles-dashboard.tsx +278 -0
  333. package/src/components/admin/suspend-dialog.tsx +98 -0
  334. package/src/components/admin/tenant-notes-panel.tsx +134 -0
  335. package/src/components/admin/tenant-row-actions.tsx +78 -0
  336. package/src/components/admin/tenant-table.tsx +339 -0
  337. package/src/components/auth/auth-error.tsx +22 -0
  338. package/src/components/auth/auth-redirect.tsx +18 -0
  339. package/src/components/auth/auth-shell.tsx +25 -0
  340. package/src/components/auth/email-verification-banner.tsx +25 -0
  341. package/src/components/auth/email-verification-result-banner.tsx +70 -0
  342. package/src/components/auth/resend-verification-button.tsx +94 -0
  343. package/src/components/auth/wopr-wordmark.tsx +19 -0
  344. package/src/components/billing/add-payment-method-dialog.tsx +267 -0
  345. package/src/components/billing/affiliate-dashboard.tsx +300 -0
  346. package/src/components/billing/auto-topup-card.tsx +432 -0
  347. package/src/components/billing/buy-credits-panel.tsx +180 -0
  348. package/src/components/billing/buy-crypto-credits-panel.tsx +96 -0
  349. package/src/components/billing/byok-callout.tsx +87 -0
  350. package/src/components/billing/coupon-input.tsx +86 -0
  351. package/src/components/billing/credit-balance.tsx +79 -0
  352. package/src/components/billing/degraded-state-banner.tsx +95 -0
  353. package/src/components/billing/dividend-banner.tsx +97 -0
  354. package/src/components/billing/dividend-eligibility.tsx +86 -0
  355. package/src/components/billing/dividend-pool-stats.tsx +86 -0
  356. package/src/components/billing/first-dividend-dialog.tsx +109 -0
  357. package/src/components/billing/low-balance-banner.tsx +50 -0
  358. package/src/components/billing/org-billing-page.tsx +360 -0
  359. package/src/components/billing/suspension-banner.tsx +53 -0
  360. package/src/components/billing/transaction-history.tsx +239 -0
  361. package/src/components/bot-settings/__tests__/bot-settings-client.test.tsx +205 -0
  362. package/src/components/bot-settings/backups-tab.tsx +377 -0
  363. package/src/components/bot-settings/bot-settings-client.tsx +1712 -0
  364. package/src/components/bot-settings/resources-tab.tsx +203 -0
  365. package/src/components/bot-settings/storage-tab.tsx +248 -0
  366. package/src/components/bot-settings/vps-info-panel.tsx +132 -0
  367. package/src/components/bot-settings/vps-upgrade-card.tsx +110 -0
  368. package/src/components/capability/CapabilityResolver.tsx +113 -0
  369. package/src/components/channel-wizard/field-interactive.tsx +48 -0
  370. package/src/components/channel-wizard/field-oauth.tsx +181 -0
  371. package/src/components/channel-wizard/field-paste.tsx +47 -0
  372. package/src/components/channel-wizard/field-qr.tsx +302 -0
  373. package/src/components/channel-wizard/index.ts +6 -0
  374. package/src/components/channel-wizard/step-renderer.tsx +103 -0
  375. package/src/components/channel-wizard/wizard.tsx +200 -0
  376. package/src/components/chat/ambient-dot.tsx +32 -0
  377. package/src/components/chat/chat-input.tsx +56 -0
  378. package/src/components/chat/chat-message.tsx +36 -0
  379. package/src/components/chat/chat-panel.tsx +138 -0
  380. package/src/components/chat/chat-widget.tsx +41 -0
  381. package/src/components/chat/index.ts +5 -0
  382. package/src/components/dashboard/command-center.tsx +614 -0
  383. package/src/components/instances/friends-tab.test.tsx +265 -0
  384. package/src/components/instances/friends-tab.tsx +721 -0
  385. package/src/components/landing/hero.tsx +53 -0
  386. package/src/components/landing/landing-nav.tsx +21 -0
  387. package/src/components/landing/landing-page.tsx +71 -0
  388. package/src/components/landing/portfolio-chart.tsx +349 -0
  389. package/src/components/landing/story-sections.tsx +50 -0
  390. package/src/components/landing/terminal-lines.ts +99 -0
  391. package/src/components/landing/terminal-sequence.tsx +453 -0
  392. package/src/components/landing/typing-effect.tsx +43 -0
  393. package/src/components/marketplace/category-filter.tsx +61 -0
  394. package/src/components/marketplace/empty-state.tsx +61 -0
  395. package/src/components/marketplace/featured-heroes.tsx +84 -0
  396. package/src/components/marketplace/first-visit-hero.tsx +110 -0
  397. package/src/components/marketplace/index.ts +9 -0
  398. package/src/components/marketplace/install-wizard.tsx +782 -0
  399. package/src/components/marketplace/marketplace-tabs.tsx +54 -0
  400. package/src/components/marketplace/plugin-card.tsx +129 -0
  401. package/src/components/marketplace/superpower-card.tsx +104 -0
  402. package/src/components/marketplace/superpower-content.tsx +117 -0
  403. package/src/components/marketplace/terminal-search.tsx +67 -0
  404. package/src/components/oauth-buttons.tsx +75 -0
  405. package/src/components/observability/fleet-health.tsx +370 -0
  406. package/src/components/observability/health-overview.tsx +246 -0
  407. package/src/components/observability/logs-viewer.tsx +215 -0
  408. package/src/components/observability/metrics-dashboard.tsx +288 -0
  409. package/src/components/onboarding/fallback-setup.tsx +137 -0
  410. package/src/components/onboarding/index.ts +3 -0
  411. package/src/components/onboarding/setup-checklist.tsx +333 -0
  412. package/src/components/onboarding/step-superpowers.tsx +122 -0
  413. package/src/components/plugin-setup/index.ts +1 -0
  414. package/src/components/plugin-setup/setup-chat-panel.tsx +188 -0
  415. package/src/components/pricing/dividend-calculator.tsx +47 -0
  416. package/src/components/pricing/dividend-stats.tsx +117 -0
  417. package/src/components/pricing/pricing-page.tsx +229 -0
  418. package/src/components/settings/create-org-wizard.tsx +225 -0
  419. package/src/components/sidebar.tsx +202 -0
  420. package/src/components/status/status-page.tsx +209 -0
  421. package/src/components/status-badge.tsx +28 -0
  422. package/src/components/theme-provider.tsx +8 -0
  423. package/src/components/ui/alert-dialog.tsx +141 -0
  424. package/src/components/ui/badge.tsx +47 -0
  425. package/src/components/ui/banner.tsx +36 -0
  426. package/src/components/ui/button.tsx +64 -0
  427. package/src/components/ui/card.tsx +75 -0
  428. package/src/components/ui/checkbox.tsx +52 -0
  429. package/src/components/ui/collapsible.tsx +31 -0
  430. package/src/components/ui/credit-detailed.tsx +33 -0
  431. package/src/components/ui/dialog.tsx +143 -0
  432. package/src/components/ui/dropdown-menu.tsx +228 -0
  433. package/src/components/ui/form.tsx +151 -0
  434. package/src/components/ui/input.tsx +21 -0
  435. package/src/components/ui/label.tsx +21 -0
  436. package/src/components/ui/popover.tsx +74 -0
  437. package/src/components/ui/progress.tsx +28 -0
  438. package/src/components/ui/radio-group.tsx +45 -0
  439. package/src/components/ui/select.tsx +175 -0
  440. package/src/components/ui/separator.tsx +28 -0
  441. package/src/components/ui/sheet.tsx +125 -0
  442. package/src/components/ui/skeleton.tsx +15 -0
  443. package/src/components/ui/switch.tsx +35 -0
  444. package/src/components/ui/table.tsx +92 -0
  445. package/src/components/ui/tabs.tsx +81 -0
  446. package/src/components/ui/textarea.tsx +18 -0
  447. package/src/components/ui/tooltip.tsx +44 -0
  448. package/src/config/provider-docs.ts +17 -0
  449. package/src/hooks/__tests__/use-async.test.ts +127 -0
  450. package/src/hooks/__tests__/use-count-up.test.ts +129 -0
  451. package/src/hooks/__tests__/use-debounce.test.ts +105 -0
  452. package/src/hooks/__tests__/use-fleet-sse.test.ts +216 -0
  453. package/src/hooks/__tests__/use-local-storage.test.ts +74 -0
  454. package/src/hooks/__tests__/use-mobile.test.ts +86 -0
  455. package/src/hooks/__tests__/use-save-queue.test.ts +159 -0
  456. package/src/hooks/use-async.ts +54 -0
  457. package/src/hooks/use-capability-meta.ts +99 -0
  458. package/src/hooks/use-count-up.ts +23 -0
  459. package/src/hooks/use-debounce.ts +12 -0
  460. package/src/hooks/use-fleet-sse.ts +47 -0
  461. package/src/hooks/use-has-org.ts +18 -0
  462. package/src/hooks/use-image-status.ts +36 -0
  463. package/src/hooks/use-local-storage.ts +36 -0
  464. package/src/hooks/use-mobile.ts +17 -0
  465. package/src/hooks/use-page-context.ts +24 -0
  466. package/src/hooks/use-pagination-params.ts +30 -0
  467. package/src/hooks/use-plugin-registry.ts +247 -0
  468. package/src/hooks/use-plugin-setup-chat.ts +211 -0
  469. package/src/hooks/use-save-queue.ts +54 -0
  470. package/src/hooks/use-webmcp.ts +40 -0
  471. package/src/lib/__tests__/__snapshots__/pricing-data.test.ts.snap +112 -0
  472. package/src/lib/__tests__/admin-api.test.ts +487 -0
  473. package/src/lib/__tests__/api-bot-crud.test.ts +391 -0
  474. package/src/lib/__tests__/api-fetch.test.ts +196 -0
  475. package/src/lib/__tests__/bot-settings-data.test.ts +352 -0
  476. package/src/lib/__tests__/org-api.test.ts +281 -0
  477. package/src/lib/__tests__/org-billing-api.test.ts +242 -0
  478. package/src/lib/__tests__/pricing-data.test.ts +32 -0
  479. package/src/lib/__tests__/settings-api.test.ts +272 -0
  480. package/src/lib/admin-affiliate-api.ts +51 -0
  481. package/src/lib/admin-api.ts +325 -0
  482. package/src/lib/admin-compliance-api.ts +127 -0
  483. package/src/lib/admin-gpu-api.ts +82 -0
  484. package/src/lib/admin-incident-api.ts +121 -0
  485. package/src/lib/admin-inference-api.ts +47 -0
  486. package/src/lib/admin-marketplace-api.ts +97 -0
  487. package/src/lib/api-config.test.ts +111 -0
  488. package/src/lib/api-config.ts +65 -0
  489. package/src/lib/api-errors.test.ts +43 -0
  490. package/src/lib/api.ts +2011 -0
  491. package/src/lib/auth-client.ts +11 -0
  492. package/src/lib/bot-settings-data.ts +342 -0
  493. package/src/lib/brand-config.ts +145 -0
  494. package/src/lib/brand.ts +669 -0
  495. package/src/lib/changeset-api.ts +29 -0
  496. package/src/lib/changeset-types.ts +56 -0
  497. package/src/lib/channel-manifests.ts +50 -0
  498. package/src/lib/chat/chat-context.tsx +70 -0
  499. package/src/lib/chat/chat-store.ts +62 -0
  500. package/src/lib/chat/types.ts +35 -0
  501. package/src/lib/chat/use-chat.ts +255 -0
  502. package/src/lib/cost-comparison-data.test.ts +95 -0
  503. package/src/lib/cost-comparison-data.ts +54 -0
  504. package/src/lib/errors.test.ts +64 -0
  505. package/src/lib/errors.ts +52 -0
  506. package/src/lib/fetch-utils.test.ts +57 -0
  507. package/src/lib/fetch-utils.ts +25 -0
  508. package/src/lib/format-credit.test.ts +66 -0
  509. package/src/lib/format-credit.ts +24 -0
  510. package/src/lib/format.test.ts +62 -0
  511. package/src/lib/format.ts +17 -0
  512. package/src/lib/logger.ts +28 -0
  513. package/src/lib/marketplace-data.ts +346 -0
  514. package/src/lib/oauth-errors.ts +19 -0
  515. package/src/lib/onboarding-data.ts +1265 -0
  516. package/src/lib/onboarding-store.ts +233 -0
  517. package/src/lib/org-api.ts +74 -0
  518. package/src/lib/org-billing-api.ts +81 -0
  519. package/src/lib/page-prompts.test.ts +32 -0
  520. package/src/lib/page-prompts.ts +23 -0
  521. package/src/lib/plugin/index.ts +32 -0
  522. package/src/lib/plugin/tool-definitions.ts +306 -0
  523. package/src/lib/pricing-data.ts +115 -0
  524. package/src/lib/promotions-types.ts +58 -0
  525. package/src/lib/settings-api.ts +63 -0
  526. package/src/lib/status-colors.ts +38 -0
  527. package/src/lib/tenant-context.tsx +134 -0
  528. package/src/lib/trpc-types.ts +173 -0
  529. package/src/lib/trpc.tsx +86 -0
  530. package/src/lib/utils.test.ts +55 -0
  531. package/src/lib/utils.ts +18 -0
  532. package/src/lib/validate-redirect-url.ts +39 -0
  533. package/src/lib/webmcp/feature-detect.ts +13 -0
  534. package/src/lib/webmcp/marketplace-onboarding-tools.ts +202 -0
  535. package/src/lib/webmcp/register.ts +44 -0
  536. package/src/lib/webmcp/tools.ts +422 -0
  537. package/src/proxy.ts +258 -0
  538. package/src/types/missing-deps.d.ts +160 -0
  539. package/src/types/motion-dom.d.ts +162 -0
  540. package/src/types/vitest-matchers.d.ts +40 -0
  541. package/src/types/web-mcp.d.ts +22 -0
  542. package/tsconfig.json +34 -0
  543. package/vitest.config.ts +26 -0
@@ -0,0 +1,1133 @@
1
+ "use client";
2
+
3
+ import { AnimatePresence, motion } from "framer-motion";
4
+ import {
5
+ CheckIcon,
6
+ CopyIcon,
7
+ DownloadIcon,
8
+ MonitorIcon,
9
+ SmartphoneIcon,
10
+ TabletIcon,
11
+ } from "lucide-react";
12
+ import { QRCodeSVG } from "qrcode.react";
13
+ import { useCallback, useEffect, useRef, useState } from "react";
14
+ import { Badge } from "@/components/ui/badge";
15
+ import { Button } from "@/components/ui/button";
16
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
17
+ import {
18
+ Dialog,
19
+ DialogClose,
20
+ DialogContent,
21
+ DialogDescription,
22
+ DialogFooter,
23
+ DialogHeader,
24
+ DialogTitle,
25
+ } from "@/components/ui/dialog";
26
+ import { Input } from "@/components/ui/input";
27
+ import { Skeleton } from "@/components/ui/skeleton";
28
+ import {
29
+ Table,
30
+ TableBody,
31
+ TableCell,
32
+ TableHead,
33
+ TableHeader,
34
+ TableRow,
35
+ } from "@/components/ui/table";
36
+ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
37
+ import type { LoginAttempt, LoginHistoryResponse } from "@/lib/api";
38
+ import { fetchLoginHistory } from "@/lib/api";
39
+ import { authClient } from "@/lib/auth-client";
40
+ import { getBrandConfig, productName } from "@/lib/brand-config";
41
+ import { trpc } from "@/lib/trpc";
42
+
43
+ // ---------- helpers ----------
44
+
45
+ function relativeTime(iso: string): string {
46
+ const diff = Date.now() - new Date(iso).getTime();
47
+ const seconds = Math.floor(diff / 1000);
48
+ if (seconds < 60) return "just now";
49
+ const minutes = Math.floor(seconds / 60);
50
+ if (minutes < 60) return `${minutes}m ago`;
51
+ const hours = Math.floor(minutes / 60);
52
+ if (hours < 24) return `${hours}h ago`;
53
+ const days = Math.floor(hours / 24);
54
+ return `${days}d ago`;
55
+ }
56
+
57
+ function deviceIcon(ua: string) {
58
+ const lower = ua.toLowerCase();
59
+ if (lower.includes("mobile") || lower.includes("iphone") || lower.includes("android")) {
60
+ return SmartphoneIcon;
61
+ }
62
+ if (lower.includes("ipad") || lower.includes("tablet")) {
63
+ return TabletIcon;
64
+ }
65
+ return MonitorIcon;
66
+ }
67
+
68
+ function parseBrowser(ua: string): string {
69
+ if (!ua) return "Unknown";
70
+ if (ua.includes("Firefox")) return "Firefox";
71
+ if (ua.includes("Edg")) return "Edge";
72
+ if (ua.includes("Chrome")) return "Chrome";
73
+ if (ua.includes("Safari")) return "Safari";
74
+ if (ua.includes("Opera") || ua.includes("OPR")) return "Opera";
75
+ return ua.length > 40 ? `${ua.slice(0, 40)}...` : ua;
76
+ }
77
+
78
+ function parseOS(ua: string): string {
79
+ if (!ua) return "";
80
+ if (ua.includes("Windows")) return "Windows";
81
+ if (ua.includes("Mac OS")) return "macOS";
82
+ if (ua.includes("Linux")) return "Linux";
83
+ if (ua.includes("Android")) return "Android";
84
+ if (ua.includes("iPhone") || ua.includes("iPad")) return "iOS";
85
+ return "";
86
+ }
87
+
88
+ // ---------- types ----------
89
+
90
+ interface Session {
91
+ id: string;
92
+ token: string;
93
+ expiresAt: Date | string;
94
+ userAgent?: string;
95
+ ipAddress?: string;
96
+ current?: boolean;
97
+ createdAt?: Date | string;
98
+ updatedAt?: Date | string;
99
+ }
100
+
101
+ // ---------- step indicator ----------
102
+
103
+ function StepIndicator({ currentStep, steps }: { currentStep: number; steps: string[] }) {
104
+ return (
105
+ <div className="flex items-center justify-center gap-0 px-4 py-2">
106
+ {steps.map((label, i) => (
107
+ <div key={label} className="flex items-center">
108
+ {i > 0 && (
109
+ <div
110
+ className={`h-px w-8 sm:w-12 ${i <= currentStep ? "bg-terminal/40" : "bg-border"}`}
111
+ />
112
+ )}
113
+ <div className="flex flex-col items-center gap-1">
114
+ <div
115
+ className={`flex size-8 items-center justify-center rounded-full text-xs font-medium transition-colors ${
116
+ i < currentStep
117
+ ? "bg-terminal/20 text-terminal"
118
+ : i === currentStep
119
+ ? "bg-terminal text-primary-foreground"
120
+ : "bg-muted text-muted-foreground"
121
+ }`}
122
+ >
123
+ {i < currentStep ? <CheckIcon className="size-4" /> : i + 1}
124
+ </div>
125
+ <span
126
+ className={`text-xs ${i === currentStep ? "font-medium text-foreground" : "text-muted-foreground"}`}
127
+ >
128
+ {label}
129
+ </span>
130
+ </div>
131
+ </div>
132
+ ))}
133
+ </div>
134
+ );
135
+ }
136
+
137
+ // ---------- 2FA section ----------
138
+
139
+ function TwoFactorSection() {
140
+ const {
141
+ data: profileData,
142
+ error: profileError,
143
+ isPending: profilePending,
144
+ } = trpc.profile.getProfile.useQuery(undefined, {
145
+ retry: false,
146
+ });
147
+ const utils = trpc.useUtils();
148
+ const [enabled, setEnabled] = useState(false);
149
+ const [codesRemaining, setCodesRemaining] = useState(8);
150
+ const [error, setError] = useState<string | null>(null);
151
+
152
+ // enable flow
153
+ const [enableOpen, setEnableOpen] = useState(false);
154
+ const [enableStep, setEnableStep] = useState(0);
155
+ const [enablePassword, setEnablePassword] = useState("");
156
+ const [enablePasswordError, setEnablePasswordError] = useState<string | null>(null);
157
+ const [enablePasswordLoading, setEnablePasswordLoading] = useState(false);
158
+ const [totpUri, setTotpUri] = useState("");
159
+ const [totpSecret, setTotpSecret] = useState("");
160
+ const [verifyCode, setVerifyCode] = useState("");
161
+ const [verifyError, setVerifyError] = useState<string | null>(null);
162
+ const [verifying, setVerifying] = useState(false);
163
+ const [recoveryCodes, setRecoveryCodes] = useState<string[]>([]);
164
+ const [copied, setCopied] = useState(false);
165
+ const copiedTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
166
+
167
+ // disable flow
168
+ const [disableOpen, setDisableOpen] = useState(false);
169
+ const [disableCode, setDisableCode] = useState("");
170
+ const [disableError, setDisableError] = useState<string | null>(null);
171
+ const [disabling, setDisabling] = useState(false);
172
+
173
+ // regen flow
174
+ const [regenOpen, setRegenOpen] = useState(false);
175
+ const [regenCode, setRegenCode] = useState("");
176
+ const [regenError, setRegenError] = useState<string | null>(null);
177
+ const [regenCodes, setRegenCodes] = useState<string[]>([]);
178
+ const [regenStep, setRegenStep] = useState<"verify" | "codes">("verify");
179
+ const [regenVerifying, setRegenVerifying] = useState(false);
180
+
181
+ useEffect(() => {
182
+ return () => {
183
+ if (copiedTimer.current) clearTimeout(copiedTimer.current);
184
+ };
185
+ }, []);
186
+
187
+ useEffect(() => {
188
+ setEnabled(
189
+ Boolean((profileData as { twoFactorEnabled?: boolean } | undefined)?.twoFactorEnabled),
190
+ );
191
+ }, [profileData]);
192
+
193
+ function handleStartEnable() {
194
+ setEnableStep(-1);
195
+ setEnablePassword("");
196
+ setEnablePasswordError(null);
197
+ setVerifyCode("");
198
+ setVerifyError(null);
199
+ setRecoveryCodes([]);
200
+ setError(null);
201
+ setEnableOpen(true);
202
+ }
203
+
204
+ async function handleEnableWithPassword() {
205
+ setEnablePasswordLoading(true);
206
+ setEnablePasswordError(null);
207
+ try {
208
+ const res = await authClient.twoFactor.enable({ password: enablePassword });
209
+ const data = res?.data as { totpURI?: string; backupCodes?: string[] } | undefined;
210
+ const uri = data?.totpURI ?? "";
211
+ setTotpUri(uri);
212
+ // extract secret from URI
213
+ const match = uri.match(/secret=([A-Z2-7]+)/i);
214
+ setTotpSecret(match?.[1] ?? "");
215
+ if (data?.backupCodes) {
216
+ setRecoveryCodes(data.backupCodes);
217
+ }
218
+ setEnableStep(0);
219
+ } catch {
220
+ setEnablePasswordError("Incorrect password. Please try again.");
221
+ } finally {
222
+ setEnablePasswordLoading(false);
223
+ }
224
+ }
225
+
226
+ async function handleVerify() {
227
+ setVerifying(true);
228
+ setVerifyError(null);
229
+ try {
230
+ await authClient.twoFactor.verifyTotp({ code: verifyCode });
231
+ await utils.profile.getProfile.invalidate();
232
+ setEnableStep(2);
233
+ setEnabled(true);
234
+ } catch {
235
+ setVerifyError("Invalid code. Please try again.");
236
+ } finally {
237
+ setVerifying(false);
238
+ }
239
+ }
240
+
241
+ async function handleDisable() {
242
+ setDisabling(true);
243
+ setDisableError(null);
244
+ try {
245
+ await authClient.twoFactor.disable({ password: disableCode });
246
+ await utils.profile.getProfile.invalidate();
247
+ setEnabled(false);
248
+ setDisableOpen(false);
249
+ setDisableCode("");
250
+ } catch {
251
+ setDisableError("Invalid code. Please try again.");
252
+ } finally {
253
+ setDisabling(false);
254
+ }
255
+ }
256
+
257
+ async function handleRegenVerify() {
258
+ setRegenVerifying(true);
259
+ setRegenError(null);
260
+ try {
261
+ const res = await authClient.twoFactor.generateBackupCodes({
262
+ password: regenCode,
263
+ });
264
+ const data = res?.data as { backupCodes?: string[] } | undefined;
265
+ setRegenCodes(data?.backupCodes ?? []);
266
+ setRegenStep("codes");
267
+ setCodesRemaining(data?.backupCodes?.length ?? 8);
268
+ } catch {
269
+ setRegenError("Invalid code. Please try again.");
270
+ } finally {
271
+ setRegenVerifying(false);
272
+ }
273
+ }
274
+
275
+ function copyToClipboard(text: string) {
276
+ navigator.clipboard.writeText(text);
277
+ setCopied(true);
278
+ copiedTimer.current = setTimeout(() => setCopied(false), 2000);
279
+ }
280
+
281
+ function downloadCodes(codes: string[]) {
282
+ const text = `${productName()} Recovery Codes\nGenerated: ${new Date().toISOString()}\n\n${codes.join("\n")}\n\nEach code can only be used once.`;
283
+ const blob = new Blob([text], { type: "text/plain" });
284
+ const url = URL.createObjectURL(blob);
285
+ const a = document.createElement("a");
286
+ a.href = url;
287
+ a.download = `${getBrandConfig().storagePrefix}-recovery-codes.txt`;
288
+ document.body.appendChild(a);
289
+ a.click();
290
+ document.body.removeChild(a);
291
+ URL.revokeObjectURL(url);
292
+ }
293
+
294
+ if (profilePending) {
295
+ return (
296
+ <Card>
297
+ <CardHeader>
298
+ <CardTitle>Two-Factor Authentication</CardTitle>
299
+ <CardDescription>Add an extra layer of security to your account</CardDescription>
300
+ </CardHeader>
301
+ <CardContent>
302
+ <div className="space-y-3">
303
+ <Skeleton className="h-4 w-64" />
304
+ <Skeleton className="h-9 w-28" />
305
+ </div>
306
+ </CardContent>
307
+ </Card>
308
+ );
309
+ }
310
+
311
+ if (profileError) {
312
+ return (
313
+ <Card>
314
+ <CardHeader>
315
+ <CardTitle>Two-Factor Authentication</CardTitle>
316
+ <CardDescription>Add an extra layer of security to your account</CardDescription>
317
+ </CardHeader>
318
+ <CardContent>
319
+ <p className="text-destructive text-sm">Failed to load security settings</p>
320
+ </CardContent>
321
+ </Card>
322
+ );
323
+ }
324
+
325
+ return (
326
+ <>
327
+ <AnimatePresence>
328
+ {error && (
329
+ <motion.div
330
+ initial={{ opacity: 0, y: -4 }}
331
+ animate={{ opacity: 1, y: 0 }}
332
+ exit={{ opacity: 0, y: -4 }}
333
+ className="rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive"
334
+ >
335
+ {error}
336
+ </motion.div>
337
+ )}
338
+ </AnimatePresence>
339
+
340
+ <Card>
341
+ <CardHeader>
342
+ <CardTitle>Two-Factor Authentication</CardTitle>
343
+ <CardDescription>Add an extra layer of security to your account</CardDescription>
344
+ </CardHeader>
345
+ <CardContent className="space-y-4">
346
+ {enabled ? (
347
+ <>
348
+ <div className="flex items-center gap-2">
349
+ <span className="size-2 rounded-full bg-terminal animate-pulse" />
350
+ <span className="text-sm font-medium text-terminal">
351
+ Two-factor authentication is active
352
+ </span>
353
+ </div>
354
+ <div className="flex items-center gap-2">
355
+ <Button variant="outline" onClick={() => setDisableOpen(true)}>
356
+ Disable 2FA
357
+ </Button>
358
+ <Button
359
+ variant="ghost"
360
+ onClick={() => {
361
+ setRegenStep("verify");
362
+ setRegenCode("");
363
+ setRegenError(null);
364
+ setRegenCodes([]);
365
+ setRegenOpen(true);
366
+ }}
367
+ >
368
+ View recovery codes
369
+ </Button>
370
+ </div>
371
+ {codesRemaining <= 2 && (
372
+ <p className="text-xs text-chart-3">
373
+ {codesRemaining} of 8 recovery codes remaining
374
+ </p>
375
+ )}
376
+ {codesRemaining > 2 && (
377
+ <p className="text-xs text-muted-foreground">
378
+ {codesRemaining} of 8 recovery codes remaining
379
+ </p>
380
+ )}
381
+ </>
382
+ ) : (
383
+ <>
384
+ <div className="flex items-center gap-2">
385
+ <span className="size-2 rounded-full bg-chart-3" />
386
+ <span className="text-sm font-medium text-chart-3">
387
+ Two-factor authentication is not enabled
388
+ </span>
389
+ </div>
390
+ <Button variant="terminal" onClick={handleStartEnable}>
391
+ Enable 2FA
392
+ </Button>
393
+ </>
394
+ )}
395
+ </CardContent>
396
+ </Card>
397
+
398
+ {/* Enable 2FA Dialog */}
399
+ <Dialog open={enableOpen} onOpenChange={setEnableOpen}>
400
+ <DialogContent className="max-w-md">
401
+ {enableStep >= 0 && (
402
+ <StepIndicator currentStep={enableStep} steps={["Scan", "Verify", "Backup"]} />
403
+ )}
404
+
405
+ {enableStep === -1 && (
406
+ <>
407
+ <DialogHeader>
408
+ <DialogTitle>Confirm your password</DialogTitle>
409
+ <DialogDescription>
410
+ Enter your password to set up two-factor authentication
411
+ </DialogDescription>
412
+ </DialogHeader>
413
+ <div className="flex flex-col items-center gap-3">
414
+ <Input
415
+ type="password"
416
+ value={enablePassword}
417
+ onChange={(e) => setEnablePassword(e.target.value)}
418
+ className={`w-full ${enablePasswordError ? "border-destructive" : ""}`}
419
+ placeholder="Your account password"
420
+ autoFocus
421
+ onKeyDown={(e) => {
422
+ if (e.key === "Enter" && enablePassword.length > 0 && !enablePasswordLoading) {
423
+ handleEnableWithPassword();
424
+ }
425
+ }}
426
+ />
427
+ <AnimatePresence>
428
+ {enablePasswordError && (
429
+ <motion.p
430
+ initial={{ opacity: 0, y: -4 }}
431
+ animate={{ opacity: 1, y: 0 }}
432
+ exit={{ opacity: 0, y: -4 }}
433
+ className="text-sm text-destructive"
434
+ >
435
+ {enablePasswordError}
436
+ </motion.p>
437
+ )}
438
+ </AnimatePresence>
439
+ </div>
440
+ <DialogFooter>
441
+ <DialogClose asChild>
442
+ <Button variant="outline">Cancel</Button>
443
+ </DialogClose>
444
+ <Button
445
+ variant="terminal"
446
+ disabled={enablePassword.length === 0 || enablePasswordLoading}
447
+ onClick={handleEnableWithPassword}
448
+ >
449
+ {enablePasswordLoading ? "Verifying..." : "Continue"}
450
+ </Button>
451
+ </DialogFooter>
452
+ </>
453
+ )}
454
+
455
+ {enableStep === 0 && (
456
+ <>
457
+ <DialogHeader>
458
+ <DialogTitle>Set up authenticator</DialogTitle>
459
+ <DialogDescription>
460
+ Scan this QR code with your authenticator app (Google Authenticator, Authy,
461
+ 1Password)
462
+ </DialogDescription>
463
+ </DialogHeader>
464
+ <div className="flex flex-col items-center gap-4">
465
+ {/* bg-white is intentional -- QR codes require white background for scanability */}
466
+ <div className="rounded-sm border border-border bg-white p-3">
467
+ <QRCodeSVG value={totpUri} size={192} />
468
+ </div>
469
+ <div className="w-full space-y-2">
470
+ <p className="text-xs text-muted-foreground">
471
+ Can&apos;t scan? Enter this key manually:
472
+ </p>
473
+ <div className="flex items-center gap-2">
474
+ <code className="flex-1 rounded-sm bg-muted px-3 py-2 text-xs font-mono tracking-widest">
475
+ {totpSecret}
476
+ </code>
477
+ <Button
478
+ variant="ghost"
479
+ size="icon-sm"
480
+ onClick={() => copyToClipboard(totpSecret)}
481
+ >
482
+ <CopyIcon className="size-4" />
483
+ </Button>
484
+ </div>
485
+ </div>
486
+ </div>
487
+ <DialogFooter>
488
+ <Button variant="terminal" onClick={() => setEnableStep(1)}>
489
+ Next
490
+ </Button>
491
+ </DialogFooter>
492
+ </>
493
+ )}
494
+
495
+ {enableStep === 1 && (
496
+ <>
497
+ <DialogHeader>
498
+ <DialogTitle>Verify your code</DialogTitle>
499
+ <DialogDescription>
500
+ Enter the 6-digit code from your authenticator app
501
+ </DialogDescription>
502
+ </DialogHeader>
503
+ <div className="flex flex-col items-center gap-3">
504
+ <Input
505
+ value={verifyCode}
506
+ onChange={(e) => setVerifyCode(e.target.value.replace(/\D/g, "").slice(0, 6))}
507
+ className={`w-48 text-center text-2xl tracking-[0.5em] font-mono ${verifyError ? "border-destructive" : ""}`}
508
+ maxLength={6}
509
+ inputMode="numeric"
510
+ autoFocus
511
+ />
512
+ <AnimatePresence>
513
+ {verifyError && (
514
+ <motion.p
515
+ initial={{ opacity: 0, y: -4 }}
516
+ animate={{ opacity: 1, y: 0 }}
517
+ exit={{ opacity: 0, y: -4 }}
518
+ className="text-sm text-destructive"
519
+ >
520
+ {verifyError}
521
+ </motion.p>
522
+ )}
523
+ </AnimatePresence>
524
+ </div>
525
+ <DialogFooter>
526
+ <Button variant="ghost" onClick={() => setEnableStep(0)}>
527
+ Back
528
+ </Button>
529
+ <Button
530
+ variant="terminal"
531
+ disabled={verifyCode.length !== 6 || verifying}
532
+ onClick={handleVerify}
533
+ >
534
+ {verifying ? "Verifying..." : "Verify"}
535
+ </Button>
536
+ </DialogFooter>
537
+ </>
538
+ )}
539
+
540
+ {enableStep === 2 && (
541
+ <>
542
+ <DialogHeader>
543
+ <DialogTitle>Save your recovery codes</DialogTitle>
544
+ <DialogDescription>
545
+ Store these codes in a safe place. Each code can only be used once. You won&apos;t
546
+ be able to see them again.
547
+ </DialogDescription>
548
+ </DialogHeader>
549
+ <div className="grid grid-cols-2 gap-2">
550
+ {recoveryCodes.map((code) => (
551
+ <div
552
+ key={code}
553
+ className="rounded-sm bg-muted px-3 py-2 text-center text-sm font-mono tracking-widest text-foreground"
554
+ >
555
+ {code}
556
+ </div>
557
+ ))}
558
+ </div>
559
+ <div className="flex items-center gap-2">
560
+ <Button
561
+ variant="outline"
562
+ size="sm"
563
+ onClick={() => copyToClipboard(recoveryCodes.join("\n"))}
564
+ >
565
+ <CopyIcon className="mr-1 size-4" />
566
+ {copied ? "Copied" : "Copy all"}
567
+ </Button>
568
+ <Button variant="outline" size="sm" onClick={() => downloadCodes(recoveryCodes)}>
569
+ <DownloadIcon className="mr-1 size-4" />
570
+ Download
571
+ </Button>
572
+ </div>
573
+ <div className="rounded-sm border border-chart-3/20 bg-chart-3/10 px-3 py-2 text-xs text-chart-3">
574
+ These codes will not be shown again. Save them now.
575
+ </div>
576
+ <DialogFooter>
577
+ <DialogClose asChild>
578
+ <Button variant="terminal">I&apos;ve saved these codes</Button>
579
+ </DialogClose>
580
+ </DialogFooter>
581
+ </>
582
+ )}
583
+ </DialogContent>
584
+ </Dialog>
585
+
586
+ {/* Disable 2FA Dialog */}
587
+ <Dialog open={disableOpen} onOpenChange={setDisableOpen}>
588
+ <DialogContent className="max-w-md">
589
+ <DialogHeader>
590
+ <DialogTitle>Disable two-factor authentication</DialogTitle>
591
+ <DialogDescription>
592
+ Enter your current authenticator code to confirm. This will remove 2FA protection from
593
+ your account.
594
+ </DialogDescription>
595
+ </DialogHeader>
596
+ <div className="rounded-sm border border-destructive/20 bg-destructive/10 px-3 py-2 text-sm text-destructive">
597
+ Your account will be less secure without 2FA.
598
+ </div>
599
+ <div className="flex flex-col items-center gap-3">
600
+ <Input
601
+ value={disableCode}
602
+ onChange={(e) => setDisableCode(e.target.value.replace(/\D/g, "").slice(0, 6))}
603
+ className={`w-48 text-center text-2xl tracking-[0.5em] font-mono ${disableError ? "border-destructive" : ""}`}
604
+ maxLength={6}
605
+ inputMode="numeric"
606
+ autoFocus
607
+ />
608
+ <AnimatePresence>
609
+ {disableError && (
610
+ <motion.p
611
+ initial={{ opacity: 0, y: -4 }}
612
+ animate={{ opacity: 1, y: 0 }}
613
+ exit={{ opacity: 0, y: -4 }}
614
+ className="text-sm text-destructive"
615
+ >
616
+ {disableError}
617
+ </motion.p>
618
+ )}
619
+ </AnimatePresence>
620
+ </div>
621
+ <DialogFooter>
622
+ <DialogClose asChild>
623
+ <Button variant="outline">Cancel</Button>
624
+ </DialogClose>
625
+ <Button
626
+ variant="destructive"
627
+ disabled={disableCode.length !== 6 || disabling}
628
+ onClick={handleDisable}
629
+ >
630
+ {disabling ? "Disabling..." : "Disable 2FA"}
631
+ </Button>
632
+ </DialogFooter>
633
+ </DialogContent>
634
+ </Dialog>
635
+
636
+ {/* Regenerate Recovery Codes Dialog */}
637
+ <Dialog open={regenOpen} onOpenChange={setRegenOpen}>
638
+ <DialogContent className="max-w-md">
639
+ {regenStep === "verify" && (
640
+ <>
641
+ <DialogHeader>
642
+ <DialogTitle>Regenerate recovery codes</DialogTitle>
643
+ <DialogDescription>
644
+ Enter your authenticator code to generate new recovery codes. This will invalidate
645
+ all previous codes.
646
+ </DialogDescription>
647
+ </DialogHeader>
648
+ <div className="flex flex-col items-center gap-3">
649
+ <Input
650
+ value={regenCode}
651
+ onChange={(e) => setRegenCode(e.target.value.replace(/\D/g, "").slice(0, 6))}
652
+ className={`w-48 text-center text-2xl tracking-[0.5em] font-mono ${regenError ? "border-destructive" : ""}`}
653
+ maxLength={6}
654
+ inputMode="numeric"
655
+ autoFocus
656
+ />
657
+ <AnimatePresence>
658
+ {regenError && (
659
+ <motion.p
660
+ initial={{ opacity: 0, y: -4 }}
661
+ animate={{ opacity: 1, y: 0 }}
662
+ exit={{ opacity: 0, y: -4 }}
663
+ className="text-sm text-destructive"
664
+ >
665
+ {regenError}
666
+ </motion.p>
667
+ )}
668
+ </AnimatePresence>
669
+ </div>
670
+ <DialogFooter>
671
+ <DialogClose asChild>
672
+ <Button variant="outline">Cancel</Button>
673
+ </DialogClose>
674
+ <Button
675
+ variant="terminal"
676
+ disabled={regenCode.length !== 6 || regenVerifying}
677
+ onClick={handleRegenVerify}
678
+ >
679
+ {regenVerifying ? "Verifying..." : "Generate codes"}
680
+ </Button>
681
+ </DialogFooter>
682
+ </>
683
+ )}
684
+
685
+ {regenStep === "codes" && (
686
+ <>
687
+ <DialogHeader>
688
+ <DialogTitle>New recovery codes</DialogTitle>
689
+ <DialogDescription>
690
+ Your previous codes have been invalidated. Save these new codes in a safe place.
691
+ </DialogDescription>
692
+ </DialogHeader>
693
+ <div className="grid grid-cols-2 gap-2">
694
+ {regenCodes.map((code) => (
695
+ <div
696
+ key={code}
697
+ className="rounded-sm bg-muted px-3 py-2 text-center text-sm font-mono tracking-widest text-foreground"
698
+ >
699
+ {code}
700
+ </div>
701
+ ))}
702
+ </div>
703
+ <div className="flex items-center gap-2">
704
+ <Button
705
+ variant="outline"
706
+ size="sm"
707
+ onClick={() => copyToClipboard(regenCodes.join("\n"))}
708
+ >
709
+ <CopyIcon className="mr-1 size-4" />
710
+ {copied ? "Copied" : "Copy all"}
711
+ </Button>
712
+ <Button variant="outline" size="sm" onClick={() => downloadCodes(regenCodes)}>
713
+ <DownloadIcon className="mr-1 size-4" />
714
+ Download
715
+ </Button>
716
+ </div>
717
+ <div className="rounded-sm border border-chart-3/20 bg-chart-3/10 px-3 py-2 text-xs text-chart-3">
718
+ These codes will not be shown again. Save them now.
719
+ </div>
720
+ <DialogFooter>
721
+ <DialogClose asChild>
722
+ <Button variant="terminal">I&apos;ve saved these codes</Button>
723
+ </DialogClose>
724
+ </DialogFooter>
725
+ </>
726
+ )}
727
+ </DialogContent>
728
+ </Dialog>
729
+ </>
730
+ );
731
+ }
732
+
733
+ // ---------- sessions section ----------
734
+
735
+ function SessionsSection() {
736
+ const [sessions, setSessions] = useState<Session[]>([]);
737
+ const [loading, setLoading] = useState(true);
738
+ const [loadError, setLoadError] = useState(false);
739
+ const [revokeError, setRevokeError] = useState<string | null>(null);
740
+ const [revokingId, setRevokingId] = useState<string | null>(null);
741
+ const [revokingAll, setRevokingAll] = useState(false);
742
+
743
+ const load = useCallback(async () => {
744
+ setLoading(true);
745
+ setLoadError(false);
746
+ try {
747
+ const res = await authClient.listSessions();
748
+ setSessions((res?.data as Session[]) ?? []);
749
+ } catch {
750
+ setLoadError(true);
751
+ } finally {
752
+ setLoading(false);
753
+ }
754
+ }, []);
755
+
756
+ useEffect(() => {
757
+ load();
758
+ }, [load]);
759
+
760
+ async function handleRevoke(token: string) {
761
+ setRevokingId(token);
762
+ setRevokeError(null);
763
+ try {
764
+ await authClient.revokeSession({ token });
765
+ setSessions((prev) => prev.filter((s) => s.token !== token));
766
+ } catch {
767
+ setRevokeError("Failed to revoke session. Please try again.");
768
+ } finally {
769
+ setRevokingId(null);
770
+ }
771
+ }
772
+
773
+ async function handleRevokeAll() {
774
+ setRevokingAll(true);
775
+ setRevokeError(null);
776
+ try {
777
+ await authClient.revokeOtherSessions();
778
+ setSessions((prev) => prev.filter((s) => s.current));
779
+ } catch {
780
+ setRevokeError("Failed to revoke sessions. Please try again.");
781
+ } finally {
782
+ setRevokingAll(false);
783
+ }
784
+ }
785
+
786
+ if (loadError) {
787
+ return (
788
+ <Card>
789
+ <CardHeader>
790
+ <CardTitle>Active Sessions</CardTitle>
791
+ <CardDescription>Devices currently signed into your account</CardDescription>
792
+ </CardHeader>
793
+ <CardContent>
794
+ <div className="flex h-24 flex-col items-center justify-center gap-3">
795
+ <p className="text-sm text-destructive">Failed to load sessions.</p>
796
+ <Button variant="outline" size="sm" onClick={load}>
797
+ Retry
798
+ </Button>
799
+ </div>
800
+ </CardContent>
801
+ </Card>
802
+ );
803
+ }
804
+
805
+ return (
806
+ <Card>
807
+ <CardHeader>
808
+ <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
809
+ <div>
810
+ <CardTitle>Active Sessions</CardTitle>
811
+ <CardDescription>Devices currently signed into your account</CardDescription>
812
+ </div>
813
+ {!loading && sessions.length > 1 && (
814
+ <Button
815
+ variant="destructive"
816
+ size="sm"
817
+ disabled={revokingAll}
818
+ onClick={handleRevokeAll}
819
+ >
820
+ {revokingAll ? "Revoking..." : "Revoke all other sessions"}
821
+ </Button>
822
+ )}
823
+ </div>
824
+ </CardHeader>
825
+ <CardContent>
826
+ {revokeError && (
827
+ <div className="mb-3 rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive">
828
+ {revokeError}
829
+ </div>
830
+ )}
831
+ {loading ? (
832
+ <div className="space-y-3">
833
+ {["s1", "s2", "s3"].map((id) => (
834
+ <div key={id} className="flex gap-4">
835
+ <Skeleton className="h-4 w-32" />
836
+ <Skeleton className="h-4 w-24" />
837
+ <Skeleton className="h-4 w-20" />
838
+ <Skeleton className="h-4 w-16" />
839
+ </div>
840
+ ))}
841
+ </div>
842
+ ) : sessions.length === 0 ? (
843
+ <div className="flex h-24 items-center justify-center">
844
+ <p className="text-sm text-muted-foreground">No active sessions found.</p>
845
+ </div>
846
+ ) : (
847
+ <div className="overflow-x-auto">
848
+ <Table className="min-w-[500px]">
849
+ <TableHeader>
850
+ <TableRow>
851
+ <TableHead className="text-xs uppercase tracking-wider font-medium text-muted-foreground">
852
+ Device
853
+ </TableHead>
854
+ <TableHead className="text-xs uppercase tracking-wider font-medium text-muted-foreground">
855
+ IP Address
856
+ </TableHead>
857
+ <TableHead className="text-xs uppercase tracking-wider font-medium text-muted-foreground">
858
+ Last Active
859
+ </TableHead>
860
+ <TableHead className="w-[100px] text-xs uppercase tracking-wider font-medium text-muted-foreground">
861
+ Actions
862
+ </TableHead>
863
+ </TableRow>
864
+ </TableHeader>
865
+ <TableBody>
866
+ {sessions.map((session) => {
867
+ const ua = session.userAgent ?? "";
868
+ const Icon = deviceIcon(ua);
869
+ const browser = parseBrowser(ua);
870
+ const os = parseOS(ua);
871
+ return (
872
+ <TableRow
873
+ key={session.token}
874
+ className={`transition-colors duration-150 ${session.current ? "bg-terminal/5" : "hover:bg-accent/50"}`}
875
+ >
876
+ <TableCell>
877
+ <div className="flex items-center gap-2">
878
+ <Icon className="size-4 text-muted-foreground" />
879
+ <span className="text-sm">
880
+ {browser}
881
+ {os ? ` on ${os}` : ""}
882
+ </span>
883
+ {session.current && <Badge variant="terminal">Current</Badge>}
884
+ </div>
885
+ </TableCell>
886
+ <TableCell className="text-sm text-muted-foreground">
887
+ {session.ipAddress ?? "\u2014"}
888
+ </TableCell>
889
+ <TableCell>
890
+ <Tooltip>
891
+ <TooltipTrigger className="text-xs text-muted-foreground">
892
+ {relativeTime(
893
+ String(session.updatedAt ?? session.createdAt ?? session.expiresAt),
894
+ )}
895
+ </TooltipTrigger>
896
+ <TooltipContent>
897
+ {new Date(
898
+ session.updatedAt ?? session.createdAt ?? session.expiresAt,
899
+ ).toLocaleString()}
900
+ </TooltipContent>
901
+ </Tooltip>
902
+ </TableCell>
903
+ <TableCell>
904
+ {session.current ? (
905
+ <span className="text-xs text-muted-foreground">This device</span>
906
+ ) : (
907
+ <Button
908
+ variant="outline"
909
+ size="sm"
910
+ disabled={revokingId === session.token}
911
+ className="hover:border-destructive/50 hover:text-destructive"
912
+ onClick={() => handleRevoke(session.token)}
913
+ >
914
+ {revokingId === session.token ? "Revoking..." : "Revoke"}
915
+ </Button>
916
+ )}
917
+ </TableCell>
918
+ </TableRow>
919
+ );
920
+ })}
921
+ </TableBody>
922
+ </Table>
923
+ </div>
924
+ )}
925
+ </CardContent>
926
+ </Card>
927
+ );
928
+ }
929
+
930
+ // ---------- login history section ----------
931
+
932
+ const LOGIN_PAGE_SIZE = 20;
933
+
934
+ function LoginHistorySection() {
935
+ const [data, setData] = useState<LoginHistoryResponse | null>(null);
936
+ const [loading, setLoading] = useState(true);
937
+ const [loadError, setLoadError] = useState(false);
938
+ const [unavailable, setUnavailable] = useState(false);
939
+ const [offset, setOffset] = useState(0);
940
+
941
+ const load = useCallback(async (newOffset: number) => {
942
+ setLoading(true);
943
+ setLoadError(false);
944
+ try {
945
+ const result = await fetchLoginHistory({
946
+ limit: LOGIN_PAGE_SIZE,
947
+ offset: newOffset,
948
+ });
949
+ setData(result);
950
+ setOffset(newOffset);
951
+ } catch (err) {
952
+ // 404 = endpoint not available yet
953
+ if (err instanceof Error && err.message.includes("404")) {
954
+ setUnavailable(true);
955
+ } else {
956
+ setLoadError(true);
957
+ }
958
+ } finally {
959
+ setLoading(false);
960
+ }
961
+ }, []);
962
+
963
+ useEffect(() => {
964
+ load(0);
965
+ }, [load]);
966
+
967
+ if (unavailable) {
968
+ return (
969
+ <Card>
970
+ <CardHeader>
971
+ <CardTitle>Login History</CardTitle>
972
+ <CardDescription>Recent authentication events</CardDescription>
973
+ </CardHeader>
974
+ <CardContent>
975
+ <div className="flex h-24 items-center justify-center">
976
+ <p className="text-sm text-muted-foreground">Login history is not available yet.</p>
977
+ </div>
978
+ </CardContent>
979
+ </Card>
980
+ );
981
+ }
982
+
983
+ if (loadError) {
984
+ return (
985
+ <Card>
986
+ <CardHeader>
987
+ <CardTitle>Login History</CardTitle>
988
+ <CardDescription>Recent authentication events</CardDescription>
989
+ </CardHeader>
990
+ <CardContent>
991
+ <div className="flex h-24 flex-col items-center justify-center gap-3">
992
+ <p className="text-sm text-destructive">Failed to load login history.</p>
993
+ <Button variant="outline" size="sm" onClick={() => load(offset)}>
994
+ Retry
995
+ </Button>
996
+ </div>
997
+ </CardContent>
998
+ </Card>
999
+ );
1000
+ }
1001
+
1002
+ return (
1003
+ <Card>
1004
+ <CardHeader>
1005
+ <CardTitle>Login History</CardTitle>
1006
+ <CardDescription>
1007
+ {data ? `${data.total} total events` : "Recent authentication events"}
1008
+ </CardDescription>
1009
+ </CardHeader>
1010
+ <CardContent>
1011
+ {loading ? (
1012
+ <div className="space-y-3">
1013
+ {["s1", "s2", "s3", "s4", "s5"].map((id) => (
1014
+ <div key={id} className="flex gap-4">
1015
+ <Skeleton className="h-4 w-20" />
1016
+ <Skeleton className="h-4 w-24" />
1017
+ <Skeleton className="h-4 w-32" />
1018
+ <Skeleton className="h-4 w-16" />
1019
+ </div>
1020
+ ))}
1021
+ </div>
1022
+ ) : !data || data.attempts.length === 0 ? (
1023
+ <div className="flex h-24 items-center justify-center">
1024
+ <p className="text-sm text-muted-foreground">No login history yet.</p>
1025
+ </div>
1026
+ ) : (
1027
+ <>
1028
+ <div className="overflow-x-auto">
1029
+ <Table className="min-w-[500px]">
1030
+ <TableHeader>
1031
+ <TableRow>
1032
+ <TableHead className="w-[100px] text-xs uppercase tracking-wider font-medium text-muted-foreground">
1033
+ Time
1034
+ </TableHead>
1035
+ <TableHead className="text-xs uppercase tracking-wider font-medium text-muted-foreground">
1036
+ Device
1037
+ </TableHead>
1038
+ <TableHead className="text-xs uppercase tracking-wider font-medium text-muted-foreground">
1039
+ IP Address
1040
+ </TableHead>
1041
+ <TableHead className="text-xs uppercase tracking-wider font-medium text-muted-foreground">
1042
+ Location
1043
+ </TableHead>
1044
+ <TableHead className="w-[80px] text-xs uppercase tracking-wider font-medium text-muted-foreground">
1045
+ Result
1046
+ </TableHead>
1047
+ </TableRow>
1048
+ </TableHeader>
1049
+ <TableBody>
1050
+ {data.attempts.map((attempt: LoginAttempt) => (
1051
+ <TableRow
1052
+ key={attempt.id}
1053
+ className={`transition-colors duration-150 ${attempt.success ? "hover:bg-accent/50" : "bg-destructive/5 hover:bg-destructive/10"}`}
1054
+ >
1055
+ <TableCell className="w-[100px]">
1056
+ <Tooltip>
1057
+ <TooltipTrigger className="text-xs text-muted-foreground">
1058
+ {relativeTime(attempt.timestamp)}
1059
+ </TooltipTrigger>
1060
+ <TooltipContent>
1061
+ {new Date(attempt.timestamp).toLocaleString()}
1062
+ </TooltipContent>
1063
+ </Tooltip>
1064
+ </TableCell>
1065
+ <TableCell className="text-sm text-muted-foreground">
1066
+ {parseBrowser(attempt.userAgent)}
1067
+ </TableCell>
1068
+ <TableCell className="text-sm text-muted-foreground">{attempt.ip}</TableCell>
1069
+ <TableCell className="text-sm text-muted-foreground">
1070
+ {attempt.location ?? "\u2014"}
1071
+ </TableCell>
1072
+ <TableCell>
1073
+ {attempt.success ? (
1074
+ <Badge variant="terminal">Success</Badge>
1075
+ ) : (
1076
+ <Badge variant="destructive">Failed</Badge>
1077
+ )}
1078
+ </TableCell>
1079
+ </TableRow>
1080
+ ))}
1081
+ </TableBody>
1082
+ </Table>
1083
+ </div>
1084
+
1085
+ {data.total > LOGIN_PAGE_SIZE && (
1086
+ <div className="mt-4 flex items-center justify-between">
1087
+ <p className="text-xs text-muted-foreground">
1088
+ Showing {offset + 1}&ndash;
1089
+ {Math.min(offset + LOGIN_PAGE_SIZE, data.total)} of {data.total}
1090
+ </p>
1091
+ <div className="flex gap-2">
1092
+ <Button
1093
+ variant="outline"
1094
+ size="sm"
1095
+ disabled={offset === 0}
1096
+ onClick={() => load(Math.max(0, offset - LOGIN_PAGE_SIZE))}
1097
+ >
1098
+ Previous
1099
+ </Button>
1100
+ <Button
1101
+ variant="outline"
1102
+ size="sm"
1103
+ disabled={!data.hasMore}
1104
+ onClick={() => load(offset + LOGIN_PAGE_SIZE)}
1105
+ >
1106
+ Next
1107
+ </Button>
1108
+ </div>
1109
+ </div>
1110
+ )}
1111
+ </>
1112
+ )}
1113
+ </CardContent>
1114
+ </Card>
1115
+ );
1116
+ }
1117
+
1118
+ // ---------- page ----------
1119
+
1120
+ export default function SecurityPage() {
1121
+ return (
1122
+ <div className="max-w-4xl space-y-6">
1123
+ <div>
1124
+ <h1 className="text-2xl font-bold tracking-tight">Security</h1>
1125
+ <p className="text-sm text-muted-foreground">Manage your account security settings</p>
1126
+ </div>
1127
+
1128
+ <TwoFactorSection />
1129
+ <SessionsSection />
1130
+ <LoginHistorySection />
1131
+ </div>
1132
+ );
1133
+ }