@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,1341 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Download, FileText, Pencil, Search, Shield, Trash2 } from "lucide-react";
|
|
4
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
5
|
+
import { toast } from "sonner";
|
|
6
|
+
import { Button } from "@/components/ui/button";
|
|
7
|
+
import {
|
|
8
|
+
Dialog,
|
|
9
|
+
DialogContent,
|
|
10
|
+
DialogDescription,
|
|
11
|
+
DialogFooter,
|
|
12
|
+
DialogHeader,
|
|
13
|
+
DialogTitle,
|
|
14
|
+
} from "@/components/ui/dialog";
|
|
15
|
+
import { Input } from "@/components/ui/input";
|
|
16
|
+
import { Label } from "@/components/ui/label";
|
|
17
|
+
import {
|
|
18
|
+
Select,
|
|
19
|
+
SelectContent,
|
|
20
|
+
SelectItem,
|
|
21
|
+
SelectTrigger,
|
|
22
|
+
SelectValue,
|
|
23
|
+
} from "@/components/ui/select";
|
|
24
|
+
import { Skeleton } from "@/components/ui/skeleton";
|
|
25
|
+
import { Switch } from "@/components/ui/switch";
|
|
26
|
+
import {
|
|
27
|
+
Table,
|
|
28
|
+
TableBody,
|
|
29
|
+
TableCell,
|
|
30
|
+
TableHead,
|
|
31
|
+
TableHeader,
|
|
32
|
+
TableRow,
|
|
33
|
+
} from "@/components/ui/table";
|
|
34
|
+
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
35
|
+
import { Textarea } from "@/components/ui/textarea";
|
|
36
|
+
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
|
37
|
+
import type { DeletionRequest, ExportRequest, RetentionPolicy } from "@/lib/admin-compliance-api";
|
|
38
|
+
import {
|
|
39
|
+
cancelDeletion,
|
|
40
|
+
fetchRetentionPolicies,
|
|
41
|
+
getDeletionRequests,
|
|
42
|
+
getExportRequests,
|
|
43
|
+
triggerDeletion,
|
|
44
|
+
triggerExport,
|
|
45
|
+
updateRetentionPolicy,
|
|
46
|
+
} from "@/lib/admin-compliance-api";
|
|
47
|
+
import type { AuditLogResponse } from "@/lib/api";
|
|
48
|
+
import { fetchAuditLog } from "@/lib/api";
|
|
49
|
+
import { toUserMessage } from "@/lib/errors";
|
|
50
|
+
import { cn } from "@/lib/utils";
|
|
51
|
+
|
|
52
|
+
const PAGE_SIZE = 50;
|
|
53
|
+
|
|
54
|
+
const STATUS_FILTERS = [
|
|
55
|
+
{ label: "All Statuses", value: "all" },
|
|
56
|
+
{ label: "Pending", value: "pending" },
|
|
57
|
+
{ label: "Completed", value: "completed" },
|
|
58
|
+
{ label: "Cancelled", value: "cancelled" },
|
|
59
|
+
] as const;
|
|
60
|
+
|
|
61
|
+
const AUDIT_ACTION_FILTERS = [
|
|
62
|
+
{ label: "All Actions", value: "all" },
|
|
63
|
+
{ label: "Deletion", value: "compliance.trigger_deletion" },
|
|
64
|
+
{ label: "Export", value: "compliance.trigger_export" },
|
|
65
|
+
{ label: "Policy Change", value: "compliance.policy_update" },
|
|
66
|
+
] as const;
|
|
67
|
+
|
|
68
|
+
const DATE_RANGES = [
|
|
69
|
+
{ label: "Last 24 hours", value: "1" },
|
|
70
|
+
{ label: "Last 7 days", value: "7" },
|
|
71
|
+
{ label: "Last 30 days", value: "30" },
|
|
72
|
+
{ label: "Last 90 days", value: "90" },
|
|
73
|
+
{ label: "All time", value: "all" },
|
|
74
|
+
] as const;
|
|
75
|
+
|
|
76
|
+
// --- Utilities ---
|
|
77
|
+
|
|
78
|
+
function relativeTime(iso: string): string {
|
|
79
|
+
const rawDiff = Date.now() - new Date(iso).getTime();
|
|
80
|
+
const isFuture = rawDiff < 0;
|
|
81
|
+
const diff = Math.abs(rawDiff);
|
|
82
|
+
const seconds = Math.floor(diff / 1000);
|
|
83
|
+
if (seconds < 60) return "just now";
|
|
84
|
+
const minutes = Math.floor(seconds / 60);
|
|
85
|
+
if (minutes < 60) return isFuture ? `in ${minutes}m` : `${minutes}m ago`;
|
|
86
|
+
const hours = Math.floor(minutes / 60);
|
|
87
|
+
if (hours < 24) return isFuture ? `in ${hours}h` : `${hours}h ago`;
|
|
88
|
+
const days = Math.floor(hours / 24);
|
|
89
|
+
return isFuture ? `in ${days}d` : `${days}d ago`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function isSafeUrl(url: string): boolean {
|
|
93
|
+
return url.startsWith("https://") || (url.startsWith("/") && !url.startsWith("//"));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function statusBadgeClasses(status: string): string {
|
|
97
|
+
if (status === "pending") return "bg-amber-500/15 text-amber-400 border border-amber-500/20";
|
|
98
|
+
if (status === "completed") return "bg-terminal/15 text-terminal border border-terminal/20";
|
|
99
|
+
if (status === "in_progress") return "bg-blue-500/15 text-blue-400 border border-blue-500/20";
|
|
100
|
+
if (status === "failed") return "bg-destructive/15 text-red-400 border border-destructive/20";
|
|
101
|
+
return "bg-secondary text-muted-foreground border border-border";
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function complianceActionBadgeClasses(action: string): string {
|
|
105
|
+
if (action.includes("trigger_deletion") || action.includes("complete_deletion"))
|
|
106
|
+
return "bg-destructive/15 text-red-400 border border-destructive/20";
|
|
107
|
+
if (action.includes("trigger_export") || action.includes("complete_export"))
|
|
108
|
+
return "bg-terminal/15 text-terminal border border-terminal/20";
|
|
109
|
+
if (action.includes("policy_update"))
|
|
110
|
+
return "bg-amber-500/15 text-amber-400 border border-amber-500/20";
|
|
111
|
+
return "bg-secondary text-muted-foreground border border-border";
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function humanAction(action: string): string {
|
|
115
|
+
return action.replace(/[._-]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// --- Skeleton rows ---
|
|
119
|
+
|
|
120
|
+
const SKEL_ROWS = ["s1", "s2", "s3", "s4", "s5", "s6", "s7", "s8"] as const;
|
|
121
|
+
const SKEL_COLS_4 = ["c1", "c2", "c3", "c4"] as const;
|
|
122
|
+
const SKEL_COLS_6 = ["c1", "c2", "c3", "c4", "c5", "c6"] as const;
|
|
123
|
+
|
|
124
|
+
function SkeletonRows({ cols, rows = 6 }: { cols: number; rows?: number }) {
|
|
125
|
+
const colKeys = cols <= 4 ? SKEL_COLS_4 : SKEL_COLS_6;
|
|
126
|
+
const rowKeys = SKEL_ROWS.slice(0, rows);
|
|
127
|
+
return (
|
|
128
|
+
<>
|
|
129
|
+
{rowKeys.map((rowKey, i) => (
|
|
130
|
+
<TableRow key={rowKey} className="h-10">
|
|
131
|
+
{colKeys.slice(0, cols).map((colKey) => (
|
|
132
|
+
<TableCell key={`${rowKey}-${colKey}`}>
|
|
133
|
+
<Skeleton className="h-4 w-full" style={{ animationDelay: `${i * 50}ms` }} />
|
|
134
|
+
</TableCell>
|
|
135
|
+
))}
|
|
136
|
+
</TableRow>
|
|
137
|
+
))}
|
|
138
|
+
</>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function EmptyState({ message }: { message: string }) {
|
|
143
|
+
return (
|
|
144
|
+
<TableRow>
|
|
145
|
+
<TableCell colSpan={99} className="h-32 text-center">
|
|
146
|
+
<p className="text-sm text-muted-foreground font-mono">
|
|
147
|
+
> {message}
|
|
148
|
+
<span className="animate-ellipsis" />
|
|
149
|
+
</p>
|
|
150
|
+
</TableCell>
|
|
151
|
+
</TableRow>
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// --- Trigger Dialog ---
|
|
156
|
+
|
|
157
|
+
function TriggerDialog({
|
|
158
|
+
open,
|
|
159
|
+
onOpenChange,
|
|
160
|
+
title,
|
|
161
|
+
description,
|
|
162
|
+
confirmLabel,
|
|
163
|
+
confirmClassName,
|
|
164
|
+
onConfirm,
|
|
165
|
+
}: {
|
|
166
|
+
open: boolean;
|
|
167
|
+
onOpenChange: (open: boolean) => void;
|
|
168
|
+
title: string;
|
|
169
|
+
description: string;
|
|
170
|
+
confirmLabel: string;
|
|
171
|
+
confirmClassName: string;
|
|
172
|
+
onConfirm: (tenantId: string, reason: string) => Promise<void>;
|
|
173
|
+
}) {
|
|
174
|
+
const [tenantId, setTenantId] = useState("");
|
|
175
|
+
const [reason, setReason] = useState("");
|
|
176
|
+
const [submitting, setSubmitting] = useState(false);
|
|
177
|
+
|
|
178
|
+
const handleOpenChange = (open: boolean) => {
|
|
179
|
+
if (!open) {
|
|
180
|
+
setTenantId("");
|
|
181
|
+
setReason("");
|
|
182
|
+
}
|
|
183
|
+
onOpenChange(open);
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const handleConfirm = async () => {
|
|
187
|
+
if (!tenantId.trim() || !reason.trim()) return;
|
|
188
|
+
setSubmitting(true);
|
|
189
|
+
try {
|
|
190
|
+
await onConfirm(tenantId.trim(), reason.trim());
|
|
191
|
+
onOpenChange(false);
|
|
192
|
+
setTenantId("");
|
|
193
|
+
setReason("");
|
|
194
|
+
} catch (err) {
|
|
195
|
+
toast.error(toUserMessage(err));
|
|
196
|
+
} finally {
|
|
197
|
+
setSubmitting(false);
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
return (
|
|
202
|
+
<Dialog open={open} onOpenChange={handleOpenChange}>
|
|
203
|
+
<DialogContent>
|
|
204
|
+
<DialogHeader>
|
|
205
|
+
<DialogTitle className="text-base font-semibold uppercase tracking-wider">
|
|
206
|
+
{title}
|
|
207
|
+
</DialogTitle>
|
|
208
|
+
<DialogDescription>{description}</DialogDescription>
|
|
209
|
+
</DialogHeader>
|
|
210
|
+
<div className="space-y-3">
|
|
211
|
+
<Input
|
|
212
|
+
placeholder="Tenant ID"
|
|
213
|
+
value={tenantId}
|
|
214
|
+
onChange={(e) => setTenantId(e.target.value)}
|
|
215
|
+
className="focus:border-terminal focus:ring-terminal/20"
|
|
216
|
+
/>
|
|
217
|
+
<Textarea
|
|
218
|
+
placeholder="Reason (required)"
|
|
219
|
+
value={reason}
|
|
220
|
+
onChange={(e) => setReason(e.target.value)}
|
|
221
|
+
className="min-h-[80px] focus:border-terminal focus:ring-terminal/20"
|
|
222
|
+
/>
|
|
223
|
+
</div>
|
|
224
|
+
<DialogFooter>
|
|
225
|
+
<Button variant="ghost" onClick={() => handleOpenChange(false)} disabled={submitting}>
|
|
226
|
+
Cancel
|
|
227
|
+
</Button>
|
|
228
|
+
<Button
|
|
229
|
+
variant="outline"
|
|
230
|
+
className={confirmClassName}
|
|
231
|
+
onClick={handleConfirm}
|
|
232
|
+
disabled={!tenantId.trim() || !reason.trim() || submitting}
|
|
233
|
+
>
|
|
234
|
+
{submitting ? "Processing..." : confirmLabel}
|
|
235
|
+
</Button>
|
|
236
|
+
</DialogFooter>
|
|
237
|
+
</DialogContent>
|
|
238
|
+
</Dialog>
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// --- Pagination ---
|
|
243
|
+
|
|
244
|
+
function Pagination({
|
|
245
|
+
offset,
|
|
246
|
+
total,
|
|
247
|
+
hasMore,
|
|
248
|
+
onNavigate,
|
|
249
|
+
}: {
|
|
250
|
+
offset: number;
|
|
251
|
+
total: number;
|
|
252
|
+
hasMore: boolean;
|
|
253
|
+
onNavigate: (offset: number) => void;
|
|
254
|
+
}) {
|
|
255
|
+
if (total <= PAGE_SIZE) return null;
|
|
256
|
+
return (
|
|
257
|
+
<div className="flex items-center justify-between text-xs text-muted-foreground font-mono">
|
|
258
|
+
<span>
|
|
259
|
+
Showing {offset + 1}-{Math.min(offset + PAGE_SIZE, total)} of {total}
|
|
260
|
+
</span>
|
|
261
|
+
<div className="flex gap-2">
|
|
262
|
+
<Button variant="ghost" size="sm" disabled={offset === 0} onClick={() => onNavigate(0)}>
|
|
263
|
+
First
|
|
264
|
+
</Button>
|
|
265
|
+
<Button
|
|
266
|
+
variant="ghost"
|
|
267
|
+
size="sm"
|
|
268
|
+
disabled={offset === 0}
|
|
269
|
+
onClick={() => onNavigate(Math.max(0, offset - PAGE_SIZE))}
|
|
270
|
+
>
|
|
271
|
+
Previous
|
|
272
|
+
</Button>
|
|
273
|
+
<Button
|
|
274
|
+
variant="ghost"
|
|
275
|
+
size="sm"
|
|
276
|
+
disabled={!hasMore}
|
|
277
|
+
onClick={() => onNavigate(offset + PAGE_SIZE)}
|
|
278
|
+
>
|
|
279
|
+
Next
|
|
280
|
+
</Button>
|
|
281
|
+
<Button
|
|
282
|
+
variant="ghost"
|
|
283
|
+
size="sm"
|
|
284
|
+
disabled={!hasMore}
|
|
285
|
+
onClick={() => {
|
|
286
|
+
const lastPageOffset = Math.floor((total - 1) / PAGE_SIZE) * PAGE_SIZE;
|
|
287
|
+
onNavigate(lastPageOffset);
|
|
288
|
+
}}
|
|
289
|
+
>
|
|
290
|
+
Last
|
|
291
|
+
</Button>
|
|
292
|
+
</div>
|
|
293
|
+
</div>
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ============================================================
|
|
298
|
+
// TAB 1: Deletion Requests
|
|
299
|
+
// ============================================================
|
|
300
|
+
|
|
301
|
+
function DeletionRequestsTab() {
|
|
302
|
+
const [data, setData] = useState<{
|
|
303
|
+
requests: DeletionRequest[];
|
|
304
|
+
total: number;
|
|
305
|
+
} | null>(null);
|
|
306
|
+
const [loading, setLoading] = useState(true);
|
|
307
|
+
const [loadError, setLoadError] = useState<string | null>(null);
|
|
308
|
+
const [offset, setOffset] = useState(0);
|
|
309
|
+
const [statusFilter, setStatusFilter] = useState("all");
|
|
310
|
+
const [search, setSearch] = useState("");
|
|
311
|
+
const [dialogOpen, setDialogOpen] = useState(false);
|
|
312
|
+
|
|
313
|
+
const load = useCallback(
|
|
314
|
+
async (newOffset: number, signal?: AbortSignal) => {
|
|
315
|
+
setLoading(true);
|
|
316
|
+
setLoadError(null);
|
|
317
|
+
try {
|
|
318
|
+
const result = await getDeletionRequests({
|
|
319
|
+
status: statusFilter === "all" ? undefined : statusFilter,
|
|
320
|
+
limit: PAGE_SIZE,
|
|
321
|
+
offset: newOffset,
|
|
322
|
+
});
|
|
323
|
+
if (signal?.aborted) return;
|
|
324
|
+
setData(result);
|
|
325
|
+
setOffset(newOffset);
|
|
326
|
+
} catch (err) {
|
|
327
|
+
if (signal?.aborted) return;
|
|
328
|
+
const message = err instanceof Error ? err.message : "Failed to load";
|
|
329
|
+
if (message.includes("NOT_IMPLEMENTED") || message.includes("not found")) {
|
|
330
|
+
setLoadError("backend_missing");
|
|
331
|
+
} else {
|
|
332
|
+
setLoadError(message);
|
|
333
|
+
}
|
|
334
|
+
} finally {
|
|
335
|
+
if (!signal?.aborted) setLoading(false);
|
|
336
|
+
}
|
|
337
|
+
},
|
|
338
|
+
[statusFilter],
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
useEffect(() => {
|
|
342
|
+
const controller = new AbortController();
|
|
343
|
+
load(0, controller.signal);
|
|
344
|
+
return () => controller.abort();
|
|
345
|
+
}, [load]);
|
|
346
|
+
|
|
347
|
+
const filteredRequests = useMemo(() => {
|
|
348
|
+
if (!data) return [];
|
|
349
|
+
if (!search.trim()) return data.requests;
|
|
350
|
+
const q = search.toLowerCase();
|
|
351
|
+
return data.requests.filter(
|
|
352
|
+
(r) =>
|
|
353
|
+
r.tenantId.toLowerCase().includes(q) ||
|
|
354
|
+
r.requestedBy.toLowerCase().includes(q) ||
|
|
355
|
+
r.status.toLowerCase().includes(q),
|
|
356
|
+
);
|
|
357
|
+
}, [data, search]);
|
|
358
|
+
|
|
359
|
+
const handleTriggerDeletion = async (tenantId: string, reason: string) => {
|
|
360
|
+
await triggerDeletion(tenantId, reason);
|
|
361
|
+
toast.success("Deletion request created");
|
|
362
|
+
load(offset);
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
const handleCancelDeletion = async (requestId: string) => {
|
|
366
|
+
try {
|
|
367
|
+
await cancelDeletion(requestId);
|
|
368
|
+
toast.success("Deletion request cancelled");
|
|
369
|
+
load(offset);
|
|
370
|
+
} catch (err) {
|
|
371
|
+
toast.error(toUserMessage(err));
|
|
372
|
+
}
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
// Backend endpoints not yet available
|
|
376
|
+
if (loadError === "backend_missing") {
|
|
377
|
+
return (
|
|
378
|
+
<div className="space-y-3">
|
|
379
|
+
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
380
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
381
|
+
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
|
382
|
+
<SelectTrigger className="w-[150px]">
|
|
383
|
+
<SelectValue />
|
|
384
|
+
</SelectTrigger>
|
|
385
|
+
<SelectContent>
|
|
386
|
+
{STATUS_FILTERS.map((f) => (
|
|
387
|
+
<SelectItem key={f.value} value={f.value}>
|
|
388
|
+
{f.label}
|
|
389
|
+
</SelectItem>
|
|
390
|
+
))}
|
|
391
|
+
</SelectContent>
|
|
392
|
+
</Select>
|
|
393
|
+
<div className="relative max-w-sm">
|
|
394
|
+
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
|
395
|
+
<Input
|
|
396
|
+
placeholder="Search requests..."
|
|
397
|
+
value={search}
|
|
398
|
+
onChange={(e) => setSearch(e.target.value)}
|
|
399
|
+
className="pl-9 focus:border-terminal focus:ring-terminal/20"
|
|
400
|
+
disabled
|
|
401
|
+
/>
|
|
402
|
+
</div>
|
|
403
|
+
</div>
|
|
404
|
+
<Button
|
|
405
|
+
variant="outline"
|
|
406
|
+
className="border-terminal/30 text-terminal hover:bg-terminal/10"
|
|
407
|
+
onClick={() => setDialogOpen(true)}
|
|
408
|
+
disabled
|
|
409
|
+
>
|
|
410
|
+
<Trash2 className="mr-1.5 size-3.5" />
|
|
411
|
+
Trigger Deletion
|
|
412
|
+
</Button>
|
|
413
|
+
</div>
|
|
414
|
+
<div className="rounded-sm border border-terminal/10 p-8 text-center">
|
|
415
|
+
<p className="text-sm text-muted-foreground font-mono">
|
|
416
|
+
> Backend endpoints for listing deletion requests are pending deployment
|
|
417
|
+
<span className="animate-ellipsis" />
|
|
418
|
+
</p>
|
|
419
|
+
<p className="mt-2 text-xs text-muted-foreground">
|
|
420
|
+
The admin.complianceDeletionRequests procedure is not yet available. This section will
|
|
421
|
+
activate automatically once deployed.
|
|
422
|
+
</p>
|
|
423
|
+
</div>
|
|
424
|
+
<TriggerDialog
|
|
425
|
+
open={dialogOpen}
|
|
426
|
+
onOpenChange={setDialogOpen}
|
|
427
|
+
title="Trigger Data Deletion"
|
|
428
|
+
description="Schedule all personal data for deletion for a specific tenant. This action is audited."
|
|
429
|
+
confirmLabel="Confirm Deletion"
|
|
430
|
+
confirmClassName="border-destructive/30 text-red-400 hover:bg-destructive/10"
|
|
431
|
+
onConfirm={handleTriggerDeletion}
|
|
432
|
+
/>
|
|
433
|
+
</div>
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
if (loadError) {
|
|
438
|
+
return (
|
|
439
|
+
<div className="flex h-40 flex-col items-center justify-center gap-3">
|
|
440
|
+
<p className="text-sm text-destructive font-mono">Failed to load deletion requests.</p>
|
|
441
|
+
<Button variant="outline" size="sm" onClick={() => load(offset)}>
|
|
442
|
+
Retry
|
|
443
|
+
</Button>
|
|
444
|
+
</div>
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
return (
|
|
449
|
+
<div className="space-y-3">
|
|
450
|
+
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
451
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
452
|
+
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
|
453
|
+
<SelectTrigger className="w-[150px]">
|
|
454
|
+
<SelectValue />
|
|
455
|
+
</SelectTrigger>
|
|
456
|
+
<SelectContent>
|
|
457
|
+
{STATUS_FILTERS.map((f) => (
|
|
458
|
+
<SelectItem key={f.value} value={f.value}>
|
|
459
|
+
{f.label}
|
|
460
|
+
</SelectItem>
|
|
461
|
+
))}
|
|
462
|
+
</SelectContent>
|
|
463
|
+
</Select>
|
|
464
|
+
<div className="relative max-w-sm">
|
|
465
|
+
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
|
466
|
+
<Input
|
|
467
|
+
placeholder="Search requests..."
|
|
468
|
+
value={search}
|
|
469
|
+
onChange={(e) => setSearch(e.target.value)}
|
|
470
|
+
className="pl-9 focus:border-terminal focus:ring-terminal/20"
|
|
471
|
+
/>
|
|
472
|
+
</div>
|
|
473
|
+
</div>
|
|
474
|
+
<Button
|
|
475
|
+
variant="outline"
|
|
476
|
+
className="border-terminal/30 text-terminal hover:bg-terminal/10"
|
|
477
|
+
onClick={() => setDialogOpen(true)}
|
|
478
|
+
>
|
|
479
|
+
<Trash2 className="mr-1.5 size-3.5" />
|
|
480
|
+
Trigger Deletion
|
|
481
|
+
</Button>
|
|
482
|
+
</div>
|
|
483
|
+
|
|
484
|
+
<div className="rounded-sm border border-terminal/10 overflow-x-auto">
|
|
485
|
+
<Table>
|
|
486
|
+
<TableHeader>
|
|
487
|
+
<TableRow className="bg-secondary crt-scanlines">
|
|
488
|
+
<TableHead className="text-xs font-medium uppercase tracking-wider w-[140px]">
|
|
489
|
+
Tenant ID
|
|
490
|
+
</TableHead>
|
|
491
|
+
<TableHead className="text-xs font-medium uppercase tracking-wider">
|
|
492
|
+
Requested By
|
|
493
|
+
</TableHead>
|
|
494
|
+
<TableHead className="text-xs font-medium uppercase tracking-wider">Status</TableHead>
|
|
495
|
+
<TableHead className="text-xs font-medium uppercase tracking-wider">
|
|
496
|
+
Delete After
|
|
497
|
+
</TableHead>
|
|
498
|
+
<TableHead className="text-xs font-medium uppercase tracking-wider">
|
|
499
|
+
Created
|
|
500
|
+
</TableHead>
|
|
501
|
+
<TableHead className="text-xs font-medium uppercase tracking-wider w-[80px]">
|
|
502
|
+
Actions
|
|
503
|
+
</TableHead>
|
|
504
|
+
</TableRow>
|
|
505
|
+
</TableHeader>
|
|
506
|
+
<TableBody
|
|
507
|
+
className={cn("transition-opacity duration-150", loading && data && "opacity-60")}
|
|
508
|
+
>
|
|
509
|
+
{loading && !data ? (
|
|
510
|
+
<SkeletonRows cols={6} />
|
|
511
|
+
) : filteredRequests.length === 0 && !loading ? (
|
|
512
|
+
<EmptyState message="No deletion requests found" />
|
|
513
|
+
) : (
|
|
514
|
+
filteredRequests.map((req) => (
|
|
515
|
+
<TableRow key={req.id} className="h-10 hover:bg-secondary/50">
|
|
516
|
+
<TableCell className="font-mono text-xs w-[140px]">{req.tenantId}</TableCell>
|
|
517
|
+
<TableCell className="font-mono text-xs">{req.requestedBy}</TableCell>
|
|
518
|
+
<TableCell>
|
|
519
|
+
<span
|
|
520
|
+
className={cn(
|
|
521
|
+
"inline-flex items-center rounded-sm px-2 py-0.5 text-xs font-medium",
|
|
522
|
+
statusBadgeClasses(req.status),
|
|
523
|
+
)}
|
|
524
|
+
>
|
|
525
|
+
{req.status}
|
|
526
|
+
</span>
|
|
527
|
+
</TableCell>
|
|
528
|
+
<TableCell>
|
|
529
|
+
<Tooltip>
|
|
530
|
+
<TooltipTrigger className="text-xs text-muted-foreground font-mono">
|
|
531
|
+
{relativeTime(req.deleteAfter)}
|
|
532
|
+
</TooltipTrigger>
|
|
533
|
+
<TooltipContent>{new Date(req.deleteAfter).toLocaleString()}</TooltipContent>
|
|
534
|
+
</Tooltip>
|
|
535
|
+
</TableCell>
|
|
536
|
+
<TableCell>
|
|
537
|
+
<Tooltip>
|
|
538
|
+
<TooltipTrigger className="text-xs text-muted-foreground font-mono">
|
|
539
|
+
{relativeTime(req.createdAt)}
|
|
540
|
+
</TooltipTrigger>
|
|
541
|
+
<TooltipContent>{new Date(req.createdAt).toLocaleString()}</TooltipContent>
|
|
542
|
+
</Tooltip>
|
|
543
|
+
</TableCell>
|
|
544
|
+
<TableCell>
|
|
545
|
+
{req.status === "pending" ? (
|
|
546
|
+
<Button
|
|
547
|
+
variant="ghost"
|
|
548
|
+
size="sm"
|
|
549
|
+
className="text-xs"
|
|
550
|
+
onClick={() => handleCancelDeletion(req.id)}
|
|
551
|
+
>
|
|
552
|
+
Cancel
|
|
553
|
+
</Button>
|
|
554
|
+
) : (
|
|
555
|
+
<span className="text-muted-foreground">{"\u2014"}</span>
|
|
556
|
+
)}
|
|
557
|
+
</TableCell>
|
|
558
|
+
</TableRow>
|
|
559
|
+
))
|
|
560
|
+
)}
|
|
561
|
+
</TableBody>
|
|
562
|
+
</Table>
|
|
563
|
+
</div>
|
|
564
|
+
|
|
565
|
+
{data && (
|
|
566
|
+
<Pagination
|
|
567
|
+
offset={offset}
|
|
568
|
+
total={data.total}
|
|
569
|
+
hasMore={offset + PAGE_SIZE < data.total}
|
|
570
|
+
onNavigate={(o) => load(o)}
|
|
571
|
+
/>
|
|
572
|
+
)}
|
|
573
|
+
|
|
574
|
+
<TriggerDialog
|
|
575
|
+
open={dialogOpen}
|
|
576
|
+
onOpenChange={setDialogOpen}
|
|
577
|
+
title="Trigger Data Deletion"
|
|
578
|
+
description="Schedule all personal data for deletion for a specific tenant. This action is audited."
|
|
579
|
+
confirmLabel="Confirm Deletion"
|
|
580
|
+
confirmClassName="border-destructive/30 text-red-400 hover:bg-destructive/10"
|
|
581
|
+
onConfirm={handleTriggerDeletion}
|
|
582
|
+
/>
|
|
583
|
+
</div>
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// ============================================================
|
|
588
|
+
// TAB 2: Data Exports
|
|
589
|
+
// ============================================================
|
|
590
|
+
|
|
591
|
+
function DataExportsTab() {
|
|
592
|
+
const [data, setData] = useState<{
|
|
593
|
+
requests: ExportRequest[];
|
|
594
|
+
total: number;
|
|
595
|
+
} | null>(null);
|
|
596
|
+
const [loading, setLoading] = useState(true);
|
|
597
|
+
const [loadError, setLoadError] = useState<string | null>(null);
|
|
598
|
+
const [offset, setOffset] = useState(0);
|
|
599
|
+
const [statusFilter, setStatusFilter] = useState("all");
|
|
600
|
+
const [search, setSearch] = useState("");
|
|
601
|
+
const [dialogOpen, setDialogOpen] = useState(false);
|
|
602
|
+
|
|
603
|
+
const load = useCallback(
|
|
604
|
+
async (newOffset: number, signal?: AbortSignal) => {
|
|
605
|
+
setLoading(true);
|
|
606
|
+
setLoadError(null);
|
|
607
|
+
try {
|
|
608
|
+
const result = await getExportRequests({
|
|
609
|
+
status: statusFilter === "all" ? undefined : statusFilter,
|
|
610
|
+
limit: PAGE_SIZE,
|
|
611
|
+
offset: newOffset,
|
|
612
|
+
});
|
|
613
|
+
if (signal?.aborted) return;
|
|
614
|
+
setData(result);
|
|
615
|
+
setOffset(newOffset);
|
|
616
|
+
} catch (err) {
|
|
617
|
+
if (signal?.aborted) return;
|
|
618
|
+
const message = err instanceof Error ? err.message : "Failed to load";
|
|
619
|
+
if (message.includes("NOT_IMPLEMENTED") || message.includes("not found")) {
|
|
620
|
+
setLoadError("backend_missing");
|
|
621
|
+
} else {
|
|
622
|
+
setLoadError(message);
|
|
623
|
+
}
|
|
624
|
+
} finally {
|
|
625
|
+
if (!signal?.aborted) setLoading(false);
|
|
626
|
+
}
|
|
627
|
+
},
|
|
628
|
+
[statusFilter],
|
|
629
|
+
);
|
|
630
|
+
|
|
631
|
+
useEffect(() => {
|
|
632
|
+
const controller = new AbortController();
|
|
633
|
+
load(0, controller.signal);
|
|
634
|
+
return () => controller.abort();
|
|
635
|
+
}, [load]);
|
|
636
|
+
|
|
637
|
+
const filteredRequests = useMemo(() => {
|
|
638
|
+
if (!data) return [];
|
|
639
|
+
if (!search.trim()) return data.requests;
|
|
640
|
+
const q = search.toLowerCase();
|
|
641
|
+
return data.requests.filter(
|
|
642
|
+
(r) =>
|
|
643
|
+
r.tenantId.toLowerCase().includes(q) ||
|
|
644
|
+
r.requestedBy.toLowerCase().includes(q) ||
|
|
645
|
+
r.status.toLowerCase().includes(q),
|
|
646
|
+
);
|
|
647
|
+
}, [data, search]);
|
|
648
|
+
|
|
649
|
+
const handleTriggerExport = async (tenantId: string, reason: string) => {
|
|
650
|
+
await triggerExport(tenantId, reason);
|
|
651
|
+
toast.success("Export request created");
|
|
652
|
+
load(offset);
|
|
653
|
+
};
|
|
654
|
+
|
|
655
|
+
// Backend endpoints not yet available
|
|
656
|
+
if (loadError === "backend_missing") {
|
|
657
|
+
return (
|
|
658
|
+
<div className="space-y-3">
|
|
659
|
+
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
660
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
661
|
+
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
|
662
|
+
<SelectTrigger className="w-[150px]">
|
|
663
|
+
<SelectValue />
|
|
664
|
+
</SelectTrigger>
|
|
665
|
+
<SelectContent>
|
|
666
|
+
{STATUS_FILTERS.map((f) => (
|
|
667
|
+
<SelectItem key={f.value} value={f.value}>
|
|
668
|
+
{f.label}
|
|
669
|
+
</SelectItem>
|
|
670
|
+
))}
|
|
671
|
+
</SelectContent>
|
|
672
|
+
</Select>
|
|
673
|
+
<div className="relative max-w-sm">
|
|
674
|
+
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
|
675
|
+
<Input
|
|
676
|
+
placeholder="Search exports..."
|
|
677
|
+
value={search}
|
|
678
|
+
onChange={(e) => setSearch(e.target.value)}
|
|
679
|
+
className="pl-9 focus:border-terminal focus:ring-terminal/20"
|
|
680
|
+
disabled
|
|
681
|
+
/>
|
|
682
|
+
</div>
|
|
683
|
+
</div>
|
|
684
|
+
<Button
|
|
685
|
+
variant="outline"
|
|
686
|
+
className="border-terminal/30 text-terminal hover:bg-terminal/10"
|
|
687
|
+
onClick={() => setDialogOpen(true)}
|
|
688
|
+
disabled
|
|
689
|
+
>
|
|
690
|
+
<Download className="mr-1.5 size-3.5" />
|
|
691
|
+
Trigger Export
|
|
692
|
+
</Button>
|
|
693
|
+
</div>
|
|
694
|
+
<div className="rounded-sm border border-terminal/10 p-8 text-center">
|
|
695
|
+
<p className="text-sm text-muted-foreground font-mono">
|
|
696
|
+
> Backend endpoints for listing export requests are pending deployment
|
|
697
|
+
<span className="animate-ellipsis" />
|
|
698
|
+
</p>
|
|
699
|
+
<p className="mt-2 text-xs text-muted-foreground">
|
|
700
|
+
The admin.complianceExportRequests procedure is not yet available. This section will
|
|
701
|
+
activate automatically once deployed.
|
|
702
|
+
</p>
|
|
703
|
+
</div>
|
|
704
|
+
<TriggerDialog
|
|
705
|
+
open={dialogOpen}
|
|
706
|
+
onOpenChange={setDialogOpen}
|
|
707
|
+
title="Trigger Data Export"
|
|
708
|
+
description="Generate a GDPR Article 15 data export for a specific tenant. This action is audited."
|
|
709
|
+
confirmLabel="Confirm Export"
|
|
710
|
+
confirmClassName="border-terminal/30 text-terminal hover:bg-terminal/10"
|
|
711
|
+
onConfirm={handleTriggerExport}
|
|
712
|
+
/>
|
|
713
|
+
</div>
|
|
714
|
+
);
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
if (loadError) {
|
|
718
|
+
return (
|
|
719
|
+
<div className="flex h-40 flex-col items-center justify-center gap-3">
|
|
720
|
+
<p className="text-sm text-destructive font-mono">Failed to load export requests.</p>
|
|
721
|
+
<Button variant="outline" size="sm" onClick={() => load(offset)}>
|
|
722
|
+
Retry
|
|
723
|
+
</Button>
|
|
724
|
+
</div>
|
|
725
|
+
);
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
return (
|
|
729
|
+
<div className="space-y-3">
|
|
730
|
+
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
731
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
732
|
+
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
|
733
|
+
<SelectTrigger className="w-[150px]">
|
|
734
|
+
<SelectValue />
|
|
735
|
+
</SelectTrigger>
|
|
736
|
+
<SelectContent>
|
|
737
|
+
{STATUS_FILTERS.map((f) => (
|
|
738
|
+
<SelectItem key={f.value} value={f.value}>
|
|
739
|
+
{f.label}
|
|
740
|
+
</SelectItem>
|
|
741
|
+
))}
|
|
742
|
+
</SelectContent>
|
|
743
|
+
</Select>
|
|
744
|
+
<div className="relative max-w-sm">
|
|
745
|
+
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
|
746
|
+
<Input
|
|
747
|
+
placeholder="Search exports..."
|
|
748
|
+
value={search}
|
|
749
|
+
onChange={(e) => setSearch(e.target.value)}
|
|
750
|
+
className="pl-9 focus:border-terminal focus:ring-terminal/20"
|
|
751
|
+
/>
|
|
752
|
+
</div>
|
|
753
|
+
</div>
|
|
754
|
+
<Button
|
|
755
|
+
variant="outline"
|
|
756
|
+
className="border-terminal/30 text-terminal hover:bg-terminal/10"
|
|
757
|
+
onClick={() => setDialogOpen(true)}
|
|
758
|
+
>
|
|
759
|
+
<Download className="mr-1.5 size-3.5" />
|
|
760
|
+
Trigger Export
|
|
761
|
+
</Button>
|
|
762
|
+
</div>
|
|
763
|
+
|
|
764
|
+
<div className="rounded-sm border border-terminal/10 overflow-x-auto">
|
|
765
|
+
<Table>
|
|
766
|
+
<TableHeader>
|
|
767
|
+
<TableRow className="bg-secondary crt-scanlines">
|
|
768
|
+
<TableHead className="text-xs font-medium uppercase tracking-wider w-[140px]">
|
|
769
|
+
Tenant ID
|
|
770
|
+
</TableHead>
|
|
771
|
+
<TableHead className="text-xs font-medium uppercase tracking-wider">
|
|
772
|
+
Requested By
|
|
773
|
+
</TableHead>
|
|
774
|
+
<TableHead className="text-xs font-medium uppercase tracking-wider">Status</TableHead>
|
|
775
|
+
<TableHead className="text-xs font-medium uppercase tracking-wider">Format</TableHead>
|
|
776
|
+
<TableHead className="text-xs font-medium uppercase tracking-wider">
|
|
777
|
+
Created
|
|
778
|
+
</TableHead>
|
|
779
|
+
<TableHead className="text-xs font-medium uppercase tracking-wider w-[80px]">
|
|
780
|
+
Actions
|
|
781
|
+
</TableHead>
|
|
782
|
+
</TableRow>
|
|
783
|
+
</TableHeader>
|
|
784
|
+
<TableBody
|
|
785
|
+
className={cn("transition-opacity duration-150", loading && data && "opacity-60")}
|
|
786
|
+
>
|
|
787
|
+
{loading && !data ? (
|
|
788
|
+
<SkeletonRows cols={6} />
|
|
789
|
+
) : filteredRequests.length === 0 && !loading ? (
|
|
790
|
+
<EmptyState message="No export requests found" />
|
|
791
|
+
) : (
|
|
792
|
+
filteredRequests.map((req) => (
|
|
793
|
+
<TableRow key={req.id} className="h-10 hover:bg-secondary/50">
|
|
794
|
+
<TableCell className="font-mono text-xs w-[140px]">{req.tenantId}</TableCell>
|
|
795
|
+
<TableCell className="font-mono text-xs">{req.requestedBy}</TableCell>
|
|
796
|
+
<TableCell>
|
|
797
|
+
<span
|
|
798
|
+
className={cn(
|
|
799
|
+
"inline-flex items-center rounded-sm px-2 py-0.5 text-xs font-medium",
|
|
800
|
+
statusBadgeClasses(req.status),
|
|
801
|
+
)}
|
|
802
|
+
>
|
|
803
|
+
{req.status}
|
|
804
|
+
</span>
|
|
805
|
+
</TableCell>
|
|
806
|
+
<TableCell>
|
|
807
|
+
<span className="inline-flex items-center rounded-sm px-2 py-0.5 text-xs font-medium bg-secondary text-muted-foreground border border-border">
|
|
808
|
+
{req.format.toUpperCase()}
|
|
809
|
+
</span>
|
|
810
|
+
</TableCell>
|
|
811
|
+
<TableCell>
|
|
812
|
+
<Tooltip>
|
|
813
|
+
<TooltipTrigger className="text-xs text-muted-foreground font-mono">
|
|
814
|
+
{relativeTime(req.createdAt)}
|
|
815
|
+
</TooltipTrigger>
|
|
816
|
+
<TooltipContent>{new Date(req.createdAt).toLocaleString()}</TooltipContent>
|
|
817
|
+
</Tooltip>
|
|
818
|
+
</TableCell>
|
|
819
|
+
<TableCell>
|
|
820
|
+
{req.status === "completed" && req.downloadUrl ? (
|
|
821
|
+
isSafeUrl(req.downloadUrl) ? (
|
|
822
|
+
<a
|
|
823
|
+
href={req.downloadUrl}
|
|
824
|
+
target="_blank"
|
|
825
|
+
rel="noopener noreferrer"
|
|
826
|
+
className="text-xs text-terminal hover:underline"
|
|
827
|
+
>
|
|
828
|
+
Download
|
|
829
|
+
</a>
|
|
830
|
+
) : (
|
|
831
|
+
<span className="text-xs text-muted-foreground">Unavailable</span>
|
|
832
|
+
)
|
|
833
|
+
) : (
|
|
834
|
+
<span className="text-muted-foreground">{"\u2014"}</span>
|
|
835
|
+
)}
|
|
836
|
+
</TableCell>
|
|
837
|
+
</TableRow>
|
|
838
|
+
))
|
|
839
|
+
)}
|
|
840
|
+
</TableBody>
|
|
841
|
+
</Table>
|
|
842
|
+
</div>
|
|
843
|
+
|
|
844
|
+
{data && (
|
|
845
|
+
<Pagination
|
|
846
|
+
offset={offset}
|
|
847
|
+
total={data.total}
|
|
848
|
+
hasMore={offset + PAGE_SIZE < data.total}
|
|
849
|
+
onNavigate={(o) => load(o)}
|
|
850
|
+
/>
|
|
851
|
+
)}
|
|
852
|
+
|
|
853
|
+
<TriggerDialog
|
|
854
|
+
open={dialogOpen}
|
|
855
|
+
onOpenChange={setDialogOpen}
|
|
856
|
+
title="Trigger Data Export"
|
|
857
|
+
description="Generate a GDPR Article 15 data export for a specific tenant. This action is audited."
|
|
858
|
+
confirmLabel="Confirm Export"
|
|
859
|
+
confirmClassName="border-terminal/30 text-terminal hover:bg-terminal/10"
|
|
860
|
+
onConfirm={handleTriggerExport}
|
|
861
|
+
/>
|
|
862
|
+
</div>
|
|
863
|
+
);
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// ============================================================
|
|
867
|
+
// TAB 3: Compliance Audit Trail
|
|
868
|
+
// ============================================================
|
|
869
|
+
|
|
870
|
+
function ComplianceAuditTab() {
|
|
871
|
+
const [data, setData] = useState<AuditLogResponse | null>(null);
|
|
872
|
+
const [loading, setLoading] = useState(true);
|
|
873
|
+
const [loadError, setLoadError] = useState(false);
|
|
874
|
+
const [offset, setOffset] = useState(0);
|
|
875
|
+
const [dateRange, setDateRange] = useState("30");
|
|
876
|
+
const [actionFilter, setActionFilter] = useState("all");
|
|
877
|
+
const [search, setSearch] = useState("");
|
|
878
|
+
|
|
879
|
+
const load = useCallback(
|
|
880
|
+
async (newOffset: number, signal?: AbortSignal) => {
|
|
881
|
+
setLoading(true);
|
|
882
|
+
setLoadError(false);
|
|
883
|
+
try {
|
|
884
|
+
const since =
|
|
885
|
+
dateRange === "all"
|
|
886
|
+
? undefined
|
|
887
|
+
: new Date(Date.now() - Number(dateRange) * 86400000).toISOString();
|
|
888
|
+
const result = await fetchAuditLog({
|
|
889
|
+
limit: PAGE_SIZE,
|
|
890
|
+
offset: newOffset,
|
|
891
|
+
since,
|
|
892
|
+
action: actionFilter === "all" ? "compliance" : actionFilter,
|
|
893
|
+
});
|
|
894
|
+
if (signal?.aborted) return;
|
|
895
|
+
setData(result);
|
|
896
|
+
setOffset(newOffset);
|
|
897
|
+
} catch {
|
|
898
|
+
if (signal?.aborted) return;
|
|
899
|
+
setLoadError(true);
|
|
900
|
+
} finally {
|
|
901
|
+
if (!signal?.aborted) setLoading(false);
|
|
902
|
+
}
|
|
903
|
+
},
|
|
904
|
+
[dateRange, actionFilter],
|
|
905
|
+
);
|
|
906
|
+
|
|
907
|
+
useEffect(() => {
|
|
908
|
+
const controller = new AbortController();
|
|
909
|
+
load(0, controller.signal);
|
|
910
|
+
return () => controller.abort();
|
|
911
|
+
}, [load]);
|
|
912
|
+
|
|
913
|
+
const filteredEvents = useMemo(() => {
|
|
914
|
+
if (!data) return [];
|
|
915
|
+
if (!search.trim()) return data.events;
|
|
916
|
+
const q = search.toLowerCase();
|
|
917
|
+
return data.events.filter(
|
|
918
|
+
(e) =>
|
|
919
|
+
e.action.toLowerCase().includes(q) ||
|
|
920
|
+
e.resourceType.toLowerCase().includes(q) ||
|
|
921
|
+
(e.resourceName ?? e.resourceId).toLowerCase().includes(q) ||
|
|
922
|
+
(e.details ?? "").toLowerCase().includes(q),
|
|
923
|
+
);
|
|
924
|
+
}, [data, search]);
|
|
925
|
+
|
|
926
|
+
if (loadError) {
|
|
927
|
+
return (
|
|
928
|
+
<div className="flex h-40 flex-col items-center justify-center gap-3">
|
|
929
|
+
<p className="text-sm text-destructive font-mono">Failed to load compliance audit trail.</p>
|
|
930
|
+
<Button variant="outline" size="sm" onClick={() => load(offset)}>
|
|
931
|
+
Retry
|
|
932
|
+
</Button>
|
|
933
|
+
</div>
|
|
934
|
+
);
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
return (
|
|
938
|
+
<div className="space-y-3">
|
|
939
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
940
|
+
<div className="relative max-w-sm">
|
|
941
|
+
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
|
942
|
+
<Input
|
|
943
|
+
placeholder="Search events..."
|
|
944
|
+
value={search}
|
|
945
|
+
onChange={(e) => setSearch(e.target.value)}
|
|
946
|
+
className="pl-9 focus:border-terminal focus:ring-terminal/20"
|
|
947
|
+
/>
|
|
948
|
+
</div>
|
|
949
|
+
<Select value={dateRange} onValueChange={setDateRange}>
|
|
950
|
+
<SelectTrigger className="w-[150px]">
|
|
951
|
+
<SelectValue />
|
|
952
|
+
</SelectTrigger>
|
|
953
|
+
<SelectContent>
|
|
954
|
+
{DATE_RANGES.map((r) => (
|
|
955
|
+
<SelectItem key={r.value} value={r.value}>
|
|
956
|
+
{r.label}
|
|
957
|
+
</SelectItem>
|
|
958
|
+
))}
|
|
959
|
+
</SelectContent>
|
|
960
|
+
</Select>
|
|
961
|
+
<Select value={actionFilter} onValueChange={setActionFilter}>
|
|
962
|
+
<SelectTrigger className="w-[160px]">
|
|
963
|
+
<SelectValue />
|
|
964
|
+
</SelectTrigger>
|
|
965
|
+
<SelectContent>
|
|
966
|
+
{AUDIT_ACTION_FILTERS.map((f) => (
|
|
967
|
+
<SelectItem key={f.value} value={f.value}>
|
|
968
|
+
{f.label}
|
|
969
|
+
</SelectItem>
|
|
970
|
+
))}
|
|
971
|
+
</SelectContent>
|
|
972
|
+
</Select>
|
|
973
|
+
</div>
|
|
974
|
+
|
|
975
|
+
<div className="rounded-sm border border-terminal/10 overflow-x-auto">
|
|
976
|
+
<Table>
|
|
977
|
+
<TableHeader>
|
|
978
|
+
<TableRow className="bg-secondary crt-scanlines">
|
|
979
|
+
<TableHead className="text-xs font-medium uppercase tracking-wider w-[100px]">
|
|
980
|
+
Time
|
|
981
|
+
</TableHead>
|
|
982
|
+
<TableHead className="text-xs font-medium uppercase tracking-wider">Action</TableHead>
|
|
983
|
+
<TableHead className="text-xs font-medium uppercase tracking-wider">
|
|
984
|
+
Resource
|
|
985
|
+
</TableHead>
|
|
986
|
+
<TableHead className="text-xs font-medium uppercase tracking-wider">
|
|
987
|
+
Details
|
|
988
|
+
</TableHead>
|
|
989
|
+
</TableRow>
|
|
990
|
+
</TableHeader>
|
|
991
|
+
<TableBody
|
|
992
|
+
className={cn("transition-opacity duration-150", loading && data && "opacity-60")}
|
|
993
|
+
>
|
|
994
|
+
{loading && !data ? (
|
|
995
|
+
<SkeletonRows cols={4} rows={8} />
|
|
996
|
+
) : filteredEvents.length === 0 && !loading ? (
|
|
997
|
+
<EmptyState message="No compliance audit events found" />
|
|
998
|
+
) : (
|
|
999
|
+
filteredEvents.map((event) => (
|
|
1000
|
+
<TableRow key={event.id} className="h-10 hover:bg-secondary/50">
|
|
1001
|
+
<TableCell className="w-[100px]">
|
|
1002
|
+
<Tooltip>
|
|
1003
|
+
<TooltipTrigger className="text-xs text-muted-foreground font-mono">
|
|
1004
|
+
{relativeTime(event.createdAt)}
|
|
1005
|
+
</TooltipTrigger>
|
|
1006
|
+
<TooltipContent>{new Date(event.createdAt).toLocaleString()}</TooltipContent>
|
|
1007
|
+
</Tooltip>
|
|
1008
|
+
</TableCell>
|
|
1009
|
+
<TableCell>
|
|
1010
|
+
<span
|
|
1011
|
+
className={cn(
|
|
1012
|
+
"inline-flex items-center rounded-sm px-2 py-0.5 text-xs font-medium",
|
|
1013
|
+
complianceActionBadgeClasses(event.action),
|
|
1014
|
+
)}
|
|
1015
|
+
>
|
|
1016
|
+
{humanAction(event.action)}
|
|
1017
|
+
</span>
|
|
1018
|
+
</TableCell>
|
|
1019
|
+
<TableCell className="text-sm">
|
|
1020
|
+
<span className="text-xs uppercase tracking-wide text-muted-foreground">
|
|
1021
|
+
{event.resourceType}
|
|
1022
|
+
</span>{" "}
|
|
1023
|
+
<span className="font-mono text-xs">
|
|
1024
|
+
{event.resourceName ?? event.resourceId}
|
|
1025
|
+
</span>
|
|
1026
|
+
</TableCell>
|
|
1027
|
+
<TableCell className="text-xs text-muted-foreground max-w-[300px] truncate">
|
|
1028
|
+
{event.details ?? "\u2014"}
|
|
1029
|
+
</TableCell>
|
|
1030
|
+
</TableRow>
|
|
1031
|
+
))
|
|
1032
|
+
)}
|
|
1033
|
+
</TableBody>
|
|
1034
|
+
</Table>
|
|
1035
|
+
</div>
|
|
1036
|
+
|
|
1037
|
+
{data && (
|
|
1038
|
+
<Pagination
|
|
1039
|
+
offset={offset}
|
|
1040
|
+
total={data.total}
|
|
1041
|
+
hasMore={data.hasMore}
|
|
1042
|
+
onNavigate={(o) => load(o)}
|
|
1043
|
+
/>
|
|
1044
|
+
)}
|
|
1045
|
+
</div>
|
|
1046
|
+
);
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
// ============================================================
|
|
1050
|
+
// TAB 4: Retention Policies
|
|
1051
|
+
// ============================================================
|
|
1052
|
+
|
|
1053
|
+
function EditRetentionPolicyDialog({
|
|
1054
|
+
policy,
|
|
1055
|
+
open,
|
|
1056
|
+
onOpenChange,
|
|
1057
|
+
onSaved,
|
|
1058
|
+
}: {
|
|
1059
|
+
policy: RetentionPolicy;
|
|
1060
|
+
open: boolean;
|
|
1061
|
+
onOpenChange: (open: boolean) => void;
|
|
1062
|
+
onSaved: (updated: RetentionPolicy) => void;
|
|
1063
|
+
}) {
|
|
1064
|
+
const [retentionDaysInput, setRetentionDaysInput] = useState(String(policy.retentionDays));
|
|
1065
|
+
const [autoDelete, setAutoDelete] = useState(policy.autoDelete);
|
|
1066
|
+
const [saving, setSaving] = useState(false);
|
|
1067
|
+
|
|
1068
|
+
const retentionDaysParsed = parseInt(retentionDaysInput, 10);
|
|
1069
|
+
const retentionDaysValid =
|
|
1070
|
+
!Number.isNaN(retentionDaysParsed) && retentionDaysParsed >= 1 && retentionDaysParsed <= 3650;
|
|
1071
|
+
|
|
1072
|
+
useEffect(() => {
|
|
1073
|
+
if (open) {
|
|
1074
|
+
setRetentionDaysInput(String(policy.retentionDays));
|
|
1075
|
+
setAutoDelete(policy.autoDelete);
|
|
1076
|
+
}
|
|
1077
|
+
}, [open, policy.retentionDays, policy.autoDelete]);
|
|
1078
|
+
|
|
1079
|
+
const handleSave = async () => {
|
|
1080
|
+
if (!retentionDaysValid) return;
|
|
1081
|
+
setSaving(true);
|
|
1082
|
+
try {
|
|
1083
|
+
const updated = await updateRetentionPolicy(policy.dataType, {
|
|
1084
|
+
retentionDays: retentionDaysParsed,
|
|
1085
|
+
autoDelete,
|
|
1086
|
+
});
|
|
1087
|
+
onSaved(updated);
|
|
1088
|
+
onOpenChange(false);
|
|
1089
|
+
toast.success(`Updated retention policy for ${policy.dataType}`);
|
|
1090
|
+
} catch (err) {
|
|
1091
|
+
toast.error(toUserMessage(err));
|
|
1092
|
+
} finally {
|
|
1093
|
+
setSaving(false);
|
|
1094
|
+
}
|
|
1095
|
+
};
|
|
1096
|
+
|
|
1097
|
+
return (
|
|
1098
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
1099
|
+
<DialogContent>
|
|
1100
|
+
<DialogHeader>
|
|
1101
|
+
<DialogTitle className="text-base font-semibold uppercase tracking-wider">
|
|
1102
|
+
Edit Retention Policy
|
|
1103
|
+
</DialogTitle>
|
|
1104
|
+
<DialogDescription>Update retention settings for {policy.dataType}</DialogDescription>
|
|
1105
|
+
</DialogHeader>
|
|
1106
|
+
<div className="space-y-4 py-2">
|
|
1107
|
+
<div className="space-y-2">
|
|
1108
|
+
<Label htmlFor="retentionDays">Retention period (days)</Label>
|
|
1109
|
+
<Input
|
|
1110
|
+
id="retentionDays"
|
|
1111
|
+
type="number"
|
|
1112
|
+
step={1}
|
|
1113
|
+
min={1}
|
|
1114
|
+
max={3650}
|
|
1115
|
+
value={retentionDaysInput}
|
|
1116
|
+
onChange={(e) => setRetentionDaysInput(e.target.value)}
|
|
1117
|
+
/>
|
|
1118
|
+
</div>
|
|
1119
|
+
<div className="flex items-center justify-between">
|
|
1120
|
+
<Label htmlFor="autoDelete">Auto-delete</Label>
|
|
1121
|
+
<Switch id="autoDelete" checked={autoDelete} onCheckedChange={setAutoDelete} />
|
|
1122
|
+
</div>
|
|
1123
|
+
</div>
|
|
1124
|
+
<DialogFooter>
|
|
1125
|
+
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
|
|
1126
|
+
Cancel
|
|
1127
|
+
</Button>
|
|
1128
|
+
<Button onClick={handleSave} disabled={saving || !retentionDaysValid}>
|
|
1129
|
+
{saving ? "Saving\u2026" : "Save"}
|
|
1130
|
+
</Button>
|
|
1131
|
+
</DialogFooter>
|
|
1132
|
+
</DialogContent>
|
|
1133
|
+
</Dialog>
|
|
1134
|
+
);
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
function RetentionPoliciesTab() {
|
|
1138
|
+
const [policies, setPolicies] = useState<RetentionPolicy[] | null>(null);
|
|
1139
|
+
const [loading, setLoading] = useState(true);
|
|
1140
|
+
const [loadError, setLoadError] = useState(false);
|
|
1141
|
+
const [editingPolicy, setEditingPolicy] = useState<RetentionPolicy | null>(null);
|
|
1142
|
+
const lastEditingPolicyRef = useRef<RetentionPolicy | null>(null);
|
|
1143
|
+
if (editingPolicy !== null) lastEditingPolicyRef.current = editingPolicy;
|
|
1144
|
+
|
|
1145
|
+
useEffect(() => {
|
|
1146
|
+
let cancelled = false;
|
|
1147
|
+
setLoading(true);
|
|
1148
|
+
setLoadError(false);
|
|
1149
|
+
fetchRetentionPolicies()
|
|
1150
|
+
.then((result) => {
|
|
1151
|
+
if (!cancelled) setPolicies(result);
|
|
1152
|
+
})
|
|
1153
|
+
.catch(() => {
|
|
1154
|
+
if (!cancelled) setLoadError(true);
|
|
1155
|
+
})
|
|
1156
|
+
.finally(() => {
|
|
1157
|
+
if (!cancelled) setLoading(false);
|
|
1158
|
+
});
|
|
1159
|
+
return () => {
|
|
1160
|
+
cancelled = true;
|
|
1161
|
+
};
|
|
1162
|
+
}, []);
|
|
1163
|
+
|
|
1164
|
+
if (loadError) {
|
|
1165
|
+
return (
|
|
1166
|
+
<div className="flex h-40 flex-col items-center justify-center gap-3">
|
|
1167
|
+
<p className="text-sm text-destructive font-mono">Failed to load retention policies.</p>
|
|
1168
|
+
<Button
|
|
1169
|
+
variant="outline"
|
|
1170
|
+
size="sm"
|
|
1171
|
+
onClick={() => {
|
|
1172
|
+
setLoading(true);
|
|
1173
|
+
setLoadError(false);
|
|
1174
|
+
fetchRetentionPolicies()
|
|
1175
|
+
.then(setPolicies)
|
|
1176
|
+
.catch(() => setLoadError(true))
|
|
1177
|
+
.finally(() => setLoading(false));
|
|
1178
|
+
}}
|
|
1179
|
+
>
|
|
1180
|
+
Retry
|
|
1181
|
+
</Button>
|
|
1182
|
+
</div>
|
|
1183
|
+
);
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
if (loading) {
|
|
1187
|
+
return (
|
|
1188
|
+
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
|
1189
|
+
{SKEL_ROWS.slice(0, 6).map((key, i) => (
|
|
1190
|
+
<div key={key} className="rounded-sm border border-terminal/10 bg-card p-4 space-y-3">
|
|
1191
|
+
<Skeleton className="h-4 w-2/3" style={{ animationDelay: `${i * 50}ms` }} />
|
|
1192
|
+
<Skeleton className="h-3 w-full" style={{ animationDelay: `${i * 50 + 25}ms` }} />
|
|
1193
|
+
<Skeleton className="h-3 w-1/2" style={{ animationDelay: `${i * 50 + 50}ms` }} />
|
|
1194
|
+
</div>
|
|
1195
|
+
))}
|
|
1196
|
+
</div>
|
|
1197
|
+
);
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
if (!policies || policies.length === 0) {
|
|
1201
|
+
return (
|
|
1202
|
+
<div className="rounded-sm border border-terminal/10 p-8 text-center">
|
|
1203
|
+
<p className="text-sm text-muted-foreground font-mono">
|
|
1204
|
+
> No retention policies configured
|
|
1205
|
+
<span className="animate-ellipsis" />
|
|
1206
|
+
</p>
|
|
1207
|
+
</div>
|
|
1208
|
+
);
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
return (
|
|
1212
|
+
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
|
1213
|
+
{policies.map((policy) => (
|
|
1214
|
+
<div
|
|
1215
|
+
key={policy.dataType}
|
|
1216
|
+
className="rounded-sm border border-terminal/10 bg-card p-4 transition-colors duration-150 hover:border-terminal/20"
|
|
1217
|
+
>
|
|
1218
|
+
<h3 className="text-sm font-semibold uppercase tracking-wider text-foreground">
|
|
1219
|
+
{policy.dataType}
|
|
1220
|
+
</h3>
|
|
1221
|
+
<dl className="mt-3 space-y-1.5 text-xs font-mono text-muted-foreground">
|
|
1222
|
+
<div className="flex justify-between">
|
|
1223
|
+
<dt>Retention period</dt>
|
|
1224
|
+
<dd className="text-terminal font-semibold">{policy.retentionDays} days</dd>
|
|
1225
|
+
</div>
|
|
1226
|
+
<div className="flex justify-between">
|
|
1227
|
+
<dt>Auto-delete</dt>
|
|
1228
|
+
<dd>
|
|
1229
|
+
{policy.autoDelete ? (
|
|
1230
|
+
<span className="inline-flex items-center rounded-sm px-2 py-0.5 text-xs font-medium bg-terminal/15 text-terminal border border-terminal/20">
|
|
1231
|
+
Enabled
|
|
1232
|
+
</span>
|
|
1233
|
+
) : (
|
|
1234
|
+
<span className="inline-flex items-center rounded-sm px-2 py-0.5 text-xs font-medium bg-secondary text-muted-foreground border border-border">
|
|
1235
|
+
Disabled
|
|
1236
|
+
</span>
|
|
1237
|
+
)}
|
|
1238
|
+
</dd>
|
|
1239
|
+
</div>
|
|
1240
|
+
<div className="flex justify-between">
|
|
1241
|
+
<dt>Last purge</dt>
|
|
1242
|
+
<dd>
|
|
1243
|
+
{policy.lastPurge ? (
|
|
1244
|
+
<Tooltip>
|
|
1245
|
+
<TooltipTrigger>{relativeTime(policy.lastPurge)}</TooltipTrigger>
|
|
1246
|
+
<TooltipContent>{new Date(policy.lastPurge).toLocaleString()}</TooltipContent>
|
|
1247
|
+
</Tooltip>
|
|
1248
|
+
) : (
|
|
1249
|
+
"\u2014"
|
|
1250
|
+
)}
|
|
1251
|
+
</dd>
|
|
1252
|
+
</div>
|
|
1253
|
+
<div className="flex justify-between">
|
|
1254
|
+
<dt>Records affected</dt>
|
|
1255
|
+
<dd>{policy.recordsAffected.toLocaleString()}</dd>
|
|
1256
|
+
</div>
|
|
1257
|
+
</dl>
|
|
1258
|
+
<div className="mt-3 flex justify-end">
|
|
1259
|
+
<Button variant="outline" size="sm" onClick={() => setEditingPolicy(policy)}>
|
|
1260
|
+
<Pencil className="mr-1.5 size-3" />
|
|
1261
|
+
Edit
|
|
1262
|
+
</Button>
|
|
1263
|
+
</div>
|
|
1264
|
+
</div>
|
|
1265
|
+
))}
|
|
1266
|
+
{lastEditingPolicyRef.current && (
|
|
1267
|
+
<EditRetentionPolicyDialog
|
|
1268
|
+
policy={lastEditingPolicyRef.current}
|
|
1269
|
+
open={editingPolicy !== null}
|
|
1270
|
+
onOpenChange={(open) => {
|
|
1271
|
+
if (!open) setEditingPolicy(null);
|
|
1272
|
+
}}
|
|
1273
|
+
onSaved={(updated) => {
|
|
1274
|
+
setPolicies((prev) =>
|
|
1275
|
+
prev ? prev.map((p) => (p.dataType === updated.dataType ? updated : p)) : prev,
|
|
1276
|
+
);
|
|
1277
|
+
setEditingPolicy(null);
|
|
1278
|
+
}}
|
|
1279
|
+
/>
|
|
1280
|
+
)}
|
|
1281
|
+
</div>
|
|
1282
|
+
);
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
// ============================================================
|
|
1286
|
+
// Main Dashboard
|
|
1287
|
+
// ============================================================
|
|
1288
|
+
|
|
1289
|
+
export function ComplianceDashboard() {
|
|
1290
|
+
const [activeTab, setActiveTab] = useState("deletions");
|
|
1291
|
+
|
|
1292
|
+
return (
|
|
1293
|
+
<div className="space-y-3 p-6">
|
|
1294
|
+
<div className="flex items-center justify-between">
|
|
1295
|
+
<h1 className="text-xl font-bold uppercase tracking-wider text-terminal [text-shadow:0_0_10px_rgba(0,255,65,0.25)]">
|
|
1296
|
+
GDPR COMPLIANCE
|
|
1297
|
+
</h1>
|
|
1298
|
+
<span className="text-sm text-muted-foreground font-mono">
|
|
1299
|
+
{activeTab === "deletions" && "Deletion requests"}
|
|
1300
|
+
{activeTab === "exports" && "Data exports"}
|
|
1301
|
+
{activeTab === "audit" && "Audit trail"}
|
|
1302
|
+
{activeTab === "retention" && "Retention policies"}
|
|
1303
|
+
</span>
|
|
1304
|
+
</div>
|
|
1305
|
+
|
|
1306
|
+
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
|
1307
|
+
<TabsList>
|
|
1308
|
+
<TabsTrigger value="deletions">
|
|
1309
|
+
<Trash2 className="mr-1.5 size-3.5" />
|
|
1310
|
+
Deletion Requests
|
|
1311
|
+
</TabsTrigger>
|
|
1312
|
+
<TabsTrigger value="exports">
|
|
1313
|
+
<Download className="mr-1.5 size-3.5" />
|
|
1314
|
+
Data Exports
|
|
1315
|
+
</TabsTrigger>
|
|
1316
|
+
<TabsTrigger value="audit">
|
|
1317
|
+
<FileText className="mr-1.5 size-3.5" />
|
|
1318
|
+
Audit Trail
|
|
1319
|
+
</TabsTrigger>
|
|
1320
|
+
<TabsTrigger value="retention">
|
|
1321
|
+
<Shield className="mr-1.5 size-3.5" />
|
|
1322
|
+
Retention Policies
|
|
1323
|
+
</TabsTrigger>
|
|
1324
|
+
</TabsList>
|
|
1325
|
+
|
|
1326
|
+
<TabsContent value="deletions">
|
|
1327
|
+
<DeletionRequestsTab />
|
|
1328
|
+
</TabsContent>
|
|
1329
|
+
<TabsContent value="exports">
|
|
1330
|
+
<DataExportsTab />
|
|
1331
|
+
</TabsContent>
|
|
1332
|
+
<TabsContent value="audit">
|
|
1333
|
+
<ComplianceAuditTab />
|
|
1334
|
+
</TabsContent>
|
|
1335
|
+
<TabsContent value="retention">
|
|
1336
|
+
<RetentionPoliciesTab />
|
|
1337
|
+
</TabsContent>
|
|
1338
|
+
</Tabs>
|
|
1339
|
+
</div>
|
|
1340
|
+
);
|
|
1341
|
+
}
|