@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.
- package/.env.paperclip +18 -0
- package/.env.wopr +18 -0
- package/README.md +36 -0
- package/biome.json +52 -0
- package/next.config.ts +45 -0
- package/package.json +84 -0
- package/postcss.config.mjs +7 -0
- package/public/file.svg +1 -0
- package/public/globe.svg +1 -0
- package/public/window.svg +1 -0
- package/src/__tests__/__snapshots__/layout-snapshots.test.tsx.snap +741 -0
- package/src/__tests__/account-page-redirect.test.tsx +73 -0
- package/src/__tests__/account-switcher.test.tsx +85 -0
- package/src/__tests__/activity-page.test.tsx +176 -0
- package/src/__tests__/add-payment-method-dialog.test.tsx +160 -0
- package/src/__tests__/admin-api.test.ts +244 -0
- package/src/__tests__/admin-gpu-api.test.ts +188 -0
- package/src/__tests__/admin-guard.test.tsx +79 -0
- package/src/__tests__/admin-marketplace-api.test.ts +179 -0
- package/src/__tests__/admin-middleware.test.ts +157 -0
- package/src/__tests__/admin-tenant-table.test.tsx +95 -0
- package/src/__tests__/affiliate-dashboard.test.tsx +178 -0
- package/src/__tests__/api-401-redirect.test.ts +78 -0
- package/src/__tests__/api-client.test.ts +316 -0
- package/src/__tests__/api-config.test.ts +89 -0
- package/src/__tests__/api-control-instance.test.ts +69 -0
- package/src/__tests__/api-fleet-resources.test.ts +52 -0
- package/src/__tests__/api-fleet-trpc.test.ts +252 -0
- package/src/__tests__/api-get-instance-config.test.ts +41 -0
- package/src/__tests__/api-null-guards.test.ts +244 -0
- package/src/__tests__/api-rename-instance.test.ts +60 -0
- package/src/__tests__/api-update-instance-config.test.ts +60 -0
- package/src/__tests__/audit-log-table-pagination.test.tsx +136 -0
- package/src/__tests__/auth-client.test.ts +87 -0
- package/src/__tests__/auth-password-reset.test.tsx +435 -0
- package/src/__tests__/auth-redirect.test.tsx +60 -0
- package/src/__tests__/auth.test.tsx +269 -0
- package/src/__tests__/auto-topup-card.test.tsx +257 -0
- package/src/__tests__/backups-tab.test.tsx +221 -0
- package/src/__tests__/billing-byok-callout.test.tsx +76 -0
- package/src/__tests__/billing-layout-nav-hidden.test.tsx +47 -0
- package/src/__tests__/billing-payment-org-invoices.test.tsx +123 -0
- package/src/__tests__/billing.test.tsx +509 -0
- package/src/__tests__/bot-settings/resources-tab.test.tsx +119 -0
- package/src/__tests__/bot-settings/storage-tab.test.tsx +80 -0
- package/src/__tests__/bot-settings/vps-info-panel.test.tsx +108 -0
- package/src/__tests__/bot-settings/vps-upgrade-card.test.tsx +52 -0
- package/src/__tests__/bot-settings-data-control.test.ts +49 -0
- package/src/__tests__/bot-settings-restart.test.tsx +149 -0
- package/src/__tests__/bot-settings.test.tsx +678 -0
- package/src/__tests__/brand.test.ts +335 -0
- package/src/__tests__/buy-credits-panel.test.tsx +249 -0
- package/src/__tests__/buy-crypto-credits-panel.test.tsx +178 -0
- package/src/__tests__/capability-conflicts.test.ts +88 -0
- package/src/__tests__/capability-resolver.test.tsx +173 -0
- package/src/__tests__/changeset-detail.test.tsx +156 -0
- package/src/__tests__/channel-setup-logger.test.ts +22 -0
- package/src/__tests__/channel-setup-toast.test.tsx +60 -0
- package/src/__tests__/channel-wizard.test.tsx +505 -0
- package/src/__tests__/chat/ambient-dot.test.tsx +35 -0
- package/src/__tests__/chat/chat-input.test.tsx +78 -0
- package/src/__tests__/chat/chat-message.test.tsx +45 -0
- package/src/__tests__/chat/chat-panel.test.tsx +111 -0
- package/src/__tests__/chat/chat-widget.test.tsx +82 -0
- package/src/__tests__/chat-store.test.ts +87 -0
- package/src/__tests__/command-center.test.tsx +246 -0
- package/src/__tests__/compliance-retention-edit.test.tsx +134 -0
- package/src/__tests__/coupon-input.test.tsx +119 -0
- package/src/__tests__/create-instance.test.tsx +96 -0
- package/src/__tests__/create-org-wizard.test.tsx +200 -0
- package/src/__tests__/credit-balance.test.tsx +103 -0
- package/src/__tests__/credits.test.tsx +376 -0
- package/src/__tests__/csrf-middleware.test.ts +198 -0
- package/src/__tests__/degraded-state-banner.test.tsx +130 -0
- package/src/__tests__/dividend-calculator.test.tsx +20 -0
- package/src/__tests__/dividend-stats.test.tsx +64 -0
- package/src/__tests__/dividend.test.tsx +169 -0
- package/src/__tests__/dockerfile.test.ts +110 -0
- package/src/__tests__/email-verification-banner.test.tsx +64 -0
- package/src/__tests__/env-example.test.ts +25 -0
- package/src/__tests__/error-boundaries.test.tsx +64 -0
- package/src/__tests__/fetch-pricing.test.ts +121 -0
- package/src/__tests__/field-oauth.test.tsx +302 -0
- package/src/__tests__/fixtures/mock-manifests-data.js +372 -0
- package/src/__tests__/fixtures/mock-manifests.ts +24 -0
- package/src/__tests__/fleet-health-timestamp.test.tsx +101 -0
- package/src/__tests__/fleet-health-update.test.tsx +83 -0
- package/src/__tests__/format-credit.test.ts +58 -0
- package/src/__tests__/gpu-dashboard.test.tsx +236 -0
- package/src/__tests__/hosted-usage-date-range.test.tsx +54 -0
- package/src/__tests__/instance-detail.test.tsx +571 -0
- package/src/__tests__/instance-list.test.tsx +230 -0
- package/src/__tests__/landing-hero.test.tsx +27 -0
- package/src/__tests__/landing-nav.test.tsx +24 -0
- package/src/__tests__/layout-snapshots.test.tsx +167 -0
- package/src/__tests__/logger.test.ts +54 -0
- package/src/__tests__/login-page-redirect.test.tsx +142 -0
- package/src/__tests__/manifest-validation.test.ts +126 -0
- package/src/__tests__/marketplace-admin.test.tsx +151 -0
- package/src/__tests__/marketplace.test.tsx +609 -0
- package/src/__tests__/merge-api-rates.test.ts +70 -0
- package/src/__tests__/middleware.test.ts +690 -0
- package/src/__tests__/network-page.test.tsx +100 -0
- package/src/__tests__/next-config-headers.test.ts +28 -0
- package/src/__tests__/not-found.test.tsx +26 -0
- package/src/__tests__/notifications.test.tsx +128 -0
- package/src/__tests__/oauth-buttons.test.tsx +101 -0
- package/src/__tests__/oauth-error-mapping.test.tsx +97 -0
- package/src/__tests__/observability.test.tsx +541 -0
- package/src/__tests__/onboarding-data.test.ts +363 -0
- package/src/__tests__/onboarding-page.test.tsx +113 -0
- package/src/__tests__/onboarding-store.test.ts +121 -0
- package/src/__tests__/org-billing-api.test.tsx +70 -0
- package/src/__tests__/org-billing-null-guards.test.ts +64 -0
- package/src/__tests__/org-billing-page.test.tsx +124 -0
- package/src/__tests__/plugin-definition.test.ts +43 -0
- package/src/__tests__/plugin-install-flow.test.tsx +535 -0
- package/src/__tests__/plugin-registry.test.tsx +475 -0
- package/src/__tests__/plugin-setup/setup-chat-panel.test.ts +142 -0
- package/src/__tests__/plugin-setup/use-plugin-setup-chat.test.ts +49 -0
- package/src/__tests__/plugin-tool-definitions.test.ts +51 -0
- package/src/__tests__/plugin-tool-sync.test.ts +59 -0
- package/src/__tests__/portfolio-chart.test.tsx +24 -0
- package/src/__tests__/pricing.test.tsx +107 -0
- package/src/__tests__/promotion-form.test.tsx +180 -0
- package/src/__tests__/promotions-list.test.tsx +194 -0
- package/src/__tests__/provider-key-api.test.ts +134 -0
- package/src/__tests__/resend-verification-button.test.tsx +104 -0
- package/src/__tests__/sanitize-redirect-url.test.ts +47 -0
- package/src/__tests__/secrets-audit-pagination.test.tsx +139 -0
- package/src/__tests__/settings.test.tsx +937 -0
- package/src/__tests__/setup-checklist.test.tsx +274 -0
- package/src/__tests__/setup.ts +82 -0
- package/src/__tests__/smoke.test.tsx +10 -0
- package/src/__tests__/snapshot-api.test.ts +104 -0
- package/src/__tests__/status-api.test.ts +46 -0
- package/src/__tests__/status-badge.test.tsx +33 -0
- package/src/__tests__/status-colors.test.ts +83 -0
- package/src/__tests__/status-page.test.tsx +86 -0
- package/src/__tests__/step-superpowers.test.tsx +218 -0
- package/src/__tests__/story-sections.test.tsx +24 -0
- package/src/__tests__/superpower-content-sanitize.test.tsx +87 -0
- package/src/__tests__/superpower-content.test.tsx +44 -0
- package/src/__tests__/suspension-banner.test.tsx +140 -0
- package/src/__tests__/tenant-context.test.tsx +146 -0
- package/src/__tests__/tenant-keys-api.test.ts +114 -0
- package/src/__tests__/tenant-table-pagination.test.tsx +124 -0
- package/src/__tests__/terminal-log-cleanup.test.tsx +51 -0
- package/src/__tests__/terminal-sequence.test.tsx +28 -0
- package/src/__tests__/transaction-history.test.tsx +325 -0
- package/src/__tests__/trpc-types.test.ts +102 -0
- package/src/__tests__/use-capability-meta.test.ts +161 -0
- package/src/__tests__/use-chat.test.ts +616 -0
- package/src/__tests__/use-has-org.test.ts +44 -0
- package/src/__tests__/use-image-status.test.ts +77 -0
- package/src/__tests__/use-pagination-params.test.ts +88 -0
- package/src/__tests__/use-plugin-setup-chat-stale-closure.test.ts +53 -0
- package/src/__tests__/use-webmcp.test.ts +119 -0
- package/src/__tests__/validate-elevenlabs-key.test.ts +95 -0
- package/src/__tests__/validate-redirect-url.test.ts +61 -0
- package/src/__tests__/verify-page.test.tsx +140 -0
- package/src/__tests__/verify-redirect.test.tsx +41 -0
- package/src/__tests__/verify-result-banner.test.tsx +66 -0
- package/src/__tests__/webmcp-feature-detect.test.ts +54 -0
- package/src/__tests__/webmcp-hook.test.tsx +72 -0
- package/src/__tests__/webmcp-marketplace-onboarding-tools.test.ts +185 -0
- package/src/__tests__/webmcp-register.test.ts +103 -0
- package/src/__tests__/webmcp-set-provider.test.ts +47 -0
- package/src/__tests__/webmcp-tools.test.ts +348 -0
- package/src/app/(auth)/error.tsx +72 -0
- package/src/app/(auth)/forgot-password/page.tsx +137 -0
- package/src/app/(auth)/layout.tsx +14 -0
- package/src/app/(auth)/loading.tsx +26 -0
- package/src/app/(auth)/login/page.tsx +188 -0
- package/src/app/(auth)/reset-password/page.tsx +169 -0
- package/src/app/(auth)/signup/page.tsx +309 -0
- package/src/app/(dashboard)/billing/credits/page.tsx +209 -0
- package/src/app/(dashboard)/billing/error.tsx +72 -0
- package/src/app/(dashboard)/billing/layout.tsx +73 -0
- package/src/app/(dashboard)/billing/loading.tsx +41 -0
- package/src/app/(dashboard)/billing/payment/page.tsx +639 -0
- package/src/app/(dashboard)/billing/plans/page.tsx +58 -0
- package/src/app/(dashboard)/billing/referrals/page.tsx +7 -0
- package/src/app/(dashboard)/billing/usage/hosted/page.tsx +348 -0
- package/src/app/(dashboard)/billing/usage/page.tsx +663 -0
- package/src/app/(dashboard)/changesets/[id]/changeset-detail-client.tsx +400 -0
- package/src/app/(dashboard)/changesets/[id]/error.tsx +57 -0
- package/src/app/(dashboard)/changesets/[id]/loading.tsx +23 -0
- package/src/app/(dashboard)/changesets/[id]/page.tsx +10 -0
- package/src/app/(dashboard)/changesets/error.tsx +72 -0
- package/src/app/(dashboard)/changesets/page.tsx +10 -0
- package/src/app/(dashboard)/chat/page.tsx +74 -0
- package/src/app/(dashboard)/dashboard/bots/[id]/settings/page.tsx +10 -0
- package/src/app/(dashboard)/dashboard/network/page.tsx +97 -0
- package/src/app/(dashboard)/dashboard/page.tsx +13 -0
- package/src/app/(dashboard)/error.tsx +72 -0
- package/src/app/(dashboard)/layout.tsx +113 -0
- package/src/app/(dashboard)/loading.tsx +27 -0
- package/src/app/(dashboard)/marketplace/[plugin]/page.tsx +548 -0
- package/src/app/(dashboard)/marketplace/error.tsx +72 -0
- package/src/app/(dashboard)/marketplace/loading.tsx +27 -0
- package/src/app/(dashboard)/marketplace/page.tsx +268 -0
- package/src/app/(dashboard)/not-found.tsx +46 -0
- package/src/app/(dashboard)/onboarding/page.tsx +267 -0
- package/src/app/(dashboard)/settings/account/page.tsx +132 -0
- package/src/app/(dashboard)/settings/activity/page.tsx +280 -0
- package/src/app/(dashboard)/settings/api-keys/page.tsx +530 -0
- package/src/app/(dashboard)/settings/brain/page.tsx +412 -0
- package/src/app/(dashboard)/settings/error.tsx +72 -0
- package/src/app/(dashboard)/settings/layout.tsx +114 -0
- package/src/app/(dashboard)/settings/loading.tsx +31 -0
- package/src/app/(dashboard)/settings/notifications/page.tsx +216 -0
- package/src/app/(dashboard)/settings/org/page.tsx +617 -0
- package/src/app/(dashboard)/settings/profile/page.tsx +510 -0
- package/src/app/(dashboard)/settings/providers/page.tsx +842 -0
- package/src/app/(dashboard)/settings/secrets/page.tsx +658 -0
- package/src/app/(dashboard)/settings/security/page.tsx +1133 -0
- package/src/app/admin/accounting/loading.tsx +32 -0
- package/src/app/admin/accounting/page.tsx +5 -0
- package/src/app/admin/affiliates/loading.tsx +32 -0
- package/src/app/admin/affiliates/page.tsx +5 -0
- package/src/app/admin/audit/loading.tsx +32 -0
- package/src/app/admin/audit/page.tsx +5 -0
- package/src/app/admin/billing-health/loading.tsx +17 -0
- package/src/app/admin/billing-health/page.tsx +10 -0
- package/src/app/admin/compliance/page.tsx +5 -0
- package/src/app/admin/error.tsx +72 -0
- package/src/app/admin/gpu/loading.tsx +38 -0
- package/src/app/admin/gpu/page.tsx +5 -0
- package/src/app/admin/incidents/page.tsx +10 -0
- package/src/app/admin/inference/loading.tsx +32 -0
- package/src/app/admin/inference/page.tsx +5 -0
- package/src/app/admin/layout.tsx +44 -0
- package/src/app/admin/loading.tsx +32 -0
- package/src/app/admin/marketplace/loading.tsx +32 -0
- package/src/app/admin/marketplace/page.tsx +5 -0
- package/src/app/admin/migrations/loading.tsx +22 -0
- package/src/app/admin/migrations/page.tsx +5 -0
- package/src/app/admin/onboarding/loading.tsx +18 -0
- package/src/app/admin/onboarding/page.tsx +5 -0
- package/src/app/admin/promotions/[id]/edit/loading.tsx +16 -0
- package/src/app/admin/promotions/[id]/edit/page.tsx +56 -0
- package/src/app/admin/promotions/[id]/loading.tsx +15 -0
- package/src/app/admin/promotions/[id]/page.tsx +311 -0
- package/src/app/admin/promotions/loading.tsx +21 -0
- package/src/app/admin/promotions/new/loading.tsx +16 -0
- package/src/app/admin/promotions/new/page.tsx +12 -0
- package/src/app/admin/promotions/page.tsx +266 -0
- package/src/app/admin/rate-overrides/loading.tsx +17 -0
- package/src/app/admin/rate-overrides/page.tsx +290 -0
- package/src/app/admin/roles/loading.tsx +27 -0
- package/src/app/admin/roles/page.tsx +5 -0
- package/src/app/admin/tenants/loading.tsx +32 -0
- package/src/app/admin/tenants/page.tsx +5 -0
- package/src/app/apple-icon.tsx +32 -0
- package/src/app/auth/callback/[provider]/page.tsx +104 -0
- package/src/app/auth/verify/page.tsx +224 -0
- package/src/app/channels/error.tsx +72 -0
- package/src/app/channels/loading.tsx +29 -0
- package/src/app/channels/page.tsx +262 -0
- package/src/app/channels/setup/[plugin]/page.tsx +136 -0
- package/src/app/error.tsx +72 -0
- package/src/app/favicon.ico +0 -0
- package/src/app/fleet/error.tsx +72 -0
- package/src/app/fleet/health/page.tsx +9 -0
- package/src/app/fleet/layout.tsx +14 -0
- package/src/app/fleet/loading.tsx +33 -0
- package/src/app/fleet/page.tsx +5 -0
- package/src/app/global-error.tsx +96 -0
- package/src/app/globals.css +251 -0
- package/src/app/icon.svg +4 -0
- package/src/app/instances/[id]/instance-detail-client.tsx +1298 -0
- package/src/app/instances/[id]/page.tsx +10 -0
- package/src/app/instances/error.tsx +72 -0
- package/src/app/instances/instance-list-client.tsx +540 -0
- package/src/app/instances/loading.tsx +33 -0
- package/src/app/instances/new/create-instance-client.tsx +377 -0
- package/src/app/instances/new/page.tsx +9 -0
- package/src/app/instances/page.tsx +9 -0
- package/src/app/layout.tsx +83 -0
- package/src/app/not-found.tsx +38 -0
- package/src/app/og/route.tsx +50 -0
- package/src/app/page.tsx +39 -0
- package/src/app/plugins/error.tsx +72 -0
- package/src/app/plugins/layout.tsx +14 -0
- package/src/app/plugins/loading.tsx +30 -0
- package/src/app/plugins/page.tsx +555 -0
- package/src/app/pricing/error.tsx +72 -0
- package/src/app/pricing/loading.tsx +25 -0
- package/src/app/pricing/page.tsx +20 -0
- package/src/app/privacy/page.tsx +406 -0
- package/src/app/robots.ts +9 -0
- package/src/app/sitemap.ts +11 -0
- package/src/app/status/error.tsx +72 -0
- package/src/app/status/loading.tsx +21 -0
- package/src/app/status/page.tsx +20 -0
- package/src/app/terms/page.tsx +414 -0
- package/src/components/account-switcher.tsx +82 -0
- package/src/components/admin/accounting-dashboard.tsx +190 -0
- package/src/components/admin/admin-guard.tsx +36 -0
- package/src/components/admin/admin-nav.tsx +71 -0
- package/src/components/admin/affiliate-dashboard.tsx +564 -0
- package/src/components/admin/audit-log-table.tsx +336 -0
- package/src/components/admin/billing-health-dashboard.test.tsx +40 -0
- package/src/components/admin/billing-health-dashboard.tsx +416 -0
- package/src/components/admin/bulk-actions-bar.test.tsx +92 -0
- package/src/components/admin/bulk-actions-bar.tsx +80 -0
- package/src/components/admin/bulk-export-dialog.test.tsx +75 -0
- package/src/components/admin/bulk-export-dialog.tsx +189 -0
- package/src/components/admin/bulk-grant-dialog.test.tsx +81 -0
- package/src/components/admin/bulk-grant-dialog.tsx +147 -0
- package/src/components/admin/bulk-preview-dialog.test.tsx +72 -0
- package/src/components/admin/bulk-preview-dialog.tsx +106 -0
- package/src/components/admin/bulk-reactivate-dialog.test.tsx +51 -0
- package/src/components/admin/bulk-reactivate-dialog.tsx +55 -0
- package/src/components/admin/bulk-select-all-banner.test.tsx +36 -0
- package/src/components/admin/bulk-select-all-banner.tsx +44 -0
- package/src/components/admin/bulk-suspend-dialog.test.tsx +77 -0
- package/src/components/admin/bulk-suspend-dialog.tsx +129 -0
- package/src/components/admin/bulk-undo-toast.test.tsx +66 -0
- package/src/components/admin/bulk-undo-toast.tsx +121 -0
- package/src/components/admin/compliance-dashboard.tsx +1341 -0
- package/src/components/admin/gpu-dashboard.tsx +552 -0
- package/src/components/admin/grant-credits-dialog.tsx +121 -0
- package/src/components/admin/incident-dashboard.test.tsx +44 -0
- package/src/components/admin/incident-dashboard.tsx +717 -0
- package/src/components/admin/inference-dashboard.tsx +415 -0
- package/src/components/admin/marketplace-admin.tsx +765 -0
- package/src/components/admin/migrations-dashboard.tsx +404 -0
- package/src/components/admin/onboarding-dashboard.tsx +422 -0
- package/src/components/admin/promotions/promotion-form.tsx +440 -0
- package/src/components/admin/roles-dashboard.tsx +278 -0
- package/src/components/admin/suspend-dialog.tsx +98 -0
- package/src/components/admin/tenant-notes-panel.tsx +134 -0
- package/src/components/admin/tenant-row-actions.tsx +78 -0
- package/src/components/admin/tenant-table.tsx +339 -0
- package/src/components/auth/auth-error.tsx +22 -0
- package/src/components/auth/auth-redirect.tsx +18 -0
- package/src/components/auth/auth-shell.tsx +25 -0
- package/src/components/auth/email-verification-banner.tsx +25 -0
- package/src/components/auth/email-verification-result-banner.tsx +70 -0
- package/src/components/auth/resend-verification-button.tsx +94 -0
- package/src/components/auth/wopr-wordmark.tsx +19 -0
- package/src/components/billing/add-payment-method-dialog.tsx +267 -0
- package/src/components/billing/affiliate-dashboard.tsx +300 -0
- package/src/components/billing/auto-topup-card.tsx +432 -0
- package/src/components/billing/buy-credits-panel.tsx +180 -0
- package/src/components/billing/buy-crypto-credits-panel.tsx +96 -0
- package/src/components/billing/byok-callout.tsx +87 -0
- package/src/components/billing/coupon-input.tsx +86 -0
- package/src/components/billing/credit-balance.tsx +79 -0
- package/src/components/billing/degraded-state-banner.tsx +95 -0
- package/src/components/billing/dividend-banner.tsx +97 -0
- package/src/components/billing/dividend-eligibility.tsx +86 -0
- package/src/components/billing/dividend-pool-stats.tsx +86 -0
- package/src/components/billing/first-dividend-dialog.tsx +109 -0
- package/src/components/billing/low-balance-banner.tsx +50 -0
- package/src/components/billing/org-billing-page.tsx +360 -0
- package/src/components/billing/suspension-banner.tsx +53 -0
- package/src/components/billing/transaction-history.tsx +239 -0
- package/src/components/bot-settings/__tests__/bot-settings-client.test.tsx +205 -0
- package/src/components/bot-settings/backups-tab.tsx +377 -0
- package/src/components/bot-settings/bot-settings-client.tsx +1712 -0
- package/src/components/bot-settings/resources-tab.tsx +203 -0
- package/src/components/bot-settings/storage-tab.tsx +248 -0
- package/src/components/bot-settings/vps-info-panel.tsx +132 -0
- package/src/components/bot-settings/vps-upgrade-card.tsx +110 -0
- package/src/components/capability/CapabilityResolver.tsx +113 -0
- package/src/components/channel-wizard/field-interactive.tsx +48 -0
- package/src/components/channel-wizard/field-oauth.tsx +181 -0
- package/src/components/channel-wizard/field-paste.tsx +47 -0
- package/src/components/channel-wizard/field-qr.tsx +302 -0
- package/src/components/channel-wizard/index.ts +6 -0
- package/src/components/channel-wizard/step-renderer.tsx +103 -0
- package/src/components/channel-wizard/wizard.tsx +200 -0
- package/src/components/chat/ambient-dot.tsx +32 -0
- package/src/components/chat/chat-input.tsx +56 -0
- package/src/components/chat/chat-message.tsx +36 -0
- package/src/components/chat/chat-panel.tsx +138 -0
- package/src/components/chat/chat-widget.tsx +41 -0
- package/src/components/chat/index.ts +5 -0
- package/src/components/dashboard/command-center.tsx +614 -0
- package/src/components/instances/friends-tab.test.tsx +265 -0
- package/src/components/instances/friends-tab.tsx +721 -0
- package/src/components/landing/hero.tsx +53 -0
- package/src/components/landing/landing-nav.tsx +21 -0
- package/src/components/landing/landing-page.tsx +71 -0
- package/src/components/landing/portfolio-chart.tsx +349 -0
- package/src/components/landing/story-sections.tsx +50 -0
- package/src/components/landing/terminal-lines.ts +99 -0
- package/src/components/landing/terminal-sequence.tsx +453 -0
- package/src/components/landing/typing-effect.tsx +43 -0
- package/src/components/marketplace/category-filter.tsx +61 -0
- package/src/components/marketplace/empty-state.tsx +61 -0
- package/src/components/marketplace/featured-heroes.tsx +84 -0
- package/src/components/marketplace/first-visit-hero.tsx +110 -0
- package/src/components/marketplace/index.ts +9 -0
- package/src/components/marketplace/install-wizard.tsx +782 -0
- package/src/components/marketplace/marketplace-tabs.tsx +54 -0
- package/src/components/marketplace/plugin-card.tsx +129 -0
- package/src/components/marketplace/superpower-card.tsx +104 -0
- package/src/components/marketplace/superpower-content.tsx +117 -0
- package/src/components/marketplace/terminal-search.tsx +67 -0
- package/src/components/oauth-buttons.tsx +75 -0
- package/src/components/observability/fleet-health.tsx +370 -0
- package/src/components/observability/health-overview.tsx +246 -0
- package/src/components/observability/logs-viewer.tsx +215 -0
- package/src/components/observability/metrics-dashboard.tsx +288 -0
- package/src/components/onboarding/fallback-setup.tsx +137 -0
- package/src/components/onboarding/index.ts +3 -0
- package/src/components/onboarding/setup-checklist.tsx +333 -0
- package/src/components/onboarding/step-superpowers.tsx +122 -0
- package/src/components/plugin-setup/index.ts +1 -0
- package/src/components/plugin-setup/setup-chat-panel.tsx +188 -0
- package/src/components/pricing/dividend-calculator.tsx +47 -0
- package/src/components/pricing/dividend-stats.tsx +117 -0
- package/src/components/pricing/pricing-page.tsx +229 -0
- package/src/components/settings/create-org-wizard.tsx +225 -0
- package/src/components/sidebar.tsx +202 -0
- package/src/components/status/status-page.tsx +209 -0
- package/src/components/status-badge.tsx +28 -0
- package/src/components/theme-provider.tsx +8 -0
- package/src/components/ui/alert-dialog.tsx +141 -0
- package/src/components/ui/badge.tsx +47 -0
- package/src/components/ui/banner.tsx +36 -0
- package/src/components/ui/button.tsx +64 -0
- package/src/components/ui/card.tsx +75 -0
- package/src/components/ui/checkbox.tsx +52 -0
- package/src/components/ui/collapsible.tsx +31 -0
- package/src/components/ui/credit-detailed.tsx +33 -0
- package/src/components/ui/dialog.tsx +143 -0
- package/src/components/ui/dropdown-menu.tsx +228 -0
- package/src/components/ui/form.tsx +151 -0
- package/src/components/ui/input.tsx +21 -0
- package/src/components/ui/label.tsx +21 -0
- package/src/components/ui/popover.tsx +74 -0
- package/src/components/ui/progress.tsx +28 -0
- package/src/components/ui/radio-group.tsx +45 -0
- package/src/components/ui/select.tsx +175 -0
- package/src/components/ui/separator.tsx +28 -0
- package/src/components/ui/sheet.tsx +125 -0
- package/src/components/ui/skeleton.tsx +15 -0
- package/src/components/ui/switch.tsx +35 -0
- package/src/components/ui/table.tsx +92 -0
- package/src/components/ui/tabs.tsx +81 -0
- package/src/components/ui/textarea.tsx +18 -0
- package/src/components/ui/tooltip.tsx +44 -0
- package/src/config/provider-docs.ts +17 -0
- package/src/hooks/__tests__/use-async.test.ts +127 -0
- package/src/hooks/__tests__/use-count-up.test.ts +129 -0
- package/src/hooks/__tests__/use-debounce.test.ts +105 -0
- package/src/hooks/__tests__/use-fleet-sse.test.ts +216 -0
- package/src/hooks/__tests__/use-local-storage.test.ts +74 -0
- package/src/hooks/__tests__/use-mobile.test.ts +86 -0
- package/src/hooks/__tests__/use-save-queue.test.ts +159 -0
- package/src/hooks/use-async.ts +54 -0
- package/src/hooks/use-capability-meta.ts +99 -0
- package/src/hooks/use-count-up.ts +23 -0
- package/src/hooks/use-debounce.ts +12 -0
- package/src/hooks/use-fleet-sse.ts +47 -0
- package/src/hooks/use-has-org.ts +18 -0
- package/src/hooks/use-image-status.ts +36 -0
- package/src/hooks/use-local-storage.ts +36 -0
- package/src/hooks/use-mobile.ts +17 -0
- package/src/hooks/use-page-context.ts +24 -0
- package/src/hooks/use-pagination-params.ts +30 -0
- package/src/hooks/use-plugin-registry.ts +247 -0
- package/src/hooks/use-plugin-setup-chat.ts +211 -0
- package/src/hooks/use-save-queue.ts +54 -0
- package/src/hooks/use-webmcp.ts +40 -0
- package/src/lib/__tests__/__snapshots__/pricing-data.test.ts.snap +112 -0
- package/src/lib/__tests__/admin-api.test.ts +487 -0
- package/src/lib/__tests__/api-bot-crud.test.ts +391 -0
- package/src/lib/__tests__/api-fetch.test.ts +196 -0
- package/src/lib/__tests__/bot-settings-data.test.ts +352 -0
- package/src/lib/__tests__/org-api.test.ts +281 -0
- package/src/lib/__tests__/org-billing-api.test.ts +242 -0
- package/src/lib/__tests__/pricing-data.test.ts +32 -0
- package/src/lib/__tests__/settings-api.test.ts +272 -0
- package/src/lib/admin-affiliate-api.ts +51 -0
- package/src/lib/admin-api.ts +325 -0
- package/src/lib/admin-compliance-api.ts +127 -0
- package/src/lib/admin-gpu-api.ts +82 -0
- package/src/lib/admin-incident-api.ts +121 -0
- package/src/lib/admin-inference-api.ts +47 -0
- package/src/lib/admin-marketplace-api.ts +97 -0
- package/src/lib/api-config.test.ts +111 -0
- package/src/lib/api-config.ts +65 -0
- package/src/lib/api-errors.test.ts +43 -0
- package/src/lib/api.ts +2011 -0
- package/src/lib/auth-client.ts +11 -0
- package/src/lib/bot-settings-data.ts +342 -0
- package/src/lib/brand-config.ts +145 -0
- package/src/lib/brand.ts +669 -0
- package/src/lib/changeset-api.ts +29 -0
- package/src/lib/changeset-types.ts +56 -0
- package/src/lib/channel-manifests.ts +50 -0
- package/src/lib/chat/chat-context.tsx +70 -0
- package/src/lib/chat/chat-store.ts +62 -0
- package/src/lib/chat/types.ts +35 -0
- package/src/lib/chat/use-chat.ts +255 -0
- package/src/lib/cost-comparison-data.test.ts +95 -0
- package/src/lib/cost-comparison-data.ts +54 -0
- package/src/lib/errors.test.ts +64 -0
- package/src/lib/errors.ts +52 -0
- package/src/lib/fetch-utils.test.ts +57 -0
- package/src/lib/fetch-utils.ts +25 -0
- package/src/lib/format-credit.test.ts +66 -0
- package/src/lib/format-credit.ts +24 -0
- package/src/lib/format.test.ts +62 -0
- package/src/lib/format.ts +17 -0
- package/src/lib/logger.ts +28 -0
- package/src/lib/marketplace-data.ts +346 -0
- package/src/lib/oauth-errors.ts +19 -0
- package/src/lib/onboarding-data.ts +1265 -0
- package/src/lib/onboarding-store.ts +233 -0
- package/src/lib/org-api.ts +74 -0
- package/src/lib/org-billing-api.ts +81 -0
- package/src/lib/page-prompts.test.ts +32 -0
- package/src/lib/page-prompts.ts +23 -0
- package/src/lib/plugin/index.ts +32 -0
- package/src/lib/plugin/tool-definitions.ts +306 -0
- package/src/lib/pricing-data.ts +115 -0
- package/src/lib/promotions-types.ts +58 -0
- package/src/lib/settings-api.ts +63 -0
- package/src/lib/status-colors.ts +38 -0
- package/src/lib/tenant-context.tsx +134 -0
- package/src/lib/trpc-types.ts +173 -0
- package/src/lib/trpc.tsx +86 -0
- package/src/lib/utils.test.ts +55 -0
- package/src/lib/utils.ts +18 -0
- package/src/lib/validate-redirect-url.ts +39 -0
- package/src/lib/webmcp/feature-detect.ts +13 -0
- package/src/lib/webmcp/marketplace-onboarding-tools.ts +202 -0
- package/src/lib/webmcp/register.ts +44 -0
- package/src/lib/webmcp/tools.ts +422 -0
- package/src/proxy.ts +258 -0
- package/src/types/missing-deps.d.ts +160 -0
- package/src/types/motion-dom.d.ts +162 -0
- package/src/types/vitest-matchers.d.ts +40 -0
- package/src/types/web-mcp.d.ts +22 -0
- package/tsconfig.json +34 -0
- 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'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'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'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'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}–
|
|
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
|
+
}
|