From 92092760a7a1341f545ba3e53a51b7436b5c79cb Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 17 Jan 2026 12:33:11 +0100 Subject: [PATCH] Complete Hub Admin Dashboard with analytics, settings, and enterprise features Major additions: - Analytics dashboard with charts (line, bar, donut) - Enterprise client monitoring with container management - Staff management with 2FA support - Profile management and settings pages - Netcup server integration - DNS verification panel - Portainer integration - Container logs and health monitoring - Automation controls for orders New API endpoints: - /api/v1/admin/analytics - /api/v1/admin/enterprise-clients - /api/v1/admin/netcup - /api/v1/admin/settings - /api/v1/admin/staff - /api/v1/profile Co-Authored-By: Claude Opus 4.5 --- .gitignore | 3 + CLAUDE.md | 146 +- Dockerfile | 12 + docker-compose.yml | 19 + next.config.ts | 16 + package-lock.json | 5472 ++++++++++++++--- package.json | 37 +- .../20260106134433_init/migration.sql | 272 + .../migration.sql | 20 + .../migration.sql | 57 + .../migration.sql | 4 + .../migration.sql | 143 + .../migration.sql | 84 + .../migration.sql | 46 + .../migration.sql | 11 + .../migration.sql | 2 + prisma/migrations/migration_lock.toml | 3 + prisma/schema.prisma | 504 +- src/__tests__/fixtures/orders.ts | 246 + src/__tests__/fixtures/subscriptions.ts | 95 + src/__tests__/fixtures/users.ts | 75 + src/__tests__/mocks/fetch.ts | 69 + src/__tests__/mocks/next-auth.ts | 45 + src/__tests__/mocks/prisma.ts | 18 + src/__tests__/setup.ts | 20 + src/__tests__/unit/lib/api/client.test.ts | 266 + .../lib/services/credential-service.test.ts | 261 + .../unit/lib/services/job-service.test.ts | 428 ++ src/app/(auth)/invite/[token]/page.tsx | 265 + src/app/(auth)/login/page.tsx | 122 + src/app/admin/analytics/page.tsx | 328 + src/app/admin/customers/[id]/page.tsx | 894 ++- src/app/admin/customers/page.tsx | 676 +- .../admin/enterprise-clients/[id]/page.tsx | 908 +++ .../containers/[containerId]/page.tsx | 403 ++ .../[id]/servers/[serverId]/danger/page.tsx | 251 + .../[id]/servers/[serverId]/page.tsx | 349 ++ .../[id]/servers/[serverId]/settings/page.tsx | 406 ++ src/app/admin/enterprise-clients/page.tsx | 532 ++ .../[id]/containers/[containerId]/page.tsx | 772 +++ src/app/admin/orders/[id]/page.tsx | 682 +- src/app/admin/orders/page.tsx | 442 +- src/app/admin/page.tsx | 347 +- src/app/admin/profile/page.tsx | 256 + .../[id]/containers/[containerId]/page.tsx | 772 +++ src/app/admin/servers/[id]/page.tsx | 370 ++ src/app/admin/servers/netcup/[id]/page.tsx | 1245 ++++ src/app/admin/servers/netcup/page.tsx | 916 +++ src/app/admin/servers/page.tsx | 657 +- src/app/admin/settings/page.tsx | 1075 ++++ src/app/admin/staff/page.tsx | 464 ++ src/app/api/cron/cleanup-stats/route.ts | 56 + src/app/api/cron/collect-stats/route.ts | 110 + src/app/api/v1/admin/analytics/route.ts | 313 + src/app/api/v1/admin/customers/[id]/route.ts | 94 + src/app/api/v1/admin/customers/route.ts | 89 + .../[id]/container-events/route.ts | 124 + .../[id]/error-dashboard/route.ts | 38 + .../[id]/error-rules/[ruleId]/route.ts | 137 + .../[id]/error-rules/route.ts | 120 + .../errors/[errorId]/acknowledge/route.ts | 51 + .../enterprise-clients/[id]/errors/route.ts | 57 + .../[id]/notifications/route.ts | 92 + .../v1/admin/enterprise-clients/[id]/route.ts | 131 + .../[id]/servers/[serverId]/actions/route.ts | 146 + .../containers/[containerId]/logs/route.ts | 60 + .../containers/[containerId]/route.ts | 198 + .../servers/[serverId]/containers/route.ts | 82 + .../[id]/servers/[serverId]/route.ts | 137 + .../[id]/servers/[serverId]/stats/route.ts | 109 + .../[serverId]/test-portainer/route.ts | 81 + .../[id]/servers/[serverId]/verify/route.ts | 67 + .../enterprise-clients/[id]/servers/route.ts | 137 + .../enterprise-clients/[id]/stats/route.ts | 40 + .../enterprise-clients/error-summary/route.ts | 52 + .../api/v1/admin/enterprise-clients/route.ts | 66 + src/app/api/v1/admin/netcup/auth/route.ts | 185 + .../netcup/servers/[id]/metrics/route.ts | 82 + .../api/v1/admin/netcup/servers/[id]/route.ts | 221 + .../netcup/servers/[id]/snapshots/route.ts | 176 + src/app/api/v1/admin/netcup/servers/route.ts | 61 + .../api/v1/admin/netcup/tasks/[id]/route.ts | 40 + .../v1/admin/orders/[id]/automation/route.ts | 216 + .../[containerId]/[action]/route.ts | 66 + .../containers/[containerId]/logs/route.ts | 47 + .../[id]/containers/[containerId]/route.ts | 133 + .../containers/[containerId]/stats/route.ts | 51 + .../v1/admin/orders/[id]/containers/route.ts | 63 + .../orders/[id]/containers/stats/route.ts | 44 + src/app/api/v1/admin/orders/[id]/dns/route.ts | 37 + .../v1/admin/orders/[id]/dns/skip/route.ts | 53 + .../v1/admin/orders/[id]/dns/verify/route.ts | 54 + .../v1/admin/orders/[id]/logs/stream/route.ts | 67 +- .../admin/orders/[id]/portainer/init/route.ts | 135 + .../v1/admin/orders/[id]/portainer/route.ts | 153 + .../v1/admin/orders/[id]/provision/route.ts | 246 +- src/app/api/v1/admin/orders/[id]/route.ts | 249 +- .../v1/admin/orders/[id]/test-ssh/route.ts | 131 + src/app/api/v1/admin/orders/route.ts | 36 +- src/app/api/v1/admin/portainer/ping/route.ts | 114 + .../v1/admin/servers/[id]/command/route.ts | 176 + .../api/v1/admin/servers/[id]/health/route.ts | 158 + src/app/api/v1/admin/settings/[key]/route.ts | 144 + .../api/v1/admin/settings/email/test/route.ts | 65 + src/app/api/v1/admin/settings/route.ts | 108 + .../v1/admin/settings/storage/test/route.ts | 45 + src/app/api/v1/admin/staff/[id]/route.ts | 221 + src/app/api/v1/admin/staff/invite/route.ts | 199 + .../api/v1/admin/staff/invites/[id]/route.ts | 106 + src/app/api/v1/admin/staff/invites/route.ts | 66 + src/app/api/v1/admin/staff/route.ts | 94 + src/app/api/v1/auth/2fa/backup-codes/route.ts | 107 + src/app/api/v1/auth/2fa/disable/route.ts | 148 + src/app/api/v1/auth/2fa/setup/route.ts | 77 + src/app/api/v1/auth/2fa/status/route.ts | 64 + src/app/api/v1/auth/2fa/verify/route.ts | 112 + src/app/api/v1/auth/accept-invite/route.ts | 134 + src/app/api/v1/auth/invite/[token]/route.ts | 74 + src/app/api/v1/jobs/[id]/logs/route.ts | 206 +- src/app/api/v1/jobs/[id]/route.ts | 218 +- src/app/api/v1/orchestrator/commands/route.ts | 166 + .../api/v1/orchestrator/heartbeat/route.ts | 138 + src/app/api/v1/orchestrator/register/route.ts | 110 + src/app/api/v1/profile/password/route.ts | 89 + src/app/api/v1/profile/photo/route.ts | 141 + src/app/api/v1/profile/route.ts | 106 + src/app/api/v1/public/orders/route.ts | 281 + src/app/globals.css | 40 + src/components/admin/AddCustomerDialog.tsx | 257 + src/components/admin/ToolsEditor.tsx | 302 + src/components/admin/automation-controls.tsx | 229 + src/components/admin/container-list.tsx | 406 ++ .../admin/container-logs-dialog.tsx | 106 + src/components/admin/create-order-dialog.tsx | 252 +- .../admin/dns-verification-panel.tsx | 441 ++ src/components/admin/edit-hostname-dialog.tsx | 134 + src/components/admin/edit-nickname-dialog.tsx | 127 + .../enterprise-container-events-panel.tsx | 349 ++ .../admin/enterprise-container-list.tsx | 294 + .../enterprise-container-logs-dialog.tsx | 179 + .../enterprise-detected-errors-panel.tsx | 295 + .../admin/enterprise-error-rules-manager.tsx | 381 ++ .../admin/enterprise-error-summary-widget.tsx | 193 + .../admin/enterprise-stats-charts.tsx | 568 ++ .../admin/error-trend-sparkline.tsx | 151 + src/components/admin/invite-staff-dialog.tsx | 204 + src/components/admin/live-stats-panel.tsx | 394 ++ src/components/admin/netcup-auth-setup.tsx | 375 ++ src/components/admin/netcup-server-link.tsx | 234 + .../admin/notification-settings-panel.tsx | 344 ++ src/components/admin/order-timeline.tsx | 151 + .../admin/portainer-credentials-panel.tsx | 298 + .../admin/provisioning-config-form.tsx | 316 + src/components/admin/reinstall-dialog.tsx | 317 + .../admin/security-verification-dialog.tsx | 381 ++ src/components/admin/server-metrics-panel.tsx | 310 + src/components/admin/server-quick-actions.tsx | 375 ++ src/components/admin/sidebar.tsx | 82 +- .../analytics/analytics-section.tsx | 29 + src/components/analytics/bar-chart.tsx | 188 + src/components/analytics/donut-chart.tsx | 176 + src/components/analytics/line-chart.tsx | 131 + src/components/analytics/stat-card.tsx | 66 + .../profile/password-change-dialog.tsx | 296 + .../profile/profile-photo-upload.tsx | 226 + .../settings/two-factor-settings.tsx | 447 ++ src/components/ui/alert-dialog.tsx | 196 + src/components/ui/avatar.tsx | 50 + src/components/ui/badge.tsx | 36 + src/components/ui/checkbox.tsx | 30 + src/components/ui/dialog.tsx | 11 + src/components/ui/dropdown-menu.tsx | 181 + src/components/ui/refresh-button.tsx | 93 + src/components/ui/select.tsx | 160 + src/components/ui/separator.tsx | 31 + src/components/ui/skeleton.tsx | 15 + src/components/ui/slider-confirm-dialog.tsx | 238 + src/components/ui/switch.tsx | 29 + src/components/ui/table.tsx | 116 + src/components/ui/tabs.tsx | 55 + src/hooks/use-analytics.ts | 90 + src/hooks/use-automation.ts | 44 + src/hooks/use-customers.ts | 29 +- src/hooks/use-dns.ts | 107 + src/hooks/use-enterprise-clients.ts | 562 ++ src/hooks/use-netcup.ts | 836 +++ src/hooks/use-orders.ts | 13 + src/hooks/use-portainer.ts | 331 + src/hooks/use-profile.ts | 126 + src/hooks/use-provisioning-logs.ts | 4 +- src/hooks/use-settings.ts | 99 + src/hooks/use-staff.ts | 234 + src/hooks/use-two-factor.ts | 152 + src/lib/ansible/index.ts | 9 + src/lib/ansible/runner.ts | 388 ++ src/lib/ansible/types.ts | 64 + src/lib/api/admin.ts | 524 +- src/lib/api/client.ts | 39 +- src/lib/auth-helpers.ts | 71 + src/lib/auth.ts | 186 +- src/lib/csv-export.ts | 115 + src/lib/services/automation-worker.ts | 256 + src/lib/services/config-generator.ts | 145 + src/lib/services/container-health-service.ts | 546 ++ src/lib/services/credential-service.ts | 98 + src/lib/services/dns-service.ts | 476 ++ src/lib/services/docker-spawner.ts | 317 + src/lib/services/email-service.ts | 196 + src/lib/services/enterprise-client-service.ts | 477 ++ src/lib/services/error-dashboard-service.ts | 477 ++ src/lib/services/error-detection-service.ts | 448 ++ src/lib/services/job-service.ts | 16 +- src/lib/services/license-service.ts | 50 + src/lib/services/log-scanning-service.ts | 397 ++ src/lib/services/netcup-service.ts | 1149 ++++ src/lib/services/notification-service.ts | 401 ++ src/lib/services/permission-service.ts | 271 + src/lib/services/portainer-client.ts | 706 +++ src/lib/services/provisioning-service.ts | 200 + .../services/security-verification-service.ts | 289 + src/lib/services/settings-service.ts | 662 ++ src/lib/services/stats-collection-service.ts | 363 ++ src/lib/services/storage-service.ts | 301 + .../services/system-notification-service.ts | 496 ++ src/lib/services/totp-service.ts | 144 + src/lib/ssh/client.ts | 412 ++ src/lib/ssh/constants.ts | 10 + src/lib/ssh/index.ts | 12 + src/lib/ssh/types.ts | 61 + src/types/api.ts | 212 + src/types/next-auth.d.ts | 9 +- tailwind.config.ts | 58 + tsconfig.json | 25 +- vitest.config.ts | 27 + 234 files changed, 52896 insertions(+), 2425 deletions(-) create mode 100644 prisma/migrations/20260106134433_init/migration.sql create mode 100644 prisma/migrations/20260106214805_add_system_settings_and_order_provisioning_fields/migration.sql create mode 100644 prisma/migrations/20260107115149_phase2_automation_dns_netcup/migration.sql create mode 100644 prisma/migrations/20260107145050_add_portainer_credentials/migration.sql create mode 100644 prisma/migrations/20260111160942_add_enterprise_clients/migration.sql create mode 100644 prisma/migrations/20260111201955_add_intelligent_error_tracking/migration.sql create mode 100644 prisma/migrations/20260114112809_add_2fa_and_staff_management/migration.sql create mode 100644 prisma/migrations/20260114115959_add_notification_cooldown/migration.sql create mode 100644 prisma/migrations/20260114123526_add_staff_profile_photo/migration.sql create mode 100644 prisma/migrations/migration_lock.toml create mode 100644 src/__tests__/fixtures/orders.ts create mode 100644 src/__tests__/fixtures/subscriptions.ts create mode 100644 src/__tests__/fixtures/users.ts create mode 100644 src/__tests__/mocks/fetch.ts create mode 100644 src/__tests__/mocks/next-auth.ts create mode 100644 src/__tests__/mocks/prisma.ts create mode 100644 src/__tests__/setup.ts create mode 100644 src/__tests__/unit/lib/api/client.test.ts create mode 100644 src/__tests__/unit/lib/services/credential-service.test.ts create mode 100644 src/__tests__/unit/lib/services/job-service.test.ts create mode 100644 src/app/(auth)/invite/[token]/page.tsx create mode 100644 src/app/admin/analytics/page.tsx create mode 100644 src/app/admin/enterprise-clients/[id]/page.tsx create mode 100644 src/app/admin/enterprise-clients/[id]/servers/[serverId]/containers/[containerId]/page.tsx create mode 100644 src/app/admin/enterprise-clients/[id]/servers/[serverId]/danger/page.tsx create mode 100644 src/app/admin/enterprise-clients/[id]/servers/[serverId]/page.tsx create mode 100644 src/app/admin/enterprise-clients/[id]/servers/[serverId]/settings/page.tsx create mode 100644 src/app/admin/enterprise-clients/page.tsx create mode 100644 src/app/admin/orders/[id]/containers/[containerId]/page.tsx create mode 100644 src/app/admin/profile/page.tsx create mode 100644 src/app/admin/servers/[id]/containers/[containerId]/page.tsx create mode 100644 src/app/admin/servers/[id]/page.tsx create mode 100644 src/app/admin/servers/netcup/[id]/page.tsx create mode 100644 src/app/admin/servers/netcup/page.tsx create mode 100644 src/app/admin/settings/page.tsx create mode 100644 src/app/admin/staff/page.tsx create mode 100644 src/app/api/cron/cleanup-stats/route.ts create mode 100644 src/app/api/cron/collect-stats/route.ts create mode 100644 src/app/api/v1/admin/analytics/route.ts create mode 100644 src/app/api/v1/admin/enterprise-clients/[id]/container-events/route.ts create mode 100644 src/app/api/v1/admin/enterprise-clients/[id]/error-dashboard/route.ts create mode 100644 src/app/api/v1/admin/enterprise-clients/[id]/error-rules/[ruleId]/route.ts create mode 100644 src/app/api/v1/admin/enterprise-clients/[id]/error-rules/route.ts create mode 100644 src/app/api/v1/admin/enterprise-clients/[id]/errors/[errorId]/acknowledge/route.ts create mode 100644 src/app/api/v1/admin/enterprise-clients/[id]/errors/route.ts create mode 100644 src/app/api/v1/admin/enterprise-clients/[id]/notifications/route.ts create mode 100644 src/app/api/v1/admin/enterprise-clients/[id]/route.ts create mode 100644 src/app/api/v1/admin/enterprise-clients/[id]/servers/[serverId]/actions/route.ts create mode 100644 src/app/api/v1/admin/enterprise-clients/[id]/servers/[serverId]/containers/[containerId]/logs/route.ts create mode 100644 src/app/api/v1/admin/enterprise-clients/[id]/servers/[serverId]/containers/[containerId]/route.ts create mode 100644 src/app/api/v1/admin/enterprise-clients/[id]/servers/[serverId]/containers/route.ts create mode 100644 src/app/api/v1/admin/enterprise-clients/[id]/servers/[serverId]/route.ts create mode 100644 src/app/api/v1/admin/enterprise-clients/[id]/servers/[serverId]/stats/route.ts create mode 100644 src/app/api/v1/admin/enterprise-clients/[id]/servers/[serverId]/test-portainer/route.ts create mode 100644 src/app/api/v1/admin/enterprise-clients/[id]/servers/[serverId]/verify/route.ts create mode 100644 src/app/api/v1/admin/enterprise-clients/[id]/servers/route.ts create mode 100644 src/app/api/v1/admin/enterprise-clients/[id]/stats/route.ts create mode 100644 src/app/api/v1/admin/enterprise-clients/error-summary/route.ts create mode 100644 src/app/api/v1/admin/enterprise-clients/route.ts create mode 100644 src/app/api/v1/admin/netcup/auth/route.ts create mode 100644 src/app/api/v1/admin/netcup/servers/[id]/metrics/route.ts create mode 100644 src/app/api/v1/admin/netcup/servers/[id]/route.ts create mode 100644 src/app/api/v1/admin/netcup/servers/[id]/snapshots/route.ts create mode 100644 src/app/api/v1/admin/netcup/servers/route.ts create mode 100644 src/app/api/v1/admin/netcup/tasks/[id]/route.ts create mode 100644 src/app/api/v1/admin/orders/[id]/automation/route.ts create mode 100644 src/app/api/v1/admin/orders/[id]/containers/[containerId]/[action]/route.ts create mode 100644 src/app/api/v1/admin/orders/[id]/containers/[containerId]/logs/route.ts create mode 100644 src/app/api/v1/admin/orders/[id]/containers/[containerId]/route.ts create mode 100644 src/app/api/v1/admin/orders/[id]/containers/[containerId]/stats/route.ts create mode 100644 src/app/api/v1/admin/orders/[id]/containers/route.ts create mode 100644 src/app/api/v1/admin/orders/[id]/containers/stats/route.ts create mode 100644 src/app/api/v1/admin/orders/[id]/dns/route.ts create mode 100644 src/app/api/v1/admin/orders/[id]/dns/skip/route.ts create mode 100644 src/app/api/v1/admin/orders/[id]/dns/verify/route.ts create mode 100644 src/app/api/v1/admin/orders/[id]/portainer/init/route.ts create mode 100644 src/app/api/v1/admin/orders/[id]/portainer/route.ts create mode 100644 src/app/api/v1/admin/orders/[id]/test-ssh/route.ts create mode 100644 src/app/api/v1/admin/portainer/ping/route.ts create mode 100644 src/app/api/v1/admin/servers/[id]/command/route.ts create mode 100644 src/app/api/v1/admin/servers/[id]/health/route.ts create mode 100644 src/app/api/v1/admin/settings/[key]/route.ts create mode 100644 src/app/api/v1/admin/settings/email/test/route.ts create mode 100644 src/app/api/v1/admin/settings/route.ts create mode 100644 src/app/api/v1/admin/settings/storage/test/route.ts create mode 100644 src/app/api/v1/admin/staff/[id]/route.ts create mode 100644 src/app/api/v1/admin/staff/invite/route.ts create mode 100644 src/app/api/v1/admin/staff/invites/[id]/route.ts create mode 100644 src/app/api/v1/admin/staff/invites/route.ts create mode 100644 src/app/api/v1/admin/staff/route.ts create mode 100644 src/app/api/v1/auth/2fa/backup-codes/route.ts create mode 100644 src/app/api/v1/auth/2fa/disable/route.ts create mode 100644 src/app/api/v1/auth/2fa/setup/route.ts create mode 100644 src/app/api/v1/auth/2fa/status/route.ts create mode 100644 src/app/api/v1/auth/2fa/verify/route.ts create mode 100644 src/app/api/v1/auth/accept-invite/route.ts create mode 100644 src/app/api/v1/auth/invite/[token]/route.ts create mode 100644 src/app/api/v1/orchestrator/commands/route.ts create mode 100644 src/app/api/v1/orchestrator/heartbeat/route.ts create mode 100644 src/app/api/v1/orchestrator/register/route.ts create mode 100644 src/app/api/v1/profile/password/route.ts create mode 100644 src/app/api/v1/profile/photo/route.ts create mode 100644 src/app/api/v1/profile/route.ts create mode 100644 src/app/api/v1/public/orders/route.ts create mode 100644 src/components/admin/AddCustomerDialog.tsx create mode 100644 src/components/admin/ToolsEditor.tsx create mode 100644 src/components/admin/automation-controls.tsx create mode 100644 src/components/admin/container-list.tsx create mode 100644 src/components/admin/container-logs-dialog.tsx create mode 100644 src/components/admin/dns-verification-panel.tsx create mode 100644 src/components/admin/edit-hostname-dialog.tsx create mode 100644 src/components/admin/edit-nickname-dialog.tsx create mode 100644 src/components/admin/enterprise-container-events-panel.tsx create mode 100644 src/components/admin/enterprise-container-list.tsx create mode 100644 src/components/admin/enterprise-container-logs-dialog.tsx create mode 100644 src/components/admin/enterprise-detected-errors-panel.tsx create mode 100644 src/components/admin/enterprise-error-rules-manager.tsx create mode 100644 src/components/admin/enterprise-error-summary-widget.tsx create mode 100644 src/components/admin/enterprise-stats-charts.tsx create mode 100644 src/components/admin/error-trend-sparkline.tsx create mode 100644 src/components/admin/invite-staff-dialog.tsx create mode 100644 src/components/admin/live-stats-panel.tsx create mode 100644 src/components/admin/netcup-auth-setup.tsx create mode 100644 src/components/admin/netcup-server-link.tsx create mode 100644 src/components/admin/notification-settings-panel.tsx create mode 100644 src/components/admin/order-timeline.tsx create mode 100644 src/components/admin/portainer-credentials-panel.tsx create mode 100644 src/components/admin/provisioning-config-form.tsx create mode 100644 src/components/admin/reinstall-dialog.tsx create mode 100644 src/components/admin/security-verification-dialog.tsx create mode 100644 src/components/admin/server-metrics-panel.tsx create mode 100644 src/components/admin/server-quick-actions.tsx create mode 100644 src/components/analytics/analytics-section.tsx create mode 100644 src/components/analytics/bar-chart.tsx create mode 100644 src/components/analytics/donut-chart.tsx create mode 100644 src/components/analytics/line-chart.tsx create mode 100644 src/components/analytics/stat-card.tsx create mode 100644 src/components/profile/password-change-dialog.tsx create mode 100644 src/components/profile/profile-photo-upload.tsx create mode 100644 src/components/settings/two-factor-settings.tsx create mode 100644 src/components/ui/alert-dialog.tsx create mode 100644 src/components/ui/avatar.tsx create mode 100644 src/components/ui/badge.tsx create mode 100644 src/components/ui/checkbox.tsx create mode 100644 src/components/ui/dropdown-menu.tsx create mode 100644 src/components/ui/refresh-button.tsx create mode 100644 src/components/ui/select.tsx create mode 100644 src/components/ui/separator.tsx create mode 100644 src/components/ui/skeleton.tsx create mode 100644 src/components/ui/slider-confirm-dialog.tsx create mode 100644 src/components/ui/switch.tsx create mode 100644 src/components/ui/table.tsx create mode 100644 src/components/ui/tabs.tsx create mode 100644 src/hooks/use-analytics.ts create mode 100644 src/hooks/use-automation.ts create mode 100644 src/hooks/use-dns.ts create mode 100644 src/hooks/use-enterprise-clients.ts create mode 100644 src/hooks/use-netcup.ts create mode 100644 src/hooks/use-portainer.ts create mode 100644 src/hooks/use-profile.ts create mode 100644 src/hooks/use-settings.ts create mode 100644 src/hooks/use-staff.ts create mode 100644 src/hooks/use-two-factor.ts create mode 100644 src/lib/ansible/index.ts create mode 100644 src/lib/ansible/runner.ts create mode 100644 src/lib/ansible/types.ts create mode 100644 src/lib/auth-helpers.ts create mode 100644 src/lib/csv-export.ts create mode 100644 src/lib/services/automation-worker.ts create mode 100644 src/lib/services/config-generator.ts create mode 100644 src/lib/services/container-health-service.ts create mode 100644 src/lib/services/credential-service.ts create mode 100644 src/lib/services/dns-service.ts create mode 100644 src/lib/services/docker-spawner.ts create mode 100644 src/lib/services/email-service.ts create mode 100644 src/lib/services/enterprise-client-service.ts create mode 100644 src/lib/services/error-dashboard-service.ts create mode 100644 src/lib/services/error-detection-service.ts create mode 100644 src/lib/services/license-service.ts create mode 100644 src/lib/services/log-scanning-service.ts create mode 100644 src/lib/services/netcup-service.ts create mode 100644 src/lib/services/notification-service.ts create mode 100644 src/lib/services/permission-service.ts create mode 100644 src/lib/services/portainer-client.ts create mode 100644 src/lib/services/provisioning-service.ts create mode 100644 src/lib/services/security-verification-service.ts create mode 100644 src/lib/services/settings-service.ts create mode 100644 src/lib/services/stats-collection-service.ts create mode 100644 src/lib/services/storage-service.ts create mode 100644 src/lib/services/system-notification-service.ts create mode 100644 src/lib/services/totp-service.ts create mode 100644 src/lib/ssh/client.ts create mode 100644 src/lib/ssh/constants.ts create mode 100644 src/lib/ssh/index.ts create mode 100644 src/lib/ssh/types.ts create mode 100644 vitest.config.ts diff --git a/.gitignore b/.gitignore index 714a664..17973e9 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,6 @@ Thumbs.db # Prisma prisma/*.db prisma/*.db-journal + +# Job runtime data +jobs/ diff --git a/CLAUDE.md b/CLAUDE.md index 842a18d..cd30a05 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,6 +11,7 @@ The Hub provides: - **Customer Management**: Create/manage customers and subscriptions - **Order Management**: Process and track provisioning orders - **Server Monitoring**: View and manage tenant servers +- **Netcup Integration**: Full server management via Netcup SCP API - **Token Usage Tracking**: Monitor AI token consumption ## Tech Stack @@ -29,19 +30,40 @@ src/ ├── app/ # Next.js App Router │ ├── admin/ # Admin dashboard pages │ │ ├── customers/ # Customer management +│ │ │ └── [id]/ # Customer detail with order creation │ │ ├── orders/ # Order management +│ │ │ └── [id]/ # Order detail with DNS, provisioning │ │ ├── servers/ # Server monitoring +│ │ │ └── netcup/ # Netcup servers management +│ │ │ └── [id]/ # Netcup server detail +│ │ ├── settings/ # Admin settings │ │ └── layout.tsx # Admin layout with sidebar │ ├── api/v1/ # API routes │ │ ├── admin/ # Admin API endpoints +│ │ │ ├── customers/ # Customer CRUD +│ │ │ ├── orders/ # Order CRUD + provisioning +│ │ │ ├── netcup/ # Netcup SCP API integration +│ │ │ └── servers/ # Server management │ │ └── public/ # Public API endpoints │ └── (auth)/ # Authentication pages ├── components/ │ ├── admin/ # Admin-specific components -│ └── ui/ # Reusable UI components +│ │ ├── create-order-dialog.tsx # Order creation wizard +│ │ ├── netcup-auth-setup.tsx # Netcup OAuth setup +│ │ ├── netcup-server-link.tsx # Link orders to Netcup servers +│ │ └── dns-verification-panel.tsx # DNS verification UI +│ └── ui/ # Reusable UI components (shadcn/ui) ├── hooks/ # React Query hooks +│ ├── use-customers.ts # Customer data hooks +│ ├── use-orders.ts # Order data hooks +│ ├── use-netcup.ts # Netcup API hooks +│ └── use-dns.ts # DNS verification hooks ├── lib/ # Utilities and shared code -│ └── prisma.ts # Prisma client singleton +│ ├── prisma.ts # Prisma client singleton +│ └── services/ # Backend services +│ ├── netcup-service.ts # Netcup SCP API client +│ ├── dns-service.ts # DNS verification service +│ └── settings-service.ts # System settings storage └── types/ # TypeScript type definitions ``` @@ -61,17 +83,70 @@ POST /api/v1/admin/orders # Create order GET /api/v1/admin/orders/[id] # Get order detail PATCH /api/v1/admin/orders/[id] # Update order GET /api/v1/admin/orders/[id]/logs # Get provisioning logs (SSE) +POST /api/v1/admin/orders/[id]/provision # Start provisioning + +# DNS Verification +GET /api/v1/admin/orders/[id]/dns # Get DNS status +POST /api/v1/admin/orders/[id]/dns/verify # Trigger DNS verification +POST /api/v1/admin/orders/[id]/dns/skip # Manual DNS override # Servers GET /api/v1/admin/servers # List servers (derived from orders) +# Netcup Integration +GET /api/v1/admin/netcup/auth # Get auth status / poll for token +POST /api/v1/admin/netcup/auth # Initiate device auth flow +DELETE /api/v1/admin/netcup/auth # Disconnect Netcup account +GET /api/v1/admin/netcup/servers # List all Netcup servers +GET /api/v1/admin/netcup/servers/[id] # Get server detail +POST /api/v1/admin/netcup/servers/[id]/power # Power actions +POST /api/v1/admin/netcup/servers/[id]/rescue # Rescue mode +GET /api/v1/admin/netcup/servers/[id]/metrics # Performance metrics +GET /api/v1/admin/netcup/servers/[id]/snapshots # List snapshots +POST /api/v1/admin/netcup/servers/[id]/snapshots # Create snapshot + # Dashboard GET /api/v1/admin/dashboard/stats # Dashboard statistics ``` +## Netcup SCP Integration + +The Hub integrates with Netcup's Server Control Panel API for full server management. + +### Authentication +Uses OAuth2 Device Flow: +1. Hub initiates device auth, gets `user_code` and `verification_uri` +2. User visits Netcup and enters the code +3. Hub polls for token exchange +4. Tokens stored in `SystemSettings` table +5. Access tokens auto-refresh (5min expiry, offline refresh token) + +### Capabilities +- **Server List**: View all Netcup servers with live status +- **Power Control**: ON/OFF/POWERCYCLE/RESET/POWEROFF +- **Rescue Mode**: Activate/deactivate rescue system +- **Metrics**: CPU, disk I/O, network throughput (up to 30 days) +- **Snapshots**: Create, list, delete, revert snapshots +- **Server Linking**: Link orders to Netcup servers by IP + +### Key Service: `netcup-service.ts` +```typescript +// Core methods +netcupService.initiateDeviceAuth() // Start OAuth flow +netcupService.pollForToken(deviceCode) // Complete OAuth +netcupService.getServers() // List with IPs from interfaces +netcupService.getServer(id, liveInfo) // Detail with live status +netcupService.powerAction(id, action) // Power control +netcupService.getServerInterfaces(id) // Get IP addresses +netcupService.getAllMetrics(id, hours) // CPU/disk/network metrics +``` + ## Development Commands ```bash +# Start database (required first) +docker compose up -d + # Install dependencies npm install @@ -105,15 +180,27 @@ All data fetching uses React Query hooks in `src/hooks/`: - `useOrders()`, `useOrder(id)` - Order data - `useServers()` - Server list - `useDashboardStats()` - Dashboard metrics +- `useNetcupServers()`, `useNetcupServer(id)` - Netcup servers +- `useNetcupAuth()` - Netcup authentication status +- `useServerMetrics(id, hours)` - Server performance metrics +- `useServerSnapshots(id)` - Server snapshots Mutations follow the pattern: - `useCreateOrder()`, `useUpdateOrder()` +- `useNetcupPowerAction()`, `useNetcupRescue()` +- `useCreateSnapshot()`, `useDeleteSnapshot()`, `useRevertSnapshot()` - Automatic cache invalidation via `queryClient.invalidateQueries()` ### API Route Pattern ```typescript export async function GET(request: NextRequest) { + // Auth check + const session = await auth() + if (!session || session.user.userType !== 'staff') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + // Parse query params const searchParams = request.nextUrl.searchParams @@ -140,6 +227,59 @@ export function MyComponent() { } ``` +### Service Pattern + +Backend services in `src/lib/services/`: +```typescript +class MyService { + private static instance: MyService + + static getInstance(): MyService { + if (!MyService.instance) { + MyService.instance = new MyService() + } + return MyService.instance + } + + async doSomething(): Promise { + // Implementation + } +} + +export const myService = MyService.getInstance() +``` + +## Database Schema (Key Models) + +```prisma +model Customer { + id String @id @default(cuid()) + name String + email String @unique + company String? + orders Order[] +} + +model Order { + id String @id @default(cuid()) + status OrderStatus + domain String + customerId String + serverIp String? + serverPassword String? + netcupServerId String? # Linked Netcup server + automationMode AutomationMode @default(MANUAL) + customer Customer @relation(...) + dnsVerification DnsVerification? +} + +model SystemSettings { + id String @id @default(cuid()) + key String @unique + value String @db.Text +} +``` + ## Coding Conventions - Use `'use client'` directive for client components @@ -148,3 +288,5 @@ export function MyComponent() { - Follow existing shadcn/ui component patterns - Use React Query for server state management - TypeScript strict mode - no `any` types +- Services are singletons exported from `lib/services/` +- Environment variables in `.env.local` (never commit) diff --git a/Dockerfile b/Dockerfile index a9d1457..42a0bff 100644 --- a/Dockerfile +++ b/Dockerfile @@ -34,9 +34,21 @@ WORKDIR /app ENV NODE_ENV=production ENV NEXT_TELEMETRY_DISABLED=1 +# Install Docker CLI for spawning provisioning containers +RUN apk add --no-cache docker-cli + RUN addgroup --system --gid 1001 nodejs RUN adduser --system --uid 1001 nextjs +# Add nextjs user to docker group for socket access +# Note: The actual docker group GID might differ - using 999 as common default +RUN addgroup -g 999 docker || true +RUN addgroup nextjs docker || true + +# Create jobs and logs directories for provisioning +RUN mkdir -p /app/jobs /app/logs +RUN chown -R nextjs:nodejs /app/jobs /app/logs + # Create public directory and copy contents if they exist RUN mkdir -p public COPY --from=builder /app/public/. ./public/ diff --git a/docker-compose.yml b/docker-compose.yml index 9c29523..b909be5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -28,6 +28,25 @@ services: DATABASE_URL: postgresql://letsbe_hub:letsbe_hub_dev@db:5432/letsbe_hub NEXTAUTH_URL: http://localhost:3000 NEXTAUTH_SECRET: dev-secret-change-in-production-min-32-chars + AUTH_TRUST_HOST: "true" + HUB_URL: http://host.docker.internal:3000 + # Use local Docker images (no registry) + DOCKER_REGISTRY_URL: "" + # Encryption key for storing sensitive credentials (Portainer passwords, etc.) + CREDENTIAL_ENCRYPTION_KEY: letsbe-hub-credential-encryption-key-dev-only + # Encryption key for settings service (SMTP passwords, tokens, etc.) + SETTINGS_ENCRYPTION_KEY: letsbe-hub-settings-encryption-key-dev-only + # Host paths for job config files (used when spawning runner containers) + # On Windows with Docker Desktop, use /c/Repos/... format + JOBS_HOST_DIR: /c/Repos/LetsBeV2_NoAISysAdmin/letsbe-hub/jobs + LOGS_HOST_DIR: /c/Repos/LetsBeV2_NoAISysAdmin/letsbe-hub/logs + volumes: + - /var/run/docker.sock:/var/run/docker.sock + # Use bind mounts for jobs/logs so spawned runner containers can access them + - ./jobs:/app/jobs + - ./logs:/app/logs + # Run as root to access Docker socket (needed for spawning provisioning containers) + user: "0:0" depends_on: db: condition: service_healthy diff --git a/next.config.ts b/next.config.ts index a358a4a..e28ecae 100644 --- a/next.config.ts +++ b/next.config.ts @@ -2,6 +2,7 @@ import type { NextConfig } from 'next' const nextConfig: NextConfig = { output: 'standalone', + // reactCompiler: true, // Requires babel-plugin-react-compiler - enable later experimental: { serverActions: { bodySizeLimit: '2mb', @@ -15,6 +16,21 @@ const nextConfig: NextConfig = { }, ], }, + // Turbopack config (Next.js 16 default bundler) + turbopack: {}, + // Handle native modules like ssh2 (for webpack fallback) + webpack: (config, { isServer }) => { + if (isServer) { + // Externalize ssh2 and its native dependencies + config.externals = config.externals || [] + config.externals.push({ + 'ssh2': 'commonjs ssh2', + }) + } + return config + }, + // Externalize ssh2 for both Turbopack and Webpack + serverExternalPackages: ['ssh2'], } export default nextConfig diff --git a/package-lock.json b/package-lock.json index afc87fd..a90832b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,53 +9,70 @@ "version": "1.0.0", "dependencies": { "@auth/prisma-adapter": "^2.7.4", + "@aws-sdk/client-s3": "^3.968.0", "@hookform/resolvers": "^3.9.1", "@prisma/client": "^6.2.1", - "@radix-ui/react-avatar": "^1.1.2", + "@radix-ui/react-avatar": "^1.1.11", + "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.4", "@radix-ui/react-dropdown-menu": "^2.1.4", "@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-label": "^2.1.1", "@radix-ui/react-popover": "^1.1.4", "@radix-ui/react-select": "^2.1.4", - "@radix-ui/react-separator": "^1.1.1", + "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.1.1", - "@radix-ui/react-tabs": "^1.1.2", + "@radix-ui/react-switch": "^1.2.6", + "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-toast": "^1.2.4", "@radix-ui/react-tooltip": "^1.1.6", "@tanstack/react-query": "^5.64.2", "@tanstack/react-table": "^8.20.6", + "@types/ssh2": "^1.15.5", "bcryptjs": "^2.4.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "date-fns": "^4.1.0", "lucide-react": "^0.469.0", - "next": "15.1.4", - "next-auth": "5.0.0-beta.25", + "next": "16.1.1", + "next-auth": "5.0.0-beta.30", "next-themes": "^0.4.4", - "react": "^19.0.0", - "react-dom": "^19.0.0", + "nodemailer": "^7.0.12", + "otplib": "^13.1.0", + "qrcode": "^1.5.4", + "react": "19.2.3", + "react-dom": "19.2.3", "react-hook-form": "^7.54.2", + "recharts": "^3.6.0", + "ssh2": "^1.17.0", "tailwind-merge": "^2.6.0", "tailwindcss-animate": "^1.0.7", + "undici": "^7.18.2", "zod": "^3.24.1", "zustand": "^5.0.3" }, "devDependencies": { "@eslint/eslintrc": "^3.3.3", + "@testing-library/react": "^16.3.1", "@types/bcryptjs": "^2.4.6", "@types/node": "^22.10.5", + "@types/nodemailer": "^7.0.5", + "@types/qrcode": "^1.5.6", "@types/react": "^19.0.4", "@types/react-dom": "^19.0.2", + "@vitejs/plugin-react": "^5.1.2", "autoprefixer": "^10.4.20", "eslint": "^9.17.0", - "eslint-config-next": "15.1.4", + "eslint-config-next": "16.1.1", + "jsdom": "^27.0.1", "postcss": "^8.4.49", "prettier": "^3.4.2", "prisma": "^6.2.1", "tailwindcss": "^3.4.17", "tsx": "^4.19.2", "typescript": "^5.7.3", - "vitest": "^2.1.8" + "vitest": "^4.0.16", + "vitest-mock-extended": "^3.1.0" } }, "node_modules/@alloc/quick-lru": { @@ -70,6 +87,61 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.1.tgz", + "integrity": "sha512-B0Hv6G3gWGMn0xKJ0txEi/jM5iFpT3MfDxmhZFb4W047GvytCf1DHQ1D69W3zHI4yWe2aTZAA0JnbMZ7Xc8DuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.4", + "@csstools/css-color-parser": "^3.1.0", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "lru-cache": "^11.2.4" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.2.4", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", + "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.7.6", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.6.tgz", + "integrity": "sha512-hBaJER6A9MpdG3WgdlOolHmbOYvSk46y7IQN/1+iqiCuUu6iWdQrs9DGKF8ocqsEqWujWf/V7b7vaDgiUmIvUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.4" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.2.4", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", + "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@auth/core": { "version": "0.41.1", "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.41.1.tgz", @@ -111,6 +183,1416 @@ "@prisma/client": ">=2.26.0 || >=3 || >=4 || >=5 || >=6" } }, + "node_modules/@aws-crypto/crc32": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", + "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/crc32c": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32c/-/crc32c-5.2.0.tgz", + "integrity": "sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha1-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha1-browser/-/sha1-browser-5.2.0.tgz", + "integrity": "sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", + "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", + "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-s3": { + "version": "3.968.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.968.0.tgz", + "integrity": "sha512-YQARjiiucSkaSLS0HNyexOQzYM5pPRWSo+FNtq5JSuXwJQb8vs53JeZfk7yKb59G94Oh0BLAv1598XaEdtAFyA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha1-browser": "5.2.0", + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.968.0", + "@aws-sdk/credential-provider-node": "3.968.0", + "@aws-sdk/middleware-bucket-endpoint": "3.968.0", + "@aws-sdk/middleware-expect-continue": "3.968.0", + "@aws-sdk/middleware-flexible-checksums": "3.968.0", + "@aws-sdk/middleware-host-header": "3.968.0", + "@aws-sdk/middleware-location-constraint": "3.968.0", + "@aws-sdk/middleware-logger": "3.968.0", + "@aws-sdk/middleware-recursion-detection": "3.968.0", + "@aws-sdk/middleware-sdk-s3": "3.968.0", + "@aws-sdk/middleware-ssec": "3.968.0", + "@aws-sdk/middleware-user-agent": "3.968.0", + "@aws-sdk/region-config-resolver": "3.968.0", + "@aws-sdk/signature-v4-multi-region": "3.968.0", + "@aws-sdk/types": "3.968.0", + "@aws-sdk/util-endpoints": "3.968.0", + "@aws-sdk/util-user-agent-browser": "3.968.0", + "@aws-sdk/util-user-agent-node": "3.968.0", + "@smithy/config-resolver": "^4.4.5", + "@smithy/core": "^3.20.3", + "@smithy/eventstream-serde-browser": "^4.2.7", + "@smithy/eventstream-serde-config-resolver": "^4.3.7", + "@smithy/eventstream-serde-node": "^4.2.7", + "@smithy/fetch-http-handler": "^5.3.8", + "@smithy/hash-blob-browser": "^4.2.8", + "@smithy/hash-node": "^4.2.7", + "@smithy/hash-stream-node": "^4.2.7", + "@smithy/invalid-dependency": "^4.2.7", + "@smithy/md5-js": "^4.2.7", + "@smithy/middleware-content-length": "^4.2.7", + "@smithy/middleware-endpoint": "^4.4.4", + "@smithy/middleware-retry": "^4.4.20", + "@smithy/middleware-serde": "^4.2.8", + "@smithy/middleware-stack": "^4.2.7", + "@smithy/node-config-provider": "^4.3.7", + "@smithy/node-http-handler": "^4.4.7", + "@smithy/protocol-http": "^5.3.7", + "@smithy/smithy-client": "^4.10.5", + "@smithy/types": "^4.11.0", + "@smithy/url-parser": "^4.2.7", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.19", + "@smithy/util-defaults-mode-node": "^4.2.22", + "@smithy/util-endpoints": "^3.2.7", + "@smithy/util-middleware": "^4.2.7", + "@smithy/util-retry": "^4.2.7", + "@smithy/util-stream": "^4.5.8", + "@smithy/util-utf8": "^4.2.0", + "@smithy/util-waiter": "^4.2.7", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-sesv2": { + "version": "3.968.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sesv2/-/client-sesv2-3.968.0.tgz", + "integrity": "sha512-vuzF/4Ovzv2UW2iVVMNSu3yIIczzdUKBkkiXTvYYRmOL4Kjtq7RLu8A8O6jy+/mJoWW1CTyZH9pTc4MCQzjLIA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.968.0", + "@aws-sdk/credential-provider-node": "3.968.0", + "@aws-sdk/middleware-host-header": "3.968.0", + "@aws-sdk/middleware-logger": "3.968.0", + "@aws-sdk/middleware-recursion-detection": "3.968.0", + "@aws-sdk/middleware-user-agent": "3.968.0", + "@aws-sdk/region-config-resolver": "3.968.0", + "@aws-sdk/signature-v4-multi-region": "3.968.0", + "@aws-sdk/types": "3.968.0", + "@aws-sdk/util-endpoints": "3.968.0", + "@aws-sdk/util-user-agent-browser": "3.968.0", + "@aws-sdk/util-user-agent-node": "3.968.0", + "@smithy/config-resolver": "^4.4.5", + "@smithy/core": "^3.20.3", + "@smithy/fetch-http-handler": "^5.3.8", + "@smithy/hash-node": "^4.2.7", + "@smithy/invalid-dependency": "^4.2.7", + "@smithy/middleware-content-length": "^4.2.7", + "@smithy/middleware-endpoint": "^4.4.4", + "@smithy/middleware-retry": "^4.4.20", + "@smithy/middleware-serde": "^4.2.8", + "@smithy/middleware-stack": "^4.2.7", + "@smithy/node-config-provider": "^4.3.7", + "@smithy/node-http-handler": "^4.4.7", + "@smithy/protocol-http": "^5.3.7", + "@smithy/smithy-client": "^4.10.5", + "@smithy/types": "^4.11.0", + "@smithy/url-parser": "^4.2.7", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.19", + "@smithy/util-defaults-mode-node": "^4.2.22", + "@smithy/util-endpoints": "^3.2.7", + "@smithy/util-middleware": "^4.2.7", + "@smithy/util-retry": "^4.2.7", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-sso": { + "version": "3.968.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.968.0.tgz", + "integrity": "sha512-y+k23MvMzpn1WpeQ9sdEXg1Bbw7dfi0ZH2uwyBv78F/kz0mZOI+RJ1KJg8DgSD8XvdxB8gX5GQ8rzo0LnDothA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.968.0", + "@aws-sdk/middleware-host-header": "3.968.0", + "@aws-sdk/middleware-logger": "3.968.0", + "@aws-sdk/middleware-recursion-detection": "3.968.0", + "@aws-sdk/middleware-user-agent": "3.968.0", + "@aws-sdk/region-config-resolver": "3.968.0", + "@aws-sdk/types": "3.968.0", + "@aws-sdk/util-endpoints": "3.968.0", + "@aws-sdk/util-user-agent-browser": "3.968.0", + "@aws-sdk/util-user-agent-node": "3.968.0", + "@smithy/config-resolver": "^4.4.5", + "@smithy/core": "^3.20.3", + "@smithy/fetch-http-handler": "^5.3.8", + "@smithy/hash-node": "^4.2.7", + "@smithy/invalid-dependency": "^4.2.7", + "@smithy/middleware-content-length": "^4.2.7", + "@smithy/middleware-endpoint": "^4.4.4", + "@smithy/middleware-retry": "^4.4.20", + "@smithy/middleware-serde": "^4.2.8", + "@smithy/middleware-stack": "^4.2.7", + "@smithy/node-config-provider": "^4.3.7", + "@smithy/node-http-handler": "^4.4.7", + "@smithy/protocol-http": "^5.3.7", + "@smithy/smithy-client": "^4.10.5", + "@smithy/types": "^4.11.0", + "@smithy/url-parser": "^4.2.7", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.19", + "@smithy/util-defaults-mode-node": "^4.2.22", + "@smithy/util-endpoints": "^3.2.7", + "@smithy/util-middleware": "^4.2.7", + "@smithy/util-retry": "^4.2.7", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.968.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.968.0.tgz", + "integrity": "sha512-u4lIpvGqMMHZN523/RxW70xNoVXHBXucIWZsxFKc373E6TWYEb16ddFhXTELioS5TU93qkd/6yDQZzI6AAhbkw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.968.0", + "@aws-sdk/xml-builder": "3.968.0", + "@smithy/core": "^3.20.3", + "@smithy/node-config-provider": "^4.3.7", + "@smithy/property-provider": "^4.2.7", + "@smithy/protocol-http": "^5.3.7", + "@smithy/signature-v4": "^5.3.7", + "@smithy/smithy-client": "^4.10.5", + "@smithy/types": "^4.11.0", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-middleware": "^4.2.7", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/crc64-nvme": { + "version": "3.968.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/crc64-nvme/-/crc64-nvme-3.968.0.tgz", + "integrity": "sha512-buylEu7i7I42uzfnQlu0oY35GAWcslU+Vyu9mlNszDKEDwsSyFDy1wg0wQ4vPyKDHlwsIm1srGa/MIaxZk1msg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.968.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.968.0.tgz", + "integrity": "sha512-G+zgXEniQxBHFtHo+0yImkYutvJZLvWqvkPUP8/cG+IaYg54OY7L/GPIAZJh0U3m0Uepao98NbL15zjM+uplqQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.968.0", + "@aws-sdk/types": "3.968.0", + "@smithy/property-provider": "^4.2.7", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.968.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.968.0.tgz", + "integrity": "sha512-79teHBx/EtsNRR3Bq8fQdmMHtUcYwvohm9EwXXFt2Jd3BEOBH872IjIlfKdAvdkM+jW1QeeWOZBAxXGPir7GcQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.968.0", + "@aws-sdk/types": "3.968.0", + "@smithy/fetch-http-handler": "^5.3.8", + "@smithy/node-http-handler": "^4.4.7", + "@smithy/property-provider": "^4.2.7", + "@smithy/protocol-http": "^5.3.7", + "@smithy/smithy-client": "^4.10.5", + "@smithy/types": "^4.11.0", + "@smithy/util-stream": "^4.5.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.968.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.968.0.tgz", + "integrity": "sha512-9J9pcweoEN8yG7Qliux1zl9J3DT8X6OLcDN2RVXdTd5xzWBaYlupnUiJzoP6lvXdMnEmlDZaV7IMtoBdG7MY6g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.968.0", + "@aws-sdk/credential-provider-env": "3.968.0", + "@aws-sdk/credential-provider-http": "3.968.0", + "@aws-sdk/credential-provider-login": "3.968.0", + "@aws-sdk/credential-provider-process": "3.968.0", + "@aws-sdk/credential-provider-sso": "3.968.0", + "@aws-sdk/credential-provider-web-identity": "3.968.0", + "@aws-sdk/nested-clients": "3.968.0", + "@aws-sdk/types": "3.968.0", + "@smithy/credential-provider-imds": "^4.2.7", + "@smithy/property-provider": "^4.2.7", + "@smithy/shared-ini-file-loader": "^4.4.2", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.968.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.968.0.tgz", + "integrity": "sha512-YxBaR0IMuHPOVTG+73Ve0QfllweN+EdwBRnHFhUGnahMGAcTmcaRdotqwqWfiws+9ud44IFKjxXR3t8jaGpFnQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.968.0", + "@aws-sdk/nested-clients": "3.968.0", + "@aws-sdk/types": "3.968.0", + "@smithy/property-provider": "^4.2.7", + "@smithy/protocol-http": "^5.3.7", + "@smithy/shared-ini-file-loader": "^4.4.2", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.968.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.968.0.tgz", + "integrity": "sha512-wei6v0c9vDEam8pM5eWe9bt+5ixg8nL0q+DFPzI6iwdLUqmJsPoAzWPEyMkgp03iE02SS2fMqPWpmRjz/NVyUw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "3.968.0", + "@aws-sdk/credential-provider-http": "3.968.0", + "@aws-sdk/credential-provider-ini": "3.968.0", + "@aws-sdk/credential-provider-process": "3.968.0", + "@aws-sdk/credential-provider-sso": "3.968.0", + "@aws-sdk/credential-provider-web-identity": "3.968.0", + "@aws-sdk/types": "3.968.0", + "@smithy/credential-provider-imds": "^4.2.7", + "@smithy/property-provider": "^4.2.7", + "@smithy/shared-ini-file-loader": "^4.4.2", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.968.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.968.0.tgz", + "integrity": "sha512-my9M/ijRyEACoyeEWiC2sTVM3+eck5IWPGTPQrlYMKivy4LLlZchohtIopuqTom+JZzLZD508j1s9aDvl7BA0w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.968.0", + "@aws-sdk/types": "3.968.0", + "@smithy/property-provider": "^4.2.7", + "@smithy/shared-ini-file-loader": "^4.4.2", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.968.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.968.0.tgz", + "integrity": "sha512-XPYPcxfWIt5jBbofoP2xhAHlFYos0dzwbHsoE18Cera/XnaCEbsUpdROo30t0Kjdbv0EWMYLMPDi9G+vPRDnhQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/client-sso": "3.968.0", + "@aws-sdk/core": "3.968.0", + "@aws-sdk/token-providers": "3.968.0", + "@aws-sdk/types": "3.968.0", + "@smithy/property-provider": "^4.2.7", + "@smithy/shared-ini-file-loader": "^4.4.2", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.968.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.968.0.tgz", + "integrity": "sha512-9HNAP6mx2jsBW4moWnRg5ycyZ0C1EbtMIegIHa93ga13B/8VZF9Y0iDnwW73yQYzCEt9UrDiFeRck/ChZup3rA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.968.0", + "@aws-sdk/nested-clients": "3.968.0", + "@aws-sdk/types": "3.968.0", + "@smithy/property-provider": "^4.2.7", + "@smithy/shared-ini-file-loader": "^4.4.2", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-bucket-endpoint": { + "version": "3.968.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.968.0.tgz", + "integrity": "sha512-KlA6D9wgyGF3KkKIRmmXxvKfzzGkibnnR6Kjp0NQAOi4jvKWuT/HKJX87sBJIrk8RWq+9Aq0SOY9LYqkdx9zJQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.968.0", + "@aws-sdk/util-arn-parser": "3.968.0", + "@smithy/node-config-provider": "^4.3.7", + "@smithy/protocol-http": "^5.3.7", + "@smithy/types": "^4.11.0", + "@smithy/util-config-provider": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-expect-continue": { + "version": "3.968.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-expect-continue/-/middleware-expect-continue-3.968.0.tgz", + "integrity": "sha512-VCcDw21JCJywZH8+vpZCsVB9HV2BQ6BdF+cXww5nKnPNi+d05sHFczRHUQjfsEJiZ8Wb/a4M3mJuVrQ5gjiNUA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.968.0", + "@smithy/protocol-http": "^5.3.7", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-flexible-checksums": { + "version": "3.968.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.968.0.tgz", + "integrity": "sha512-5G4hpKS0XbU8s3WuuFP6qpB6kkFB45LQ2VomrS0FoyTXH9XUDYL1OmwraBe3t2N5LnpqOh1+RAJOyO8gRwO7xA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@aws-crypto/crc32c": "5.2.0", + "@aws-crypto/util": "5.2.0", + "@aws-sdk/core": "3.968.0", + "@aws-sdk/crc64-nvme": "3.968.0", + "@aws-sdk/types": "3.968.0", + "@smithy/is-array-buffer": "^4.2.0", + "@smithy/node-config-provider": "^4.3.7", + "@smithy/protocol-http": "^5.3.7", + "@smithy/types": "^4.11.0", + "@smithy/util-middleware": "^4.2.7", + "@smithy/util-stream": "^4.5.8", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-host-header": { + "version": "3.968.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.968.0.tgz", + "integrity": "sha512-ujlNT215VtE/2D2jEhFVcTuPPB36HJyLBM0ytnni/WPIjzq89iJrKR1tEhxpk8uct6A5NSQ6w9Y7g2Rw1rkSoQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.968.0", + "@smithy/protocol-http": "^5.3.7", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-location-constraint": { + "version": "3.968.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-location-constraint/-/middleware-location-constraint-3.968.0.tgz", + "integrity": "sha512-+usAEX4rPmOofmLhZHgnRvW3idDnXdYnhaiOjfj2ynU05elTUkF2b4fyq+KhdjZQVbUpCewq4eKqgjGaGhIyyw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.968.0", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-logger": { + "version": "3.968.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.968.0.tgz", + "integrity": "sha512-zvhhEPZgvaRDxzf27m2WmgaXoN7upFt/gvG7ofBN5zCBlkh3JtFamMh5KWYVQwMhc4eQBK3NjH0oIUKZSVztag==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.968.0", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-recursion-detection": { + "version": "3.968.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.968.0.tgz", + "integrity": "sha512-KygPiwpSAPGobgodK/oLb7OLiwK29pNJeNtP+GZ9pxpceDRqhN0Ub8Eo84dBbWq+jbzAqBYHzy+B1VsbQ/hLWA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.968.0", + "@aws/lambda-invoke-store": "^0.2.2", + "@smithy/protocol-http": "^5.3.7", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-sdk-s3": { + "version": "3.968.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.968.0.tgz", + "integrity": "sha512-fh2mQ/uwJ1Sth1q2dWAbeyky/SBPaqe1fjxvsNeEY6dtfi8PjW85zHpz1JoAhCKTRkrEdXYAqkqUwsUydLucyQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.968.0", + "@aws-sdk/types": "3.968.0", + "@aws-sdk/util-arn-parser": "3.968.0", + "@smithy/core": "^3.20.3", + "@smithy/node-config-provider": "^4.3.7", + "@smithy/protocol-http": "^5.3.7", + "@smithy/signature-v4": "^5.3.7", + "@smithy/smithy-client": "^4.10.5", + "@smithy/types": "^4.11.0", + "@smithy/util-config-provider": "^4.2.0", + "@smithy/util-middleware": "^4.2.7", + "@smithy/util-stream": "^4.5.8", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-ssec": { + "version": "3.968.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-ssec/-/middleware-ssec-3.968.0.tgz", + "integrity": "sha512-gbrhJ/JrKJ48SDPtlt5jPOadiPl2Rae0VLuNRyNg0ng7ygRO/0NjgKME4D1XINDjMOiZsOLNAcXmmwGFsVZsyw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.968.0", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-user-agent": { + "version": "3.968.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.968.0.tgz", + "integrity": "sha512-4h5/B8FyxMjLxtXd5jbM2R69aO57qQiHoAJQTtkpuxmM7vhvjSxEQtMM9L1kuMXoMVNE7xM4886h0+gbmmxplg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.968.0", + "@aws-sdk/types": "3.968.0", + "@aws-sdk/util-endpoints": "3.968.0", + "@smithy/core": "^3.20.3", + "@smithy/protocol-http": "^5.3.7", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.968.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.968.0.tgz", + "integrity": "sha512-LLppm+8MzD3afD2IA/tYDp5AoVPOybK7MHQz5DVB4HsZ+fHvwYlvau2ZUK8nKwJSk5c1kWcxCZkyuJQjFu37ng==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.968.0", + "@aws-sdk/middleware-host-header": "3.968.0", + "@aws-sdk/middleware-logger": "3.968.0", + "@aws-sdk/middleware-recursion-detection": "3.968.0", + "@aws-sdk/middleware-user-agent": "3.968.0", + "@aws-sdk/region-config-resolver": "3.968.0", + "@aws-sdk/types": "3.968.0", + "@aws-sdk/util-endpoints": "3.968.0", + "@aws-sdk/util-user-agent-browser": "3.968.0", + "@aws-sdk/util-user-agent-node": "3.968.0", + "@smithy/config-resolver": "^4.4.5", + "@smithy/core": "^3.20.3", + "@smithy/fetch-http-handler": "^5.3.8", + "@smithy/hash-node": "^4.2.7", + "@smithy/invalid-dependency": "^4.2.7", + "@smithy/middleware-content-length": "^4.2.7", + "@smithy/middleware-endpoint": "^4.4.4", + "@smithy/middleware-retry": "^4.4.20", + "@smithy/middleware-serde": "^4.2.8", + "@smithy/middleware-stack": "^4.2.7", + "@smithy/node-config-provider": "^4.3.7", + "@smithy/node-http-handler": "^4.4.7", + "@smithy/protocol-http": "^5.3.7", + "@smithy/smithy-client": "^4.10.5", + "@smithy/types": "^4.11.0", + "@smithy/url-parser": "^4.2.7", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.19", + "@smithy/util-defaults-mode-node": "^4.2.22", + "@smithy/util-endpoints": "^3.2.7", + "@smithy/util-middleware": "^4.2.7", + "@smithy/util-retry": "^4.2.7", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/region-config-resolver": { + "version": "3.968.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.968.0.tgz", + "integrity": "sha512-BzrCpxEsAHbi+yDGtgXJ+/5AvLPjfhcT6DlL+Fc4g13J5Z0VwiO95Wem+Q4KK7WDZH7/sZ/1WFvfitjLTKZbEw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.968.0", + "@smithy/config-resolver": "^4.4.5", + "@smithy/node-config-provider": "^4.3.7", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.968.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.968.0.tgz", + "integrity": "sha512-kRBA1KK3LTHnfYJLPsESNF2WhQN6DyGc9MiM6qG8AdJwMPQkanF5hwtckV1ToO2KB5v1q+1PuvBvy6Npd2IV+w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-sdk-s3": "3.968.0", + "@aws-sdk/types": "3.968.0", + "@smithy/protocol-http": "^5.3.7", + "@smithy/signature-v4": "^5.3.7", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.968.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.968.0.tgz", + "integrity": "sha512-lXUZqB2qTFmZYNXPnVT0suSHGiuQAPrL2DhmhbjqOdR7+GKDHL5KbeKFvPisy7Y4neliJqT4Q1VPWa0nqYaiZg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.968.0", + "@aws-sdk/nested-clients": "3.968.0", + "@aws-sdk/types": "3.968.0", + "@smithy/property-provider": "^4.2.7", + "@smithy/shared-ini-file-loader": "^4.4.2", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.968.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.968.0.tgz", + "integrity": "sha512-Wuumj/1cuiuXTMdHmvH88zbEl+5Pw++fOFQuMCF4yP0R+9k1lwX8rVst+oy99xaxtdluJZXrsccoZoA67ST1Ow==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-arn-parser": { + "version": "3.968.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-arn-parser/-/util-arn-parser-3.968.0.tgz", + "integrity": "sha512-gqqvYcitIIM2K4lrDX9de9YvOfXBcVdxfT/iLnvHJd4YHvSXlt+gs+AsL4FfPCxG4IG9A+FyulP9Sb1MEA75vw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-endpoints": { + "version": "3.968.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.968.0.tgz", + "integrity": "sha512-9IdilgylS0crFSeI59vtr8qhDYMYYOvnvkl1dLp59+EmLH1IdXz7+4cR5oh5PkoqD7DRzc5Uzm2GnZhK6I0oVQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.968.0", + "@smithy/types": "^4.11.0", + "@smithy/url-parser": "^4.2.7", + "@smithy/util-endpoints": "^3.2.7", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-locate-window": { + "version": "3.965.2", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.2.tgz", + "integrity": "sha512-qKgO7wAYsXzhwCHhdbaKFyxd83Fgs8/1Ka+jjSPrv2Ll7mB55Wbwlo0kkfMLh993/yEc8aoDIAc1Fz9h4Spi4Q==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-user-agent-browser": { + "version": "3.968.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.968.0.tgz", + "integrity": "sha512-nRxjs8Jpq8ZHFsa/0uiww2f4+40D6Dt6bQmepAJHIE/D+atwPINDKsfamCjFnxrjKU3WBWpGYEf/QDO0XZsFMw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.968.0", + "@smithy/types": "^4.11.0", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/util-user-agent-node": { + "version": "3.968.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.968.0.tgz", + "integrity": "sha512-oaIkPGraGhZgkDmxVhTIlakaUNWKO9aMN+uB6I+eS26MWi/lpMK66HTZeXEnaTrmt5/kl99YC0N37zScz58Tdg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-user-agent": "3.968.0", + "@aws-sdk/types": "3.968.0", + "@smithy/node-config-provider": "^4.3.7", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "aws-crt": ">=1.0.0" + }, + "peerDependenciesMeta": { + "aws-crt": { + "optional": true + } + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.968.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.968.0.tgz", + "integrity": "sha512-bZQKn41ebPh/uW9uWUE5oLuaBr44Gt78dkw2amu5zcwo1J/d8s6FdzZcRDmz0rHE2NHJWYkdQYeVQo7jhMziqA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.11.0", + "fast-xml-parser": "5.2.5", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.3.tgz", + "integrity": "sha512-oLvsaPMTBejkkmHhjf09xTgk71mOqyr/409NKhRIL08If7AhVfUsJhVsx386uJaqNd42v9kWamQ9lFbkoC2dYw==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", + "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", + "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", + "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", + "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.5", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.0.25", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.25.tgz", + "integrity": "sha512-g0Kw9W3vjx5BEBAF8c5Fm2NcB/Fs8jJXh85aXqwEXiL+tqtOut07TWgyaGzAAfTM+gKckrrncyeGEZPcaRgm2Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@emnapi/core": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", @@ -829,10 +2311,20 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@img/colour": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", + "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", - "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", "cpu": [ "arm64" ], @@ -848,13 +2340,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.0.4" + "@img/sharp-libvips-darwin-arm64": "1.2.4" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", - "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", "cpu": [ "x64" ], @@ -870,13 +2362,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.0.4" + "@img/sharp-libvips-darwin-x64": "1.2.4" } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", - "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", "cpu": [ "arm64" ], @@ -890,9 +2382,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", - "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", "cpu": [ "x64" ], @@ -906,9 +2398,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", - "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", "cpu": [ "arm" ], @@ -922,9 +2414,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", - "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", "cpu": [ "arm64" ], @@ -937,10 +2429,42 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", - "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", "cpu": [ "s390x" ], @@ -954,9 +2478,9 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", - "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", "cpu": [ "x64" ], @@ -970,9 +2494,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", - "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", "cpu": [ "arm64" ], @@ -986,9 +2510,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", - "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", "cpu": [ "x64" ], @@ -1002,9 +2526,9 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", - "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", "cpu": [ "arm" ], @@ -1020,13 +2544,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.0.5" + "@img/sharp-libvips-linux-arm": "1.2.4" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", - "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", "cpu": [ "arm64" ], @@ -1042,13 +2566,57 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.0.4" + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", - "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", "cpu": [ "s390x" ], @@ -1064,13 +2632,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.0.4" + "@img/sharp-libvips-linux-s390x": "1.2.4" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", - "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", "cpu": [ "x64" ], @@ -1086,13 +2654,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.0.4" + "@img/sharp-libvips-linux-x64": "1.2.4" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", - "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", "cpu": [ "arm64" ], @@ -1108,13 +2676,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", - "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", "cpu": [ "x64" ], @@ -1130,20 +2698,20 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.0.4" + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", - "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", "cpu": [ "wasm32" ], "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { - "@emnapi/runtime": "^1.2.0" + "@emnapi/runtime": "^1.7.0" }, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" @@ -1152,10 +2720,29 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", - "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", "cpu": [ "ia32" ], @@ -1172,9 +2759,9 @@ } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", - "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", "cpu": [ "x64" ], @@ -1200,6 +2787,17 @@ "@jridgewell/trace-mapping": "^0.3.24" } }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -1239,15 +2837,15 @@ } }, "node_modules/@next/env": { - "version": "15.1.4", - "resolved": "https://registry.npmjs.org/@next/env/-/env-15.1.4.tgz", - "integrity": "sha512-2fZ5YZjedi5AGaeoaC0B20zGntEHRhi2SdWcu61i48BllODcAmmtj8n7YarSPt4DaTsJaBFdxQAVEVzgmx2Zpw==", + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.1.tgz", + "integrity": "sha512-3oxyM97Sr2PqiVyMyrZUtrtM3jqqFxOQJVuKclDsgj/L728iZt/GyslkN4NwarledZATCenbk4Offjk1hQmaAA==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { - "version": "15.1.4", - "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-15.1.4.tgz", - "integrity": "sha512-HwlEXwCK3sr6zmVGEvWBjW9tBFs1Oe6hTmTLoFQtpm4As5HCdu8jfSE0XJOp7uhfEGLniIx8yrGxEWwNnY0fmQ==", + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.1.1.tgz", + "integrity": "sha512-Ovb/6TuLKbE1UiPcg0p39Ke3puyTCIKN9hGbNItmpQsp+WX3qrjO3WaMVSi6JHr9X1NrmthqIguVHodMJbh/dw==", "dev": true, "license": "MIT", "dependencies": { @@ -1255,9 +2853,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "15.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.1.4.tgz", - "integrity": "sha512-wBEMBs+np+R5ozN1F8Y8d/Dycns2COhRnkxRc+rvnbXke5uZBHkUGFgWxfTXn5rx7OLijuUhyfB+gC/ap58dDw==", + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.1.tgz", + "integrity": "sha512-JS3m42ifsVSJjSTzh27nW+Igfha3NdBOFScr9C80hHGrWx55pTrVL23RJbqir7k7/15SKlrLHhh/MQzqBBYrQA==", "cpu": [ "arm64" ], @@ -1271,9 +2869,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "15.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.1.4.tgz", - "integrity": "sha512-7sgf5rM7Z81V9w48F02Zz6DgEJulavC0jadab4ZsJ+K2sxMNK0/BtF8J8J3CxnsJN3DGcIdC260wEKssKTukUw==", + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.1.tgz", + "integrity": "sha512-hbyKtrDGUkgkyQi1m1IyD3q4I/3m9ngr+V93z4oKHrPcmxwNL5iMWORvLSGAf2YujL+6HxgVvZuCYZfLfb4bGw==", "cpu": [ "x64" ], @@ -1287,9 +2885,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "15.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.1.4.tgz", - "integrity": "sha512-JaZlIMNaJenfd55kjaLWMfok+vWBlcRxqnRoZrhFQrhM1uAehP3R0+Aoe+bZOogqlZvAz53nY/k3ZyuKDtT2zQ==", + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.1.tgz", + "integrity": "sha512-/fvHet+EYckFvRLQ0jPHJCUI5/B56+2DpI1xDSvi80r/3Ez+Eaa2Yq4tJcRTaB1kqj/HrYKn8Yplm9bNoMJpwQ==", "cpu": [ "arm64" ], @@ -1303,9 +2901,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "15.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.1.4.tgz", - "integrity": "sha512-7EBBjNoyTO2ipMDgCiORpwwOf5tIueFntKjcN3NK+GAQD7OzFJe84p7a2eQUeWdpzZvhVXuAtIen8QcH71ZCOQ==", + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.1.tgz", + "integrity": "sha512-MFHrgL4TXNQbBPzkKKur4Fb5ICEJa87HM7fczFs2+HWblM7mMLdco3dvyTI+QmLBU9xgns/EeeINSZD6Ar+oLg==", "cpu": [ "arm64" ], @@ -1319,9 +2917,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "15.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.1.4.tgz", - "integrity": "sha512-9TGEgOycqZFuADyFqwmK/9g6S0FYZ3tphR4ebcmCwhL8Y12FW8pIBKJvSwV+UBjMkokstGNH+9F8F031JZKpHw==", + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.1.tgz", + "integrity": "sha512-20bYDfgOQAPUkkKBnyP9PTuHiJGM7HzNBbuqmD0jiFVZ0aOldz+VnJhbxzjcSabYsnNjMPsE0cyzEudpYxsrUQ==", "cpu": [ "x64" ], @@ -1335,9 +2933,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "15.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.1.4.tgz", - "integrity": "sha512-0578bLRVDJOh+LdIoKvgNDz77+Bd85c5JrFgnlbI1SM3WmEQvsjxTA8ATu9Z9FCiIS/AliVAW2DV/BDwpXbtiQ==", + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.1.tgz", + "integrity": "sha512-9pRbK3M4asAHQRkwaXwu601oPZHghuSC8IXNENgbBSyImHv/zY4K5udBusgdHkvJ/Tcr96jJwQYOll0qU8+fPA==", "cpu": [ "x64" ], @@ -1351,9 +2949,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "15.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.1.4.tgz", - "integrity": "sha512-JgFCiV4libQavwII+kncMCl30st0JVxpPOtzWcAI2jtum4HjYaclobKhj+JsRu5tFqMtA5CJIa0MvYyuu9xjjQ==", + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.1.tgz", + "integrity": "sha512-bdfQkggaLgnmYrFkSQfsHfOhk/mCYmjnrbRCGgkMcoOBZ4n+TRRSLmT/CU5SATzlBJ9TpioUyBW/vWFXTqQRiA==", "cpu": [ "arm64" ], @@ -1367,9 +2965,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "15.1.4", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.1.4.tgz", - "integrity": "sha512-xxsJy9wzq7FR5SqPCUqdgSXiNXrMuidgckBa8nH9HtjjxsilgcN6VgXF6tZ3uEWuVEadotQJI8/9EQ6guTC4Yw==", + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.1.tgz", + "integrity": "sha512-Ncwbw2WJ57Al5OX0k4chM68DKhEPlrXBaSXDCi2kPi5f4d8b3ejr3RRJGfKBLrn2YJL5ezNS7w2TZLHSti8CMw==", "cpu": [ "x64" ], @@ -1382,6 +2980,18 @@ "node": ">= 10" } }, + "node_modules/@noble/hashes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1427,6 +3037,62 @@ "node": ">=12.4.0" } }, + "node_modules/@otplib/core": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/@otplib/core/-/core-13.1.0.tgz", + "integrity": "sha512-bMCuRi5U4m86JnKiijEPTyM1KQlmanlcg/uleTCo4ewtqsYgnIvdNqIOUA6x1cS4X+U9XalmX6zF1N4ddZBdoA==", + "license": "MIT" + }, + "node_modules/@otplib/hotp": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/@otplib/hotp/-/hotp-13.1.0.tgz", + "integrity": "sha512-lqeRLsUwpJ5l4RzhgSOw5uNmY19RayIf3SBnghHnP7uPhA0VccgaVzfk7QliiCkClkhyTpx4T+DTrgdnseOXEg==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.1.0", + "@otplib/uri": "13.1.0" + } + }, + "node_modules/@otplib/plugin-base32-scure": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/@otplib/plugin-base32-scure/-/plugin-base32-scure-13.1.0.tgz", + "integrity": "sha512-bCyIZd9vZ4qoBErW4LzdyI1L3naKLGY1GuxseriN/Pb4gKlOXKpSmP6i/XK6E1CpQ1GtAd7P6TkcP9CyNDIFWg==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.1.0", + "@scure/base": "^2.0.0" + } + }, + "node_modules/@otplib/plugin-crypto-noble": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/@otplib/plugin-crypto-noble/-/plugin-crypto-noble-13.1.0.tgz", + "integrity": "sha512-/8mIOQSK08gccqpgGK6cW45he0YRL/eSl4QVSfLlaXhtBJZz78UEr/gnzIhIy9ftVgS9jZDsSDMWnozCasQYww==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^2.0.1", + "@otplib/core": "13.1.0" + } + }, + "node_modules/@otplib/totp": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/@otplib/totp/-/totp-13.1.0.tgz", + "integrity": "sha512-mEukKW8gxb8pEGL2VPu5zcOwH3lAXTrC6VLo7uhNpn83q9/BTFqDP8m47eygq7kz1MHSDSdL5xbbHJh8OfXY0g==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.1.0", + "@otplib/hotp": "13.1.0", + "@otplib/uri": "13.1.0" + } + }, + "node_modules/@otplib/uri": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/@otplib/uri/-/uri-13.1.0.tgz", + "integrity": "sha512-uSUzIa5F1TSUsHXSUA8xUkYaeYGXyFDq1mpPzQ2eGa+KzDBgw24GLJini2G/VGNjpNth6Q92GuAbcZQ29U0QUA==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.1.0" + } + }, "node_modules/@panva/hkdf": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz", @@ -1624,6 +3290,92 @@ } } }, + "node_modules/@radix-ui/react-checkbox": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz", + "integrity": "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-collection": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", @@ -2743,6 +4495,91 @@ } } }, + "node_modules/@radix-ui/react-switch": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz", + "integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-tabs": { "version": "1.1.13", "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", @@ -3233,6 +5070,49 @@ "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", "license": "MIT" }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.3", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.3.tgz", + "integrity": "sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.53", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz", + "integrity": "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.55.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz", @@ -3590,25 +5470,758 @@ "dev": true, "license": "MIT" }, - "node_modules/@rushstack/eslint-patch": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.15.0.tgz", - "integrity": "sha512-ojSshQPKwVvSMR8yT2L/QtUkV5SXi/IfDiJ4/8d6UbTPjiHVmxZzUAzGD8Tzks1b9+qQkZa0isUOvYObedITaw==", - "dev": true, - "license": "MIT" + "node_modules/@scure/base": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz", + "integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@smithy/abort-controller": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.8.tgz", + "integrity": "sha512-peuVfkYHAmS5ybKxWcfraK7WBBP0J+rkfUcbHJJKQ4ir3UAUNQI+Y4Vt/PqSzGqgloJ5O1dk7+WzNL8wcCSXbw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/chunked-blob-reader": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader/-/chunked-blob-reader-5.2.0.tgz", + "integrity": "sha512-WmU0TnhEAJLWvfSeMxBNe5xtbselEO8+4wG0NtZeL8oR21WgH1xiO37El+/Y+H/Ie4SCwBy3MxYWmOYaGgZueA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/chunked-blob-reader-native": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader-native/-/chunked-blob-reader-native-4.2.1.tgz", + "integrity": "sha512-lX9Ay+6LisTfpLid2zZtIhSEjHMZoAR5hHCR4H7tBz/Zkfr5ea8RcQ7Tk4mi0P76p4cN+Btz16Ffno7YHpKXnQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-base64": "^4.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/config-resolver": { + "version": "4.4.6", + "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.6.tgz", + "integrity": "sha512-qJpzYC64kaj3S0fueiu3kXm8xPrR3PcXDPEgnaNMRn0EjNSZFoFjvbUp0YUDsRhN1CB90EnHJtbxWKevnH99UQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.8", + "@smithy/types": "^4.12.0", + "@smithy/util-config-provider": "^4.2.0", + "@smithy/util-endpoints": "^3.2.8", + "@smithy/util-middleware": "^4.2.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/core": { + "version": "3.20.5", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.20.5.tgz", + "integrity": "sha512-0Tz77Td8ynHaowXfOdrD0F1IH4tgWGUhwmLwmpFyTbr+U9WHXNNp9u/k2VjBXGnSe7BwjBERRpXsokGTXzNjhA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/middleware-serde": "^4.2.9", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-stream": "^4.5.10", + "@smithy/util-utf8": "^4.2.0", + "@smithy/uuid": "^1.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.8.tgz", + "integrity": "sha512-FNT0xHS1c/CPN8upqbMFP83+ul5YgdisfCfkZ86Jh2NSmnqw/AJ6x5pEogVCTVvSm7j9MopRU89bmDelxuDMYw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.8", + "@smithy/property-provider": "^4.2.8", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-codec": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-4.2.8.tgz", + "integrity": "sha512-jS/O5Q14UsufqoGhov7dHLOPCzkYJl9QDzusI2Psh4wyYx/izhzvX9P4D69aTxcdfVhEPhjK+wYyn/PzLjKbbw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@smithy/types": "^4.12.0", + "@smithy/util-hex-encoding": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-browser": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-4.2.8.tgz", + "integrity": "sha512-MTfQT/CRQz5g24ayXdjg53V0mhucZth4PESoA5IhvaWVDTOQLfo8qI9vzqHcPsdd2v6sqfTYqF5L/l+pea5Uyw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-serde-universal": "^4.2.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-config-resolver": { + "version": "4.3.8", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-4.3.8.tgz", + "integrity": "sha512-ah12+luBiDGzBruhu3efNy1IlbwSEdNiw8fOZksoKoWW1ZHvO/04MQsdnws/9Aj+5b0YXSSN2JXKy/ClIsW8MQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-node": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-node/-/eventstream-serde-node-4.2.8.tgz", + "integrity": "sha512-cYpCpp29z6EJHa5T9WL0KAlq3SOKUQkcgSoeRfRVwjGgSFl7Uh32eYGt7IDYCX20skiEdRffyDpvF2efEZPC0A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-serde-universal": "^4.2.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-universal": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-universal/-/eventstream-serde-universal-4.2.8.tgz", + "integrity": "sha512-iJ6YNJd0bntJYnX6s52NC4WFYcZeKrPUr1Kmmr5AwZcwCSzVpS7oavAmxMR7pMq7V+D1G4s9F5NJK0xwOsKAlQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-codec": "^4.2.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.3.9", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.9.tgz", + "integrity": "sha512-I4UhmcTYXBrct03rwzQX1Y/iqQlzVQaPxWjCjula++5EmWq9YGBrx6bbGqluGc1f0XEfhSkiY4jhLgbsJUMKRA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.8", + "@smithy/querystring-builder": "^4.2.8", + "@smithy/types": "^4.12.0", + "@smithy/util-base64": "^4.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/hash-blob-browser": { + "version": "4.2.9", + "resolved": "https://registry.npmjs.org/@smithy/hash-blob-browser/-/hash-blob-browser-4.2.9.tgz", + "integrity": "sha512-m80d/iicI7DlBDxyQP6Th7BW/ejDGiF0bgI754+tiwK0lgMkcaIBgvwwVc7OFbY4eUzpGtnig52MhPAEJ7iNYg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/chunked-blob-reader": "^5.2.0", + "@smithy/chunked-blob-reader-native": "^4.2.1", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/hash-node": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.8.tgz", + "integrity": "sha512-7ZIlPbmaDGxVoxErDZnuFG18WekhbA/g2/i97wGj+wUBeS6pcUeAym8u4BXh/75RXWhgIJhyC11hBzig6MljwA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "@smithy/util-buffer-from": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/hash-stream-node": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/hash-stream-node/-/hash-stream-node-4.2.8.tgz", + "integrity": "sha512-v0FLTXgHrTeheYZFGhR+ehX5qUm4IQsjAiL9qehad2cyjMWcN2QG6/4mSwbSgEQzI7jwfoXj7z4fxZUx/Mhj2w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/invalid-dependency": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.8.tgz", + "integrity": "sha512-N9iozRybwAQ2dn9Fot9kI6/w9vos2oTXLhtK7ovGqwZjlOcxu6XhPlpLpC+INsxktqHinn5gS2DXDjDF2kG5sQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/is-array-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.0.tgz", + "integrity": "sha512-DZZZBvC7sjcYh4MazJSGiWMI2L7E0oCiRHREDzIxi/M2LY79/21iXt6aPLHge82wi5LsuRF5A06Ds3+0mlh6CQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/md5-js": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/md5-js/-/md5-js-4.2.8.tgz", + "integrity": "sha512-oGMaLj4tVZzLi3itBa9TCswgMBr7k9b+qKYowQ6x1rTyTuO1IU2YHdHUa+891OsOH+wCsH7aTPRsTJO3RMQmjQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-content-length": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.8.tgz", + "integrity": "sha512-RO0jeoaYAB1qBRhfVyq0pMgBoUK34YEJxVxyjOWYZiOKOq2yMZ4MnVXMZCUDenpozHue207+9P5ilTV1zeda0A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-endpoint": { + "version": "4.4.6", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.6.tgz", + "integrity": "sha512-dpq3bHqbEOBqGBjRVHVFP3eUSPpX0BYtg1D5d5Irgk6orGGAuZfY22rC4sErhg+ZfY/Y0kPqm1XpAmDZg7DeuA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.20.5", + "@smithy/middleware-serde": "^4.2.9", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "@smithy/util-middleware": "^4.2.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-retry": { + "version": "4.4.22", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.22.tgz", + "integrity": "sha512-vwWDMaObSMjw6WCC/3Ae9G7uul5Sk95jr07CDk1gkIMpaDic0phPS1MpVAZ6+YkF7PAzRlpsDjxPwRlh/S11FQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/service-error-classification": "^4.2.8", + "@smithy/smithy-client": "^4.10.7", + "@smithy/types": "^4.12.0", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-retry": "^4.2.8", + "@smithy/uuid": "^1.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-serde": { + "version": "4.2.9", + "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.9.tgz", + "integrity": "sha512-eMNiej0u/snzDvlqRGSN3Vl0ESn3838+nKyVfF2FKNXFbi4SERYT6PR392D39iczngbqqGG0Jl1DlCnp7tBbXQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-stack": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.8.tgz", + "integrity": "sha512-w6LCfOviTYQjBctOKSwy6A8FIkQy7ICvglrZFl6Bw4FmcQ1Z420fUtIhxaUZZshRe0VCq4kvDiPiXrPZAe8oRA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-config-provider": { + "version": "4.3.8", + "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.8.tgz", + "integrity": "sha512-aFP1ai4lrbVlWjfpAfRSL8KFcnJQYfTl5QxLJXY32vghJrDuFyPZ6LtUL+JEGYiFRG1PfPLHLoxj107ulncLIg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/property-provider": "^4.2.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.4.8", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.4.8.tgz", + "integrity": "sha512-q9u+MSbJVIJ1QmJ4+1u+cERXkrhuILCBDsJUBAW1MPE6sFonbCNaegFuwW9ll8kh5UdyY3jOkoOGlc7BesoLpg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/abort-controller": "^4.2.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/querystring-builder": "^4.2.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/property-provider": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.8.tgz", + "integrity": "sha512-EtCTbyIveCKeOXDSWSdze3k612yCPq1YbXsbqX3UHhkOSW8zKsM9NOJG5gTIya0vbY2DIaieG8pKo1rITHYL0w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/protocol-http": { + "version": "5.3.8", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.8.tgz", + "integrity": "sha512-QNINVDhxpZ5QnP3aviNHQFlRogQZDfYlCkQT+7tJnErPQbDhysondEjhikuANxgMsZrkGeiAxXy4jguEGsDrWQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/querystring-builder": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.8.tgz", + "integrity": "sha512-Xr83r31+DrE8CP3MqPgMJl+pQlLLmOfiEUnoyAlGzzJIrEsbKsPy1hqH0qySaQm4oWrCBlUqRt+idEgunKB+iw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "@smithy/util-uri-escape": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/querystring-parser": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.8.tgz", + "integrity": "sha512-vUurovluVy50CUlazOiXkPq40KGvGWSdmusa3130MwrR1UNnNgKAlj58wlOe61XSHRpUfIIh6cE0zZ8mzKaDPA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/service-error-classification": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.8.tgz", + "integrity": "sha512-mZ5xddodpJhEt3RkCjbmUQuXUOaPNTkbMGR0bcS8FE0bJDLMZlhmpgrvPNCYglVw5rsYTpSnv19womw9WWXKQQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/shared-ini-file-loader": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.3.tgz", + "integrity": "sha512-DfQjxXQnzC5UbCUPeC3Ie8u+rIWZTvuDPAGU/BxzrOGhRvgUanaP68kDZA+jaT3ZI+djOf+4dERGlm9mWfFDrg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.3.8", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.8.tgz", + "integrity": "sha512-6A4vdGj7qKNRF16UIcO8HhHjKW27thsxYci+5r/uVRkdcBEkOEiY8OMPuydLX4QHSrJqGHPJzPRwwVTqbLZJhg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^4.2.0", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "@smithy/util-hex-encoding": "^4.2.0", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-uri-escape": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/smithy-client": { + "version": "4.10.7", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.10.7.tgz", + "integrity": "sha512-Uznt0I9z3os3Z+8pbXrOSCTXCA6vrjyN7Ub+8l2pRDum44vLv8qw0qGVkJN0/tZBZotaEFHrDPKUoPNueTr5Vg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.20.5", + "@smithy/middleware-endpoint": "^4.4.6", + "@smithy/middleware-stack": "^4.2.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "@smithy/util-stream": "^4.5.10", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.12.0.tgz", + "integrity": "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/url-parser": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.8.tgz", + "integrity": "sha512-NQho9U68TGMEU639YkXnVMV3GEFFULmmaWdlu1E9qzyIePOHsoSnagTGSDv1Zi8DCNN6btxOSdgmy5E/hsZwhA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/querystring-parser": "^4.2.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-base64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.3.0.tgz", + "integrity": "sha512-GkXZ59JfyxsIwNTWFnjmFEI8kZpRNIBfxKjv09+nkAWPt/4aGaEWMM04m4sxgNVWkbt2MdSvE3KF/PfX4nFedQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-body-length-browser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.2.0.tgz", + "integrity": "sha512-Fkoh/I76szMKJnBXWPdFkQJl2r9SjPt3cMzLdOB6eJ4Pnpas8hVoWPYemX/peO0yrrvldgCUVJqOAjUrOLjbxg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-body-length-node": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.2.1.tgz", + "integrity": "sha512-h53dz/pISVrVrfxV1iqXlx5pRg3V2YWFcSQyPyXZRrZoZj4R4DeWRDo1a7dd3CPTcFi3kE+98tuNyD2axyZReA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-buffer-from": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.0.tgz", + "integrity": "sha512-kAY9hTKulTNevM2nlRtxAG2FQ3B2OR6QIrPY3zE5LqJy1oxzmgBGsHLWTcNhWXKchgA0WHW+mZkQrng/pgcCew==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-config-provider": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.2.0.tgz", + "integrity": "sha512-YEjpl6XJ36FTKmD+kRJJWYvrHeUvm5ykaUS5xK+6oXffQPHeEM4/nXlZPe+Wu0lsgRUcNZiliYNh/y7q9c2y6Q==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-defaults-mode-browser": { + "version": "4.3.21", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.21.tgz", + "integrity": "sha512-DtmVJarzqtjghtGjCw/PFJolcJkP7GkZgy+hWTAN3YLXNH+IC82uMoMhFoC3ZtIz5mOgCm5+hOGi1wfhVYgrxw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/property-provider": "^4.2.8", + "@smithy/smithy-client": "^4.10.7", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-defaults-mode-node": { + "version": "4.2.24", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.24.tgz", + "integrity": "sha512-JelBDKPAVswVY666rezBvY6b0nF/v9TXjUbNwDNAyme7qqKYEX687wJv0uze8lBIZVbg30wlWnlYfVSjjpKYFA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/config-resolver": "^4.4.6", + "@smithy/credential-provider-imds": "^4.2.8", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/property-provider": "^4.2.8", + "@smithy/smithy-client": "^4.10.7", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-endpoints": { + "version": "3.2.8", + "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.2.8.tgz", + "integrity": "sha512-8JaVTn3pBDkhZgHQ8R0epwWt+BqPSLCjdjXXusK1onwJlRuN69fbvSK66aIKKO7SwVFM6x2J2ox5X8pOaWcUEw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-hex-encoding": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.0.tgz", + "integrity": "sha512-CCQBwJIvXMLKxVbO88IukazJD9a4kQ9ZN7/UMGBjBcJYvatpWk+9g870El4cB8/EJxfe+k+y0GmR9CAzkF+Nbw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-middleware": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.8.tgz", + "integrity": "sha512-PMqfeJxLcNPMDgvPbbLl/2Vpin+luxqTGPpW3NAQVLbRrFRzTa4rNAASYeIGjRV9Ytuhzny39SpyU04EQreF+A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-retry": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.8.tgz", + "integrity": "sha512-CfJqwvoRY0kTGe5AkQokpURNCT1u/MkRzMTASWMPPo2hNSnKtF1D45dQl3DE2LKLr4m+PW9mCeBMJr5mCAVThg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/service-error-classification": "^4.2.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-stream": { + "version": "4.5.10", + "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.10.tgz", + "integrity": "sha512-jbqemy51UFSZSp2y0ZmRfckmrzuKww95zT9BYMmuJ8v3altGcqjwoV1tzpOwuHaKrwQrCjIzOib499ymr2f98g==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/fetch-http-handler": "^5.3.9", + "@smithy/node-http-handler": "^4.4.8", + "@smithy/types": "^4.12.0", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-buffer-from": "^4.2.0", + "@smithy/util-hex-encoding": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-uri-escape": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.2.0.tgz", + "integrity": "sha512-igZpCKV9+E/Mzrpq6YacdTQ0qTiLm85gD6N/IrmyDvQFA4UnU3d5g3m8tMT/6zG/vVkWSU+VxeUyGonL62DuxA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-utf8": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.0.tgz", + "integrity": "sha512-zBPfuzoI8xyBtR2P6WQj63Rz8i3AmfAaJLuNG8dWsfvPe8lO4aCPYLn879mEgHndZH1zQ2oXmG8O1GGzzaoZiw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-waiter": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-4.2.8.tgz", + "integrity": "sha512-n+lahlMWk+aejGuax7DPWtqav8HYnWxQwR+LCG2BgCUmaGcTe9qZCFsmw8TMg9iG75HOwhrJCX9TCJRLH+Yzqg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/abort-controller": "^4.2.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/uuid": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@smithy/uuid/-/uuid-1.1.0.tgz", + "integrity": "sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "devOptional": true, "license": "MIT" }, - "node_modules/@swc/counter": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", - "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", - "license": "Apache-2.0" + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" }, "node_modules/@swc/helpers": { "version": "0.5.15", @@ -3678,6 +6291,66 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/dom/node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@testing-library/react": { + "version": "16.3.1", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.1.tgz", + "integrity": "sha512-gr4KtAWqIOQoucWYD/f6ki+j5chXfcPc74Col/6poTyqTmn7zRmodWahWRCp8tYd+GMqBonw6hstNzqjbs6gjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", @@ -3689,6 +6362,59 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, "node_modules/@types/bcryptjs": { "version": "2.4.6", "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", @@ -3696,10 +6422,85 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/cookie": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", - "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", + "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, "license": "MIT" }, "node_modules/@types/estree": { @@ -3724,19 +6525,40 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.19.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.3.tgz", - "integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==", + "version": "22.19.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.5.tgz", + "integrity": "sha512-HfF8+mYcHPcPypui3w3mvzuIErlNOh2OAG+BCeBZCEwyiD5ls2SiCwEyT47OELtf7M3nHxBdu0FsmzdKxkN52Q==", "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" } }, + "node_modules/@types/nodemailer": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.5.tgz", + "integrity": "sha512-7WtR4MFJUNN2UFy0NIowBRJswj5KXjXDhlZY43Hmots5eGu5q/dTeFd/I6GgJA/qj3RqO6dDy4SvfcV3fOVeIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@aws-sdk/client-sesv2": "^3.839.0", + "@types/node": "*" + } + }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/react": { - "version": "19.2.7", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", - "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.8.tgz", + "integrity": "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==", "devOptional": true, "license": "MIT", "dependencies": { @@ -3753,6 +6575,36 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/ssh2": { + "version": "1.15.5", + "resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-1.15.5.tgz", + "integrity": "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==", + "license": "MIT", + "dependencies": { + "@types/node": "^18.11.18" + } + }, + "node_modules/@types/ssh2/node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/ssh2/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "license": "MIT" + }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.52.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.52.0.tgz", @@ -4278,39 +7130,62 @@ "win32" ] }, - "node_modules/@vitest/expect": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", - "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "node_modules/@vitejs/plugin-react": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.2.tgz", + "integrity": "sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "2.1.9", - "@vitest/utils": "2.1.9", - "chai": "^5.1.2", - "tinyrainbow": "^1.2.0" + "@babel/core": "^7.28.5", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.53", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.16.tgz", + "integrity": "sha512-eshqULT2It7McaJkQGLkPjPjNph+uevROGuIMJdG3V+0BSR2w9u6J9Lwu+E8cK5TETlfou8GRijhafIMhXsimA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.16", + "@vitest/utils": "4.0.16", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/mocker": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", - "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.16.tgz", + "integrity": "sha512-yb6k4AZxJTB+q9ycAvsoxGn+j/po0UaPgajllBgt1PzoMAAmJGYFdDk0uCcRcxb3BrME34I6u8gHZTQlkqSZpg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "2.1.9", + "@vitest/spy": "4.0.16", "estree-walker": "^3.0.3", - "magic-string": "^0.30.12" + "magic-string": "^0.30.21" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { "msw": "^2.4.9", - "vite": "^5.0.0" + "vite": "^6.0.0 || ^7.0.0-0" }, "peerDependenciesMeta": { "msw": { @@ -4322,84 +7197,66 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", - "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.16.tgz", + "integrity": "sha512-eNCYNsSty9xJKi/UdVD8Ou16alu7AYiS2fCPRs0b1OdhJiV89buAXQLpTbe+X8V9L6qrs9CqyvU7OaAopJYPsA==", "dev": true, "license": "MIT", "dependencies": { - "tinyrainbow": "^1.2.0" + "tinyrainbow": "^3.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/runner": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", - "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.16.tgz", + "integrity": "sha512-VWEDm5Wv9xEo80ctjORcTQRJ539EGPB3Pb9ApvVRAY1U/WkHXmmYISqU5E79uCwcW7xYUV38gwZD+RV755fu3Q==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "2.1.9", - "pathe": "^1.1.2" + "@vitest/utils": "4.0.16", + "pathe": "^2.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/runner/node_modules/pathe": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", - "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", - "dev": true, - "license": "MIT" - }, "node_modules/@vitest/snapshot": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", - "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.16.tgz", + "integrity": "sha512-sf6NcrYhYBsSYefxnry+DR8n3UV4xWZwWxYbCJUt2YdvtqzSPR7VfGrY0zsv090DAbjFZsi7ZaMi1KnSRyK1XA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "2.1.9", - "magic-string": "^0.30.12", - "pathe": "^1.1.2" + "@vitest/pretty-format": "4.0.16", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/snapshot/node_modules/pathe": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", - "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", - "dev": true, - "license": "MIT" - }, "node_modules/@vitest/spy": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", - "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.16.tgz", + "integrity": "sha512-4jIOWjKP0ZUaEmJm00E0cOBLU+5WE0BpeNr3XN6TEF05ltro6NJqHWxXD0kA8/Zc8Nh23AT8WQxwNG+WeROupw==", "dev": true, "license": "MIT", - "dependencies": { - "tinyspy": "^3.0.2" - }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/utils": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", - "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.16.tgz", + "integrity": "sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "2.1.9", - "loupe": "^3.1.2", - "tinyrainbow": "^1.2.0" + "@vitest/pretty-format": "4.0.16", + "tinyrainbow": "^3.0.3" }, "funding": { "url": "https://opencollective.com/vitest" @@ -4428,6 +7285,16 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -4445,11 +7312,19 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -4675,6 +7550,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -4756,9 +7640,9 @@ } }, "node_modules/axe-core": { - "version": "4.11.0", - "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.0.tgz", - "integrity": "sha512-ilYanEU8vxxBexpJd8cWM4ElSQq4QctCLKih0TSfjIfCQTeyH/6zVrmIJfLPrKTKJRbiG+cfnZbQIjAlJmF1jQ==", + "version": "4.11.1", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.1.tgz", + "integrity": "sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==", "dev": true, "license": "MPL-2.0", "engines": { @@ -4783,21 +7667,39 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.9.11", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz", - "integrity": "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==", - "dev": true, + "version": "2.9.14", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.14.tgz", + "integrity": "sha512-B0xUquLkiGLgHhpPBqvl7GWegWBUNuujQ6kXd/r1U38ElPT6Ok8KZ8e+FpUGEc2ZoRQUzq/aUnaKFc/svWUGSg==", "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "license": "BSD-3-Clause", + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, "node_modules/bcryptjs": { "version": "2.4.3", "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==", "license": "MIT" }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -4810,6 +7712,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bowser": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.13.1.tgz", + "integrity": "sha512-OHawaAbjwx6rqICCKgSG0SAnT05bzd7ppyKLVUITZpANBaaMFBAsaNkto3LoQ31tyFP5kNujE8Cdx85G9VzOkw==", + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -4867,15 +7775,13 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/busboy": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", - "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", - "dependencies": { - "streamsearch": "^1.1.0" - }, + "node_modules/buildcheck": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz", + "integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==", + "optional": true, "engines": { - "node": ">=10.16.0" + "node": ">=10.0.0" } }, "node_modules/c12": { @@ -4907,16 +7813,6 @@ } } }, - "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", @@ -4977,6 +7873,15 @@ "node": ">=6" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/camelcase-css": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", @@ -4987,9 +7892,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001762", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001762.tgz", - "integrity": "sha512-PxZwGNvH7Ak8WX5iXzoK1KPZttBXNPuaOvI2ZYU7NrlM+d9Ov+TUvlLOBNGzVXAntMSMMlJPd+jY6ovrVjSmUw==", + "version": "1.0.30001764", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001764.tgz", + "integrity": "sha512-9JGuzl2M+vPL+pz70gtMF9sHdMFbY9FJaQBi186cHKH3pSzDvzoUJUPV6fqiKIMyXbud9ZLg4F3Yza1vJ1+93g==", "funding": [ { "type": "opencollective", @@ -5007,18 +7912,11 @@ "license": "CC-BY-4.0" }, "node_modules/chai": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", - "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", "dev": true, "license": "MIT", - "dependencies": { - "assertion-error": "^2.0.1", - "check-error": "^2.1.1", - "deep-eql": "^5.0.1", - "loupe": "^3.1.0", - "pathval": "^2.0.0" - }, "engines": { "node": ">=18" } @@ -5040,16 +7938,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/check-error": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", - "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - } - }, "node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", @@ -5094,6 +7982,17 @@ "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "license": "MIT" }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -5103,25 +8002,10 @@ "node": ">=6" } }, - "node_modules/color": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", - "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", - "license": "MIT", - "optional": true, - "dependencies": { - "color-convert": "^2.0.1", - "color-string": "^1.9.0" - }, - "engines": { - "node": ">=12.5.0" - } - }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "devOptional": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -5134,20 +8018,8 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "devOptional": true, "license": "MIT" }, - "node_modules/color-string": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", - "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", - "license": "MIT", - "optional": true, - "dependencies": { - "color-name": "^1.0.0", - "simple-swizzle": "^0.2.2" - } - }, "node_modules/commander": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", @@ -5181,13 +8053,25 @@ "node": "^14.18.0 || >=16.10.0" } }, - "node_modules/cookie": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", - "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", - "license": "MIT", + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cpu-features": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz", + "integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "buildcheck": "~0.0.6", + "nan": "^2.19.0" + }, "engines": { - "node": ">= 0.6" + "node": ">=10.0.0" } }, "node_modules/cross-spawn": { @@ -5205,6 +8089,20 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", + "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.12.2", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -5217,6 +8115,32 @@ "node": ">=4" } }, + "node_modules/cssstyle": { + "version": "5.3.7", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.7.tgz", + "integrity": "sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^4.1.1", + "@csstools/css-syntax-patches-for-csstree": "^1.0.21", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cssstyle/node_modules/lru-cache": { + "version": "11.2.4", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", + "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -5224,6 +8148,127 @@ "devOptional": true, "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", + "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -5231,6 +8276,20 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/data-urls": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.0.tgz", + "integrity": "sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^15.0.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -5285,6 +8344,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -5303,16 +8372,28 @@ } } }, - "node_modules/deep-eql": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", - "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", - "dev": true, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", "license": "MIT", "engines": { - "node": ">=6" + "node": ">=0.10.0" } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -5373,6 +8454,17 @@ "devOptional": true, "license": "MIT" }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + } + }, "node_modules/destr": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", @@ -5402,6 +8494,12 @@ "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", "license": "Apache-2.0" }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/dlv": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", @@ -5421,6 +8519,14 @@ "node": ">=0.10.0" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/dotenv": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", @@ -5484,6 +8590,19 @@ "node": ">=14" } }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-abstract": { "version": "1.24.1", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", @@ -5668,6 +8787,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es-toolkit": { + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.43.0.tgz", + "integrity": "sha512-SKCT8AsWvYzBBuUqMk4NPwFlSdqLpJwmy6AP322ERn8W2YLIB6JBXnwMI2Qsh2gfphT3q7EKAxKb23cvFHFwKA==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/esbuild": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", @@ -5794,25 +8923,24 @@ } }, "node_modules/eslint-config-next": { - "version": "15.1.4", - "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-15.1.4.tgz", - "integrity": "sha512-u9+7lFmfhKNgGjhQ9tBeyCFsPJyq0SvGioMJBngPC7HXUpR0U+ckEwQR48s7TrRNHra1REm6evGL2ie38agALg==", + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.1.1.tgz", + "integrity": "sha512-55nTpVWm3qeuxoQKLOjQVciKZJUphKrNM0fCcQHAIOGl6VFXgaqeMfv0aKJhs7QtcnlAPhNVqsqRfRjeKBPIUA==", "dev": true, "license": "MIT", "dependencies": { - "@next/eslint-plugin-next": "15.1.4", - "@rushstack/eslint-patch": "^1.10.3", - "@typescript-eslint/eslint-plugin": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", - "@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "@next/eslint-plugin-next": "16.1.1", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", - "eslint-plugin-import": "^2.31.0", + "eslint-plugin-import": "^2.32.0", "eslint-plugin-jsx-a11y": "^6.10.0", "eslint-plugin-react": "^7.37.0", - "eslint-plugin-react-hooks": "^5.0.0" + "eslint-plugin-react-hooks": "^7.0.0", + "globals": "16.4.0", + "typescript-eslint": "^8.46.0" }, "peerDependencies": { - "eslint": "^7.23.0 || ^8.0.0 || ^9.0.0", + "eslint": ">=9.0.0", "typescript": ">=3.3.1" }, "peerDependenciesMeta": { @@ -5821,6 +8949,19 @@ } } }, + "node_modules/eslint-config-next/node_modules/globals": { + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.4.0.tgz", + "integrity": "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/eslint-import-resolver-node": { "version": "0.3.9", "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", @@ -6024,13 +9165,20 @@ } }, "node_modules/eslint-plugin-react-hooks": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", - "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", "dev": true, "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, "engines": { - "node": ">=10" + "node": ">=18" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" @@ -6168,6 +9316,12 @@ "node": ">=0.10.0" } }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "license": "MIT" + }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -6259,6 +9413,24 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-xml-parser": { + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.2.5.tgz", + "integrity": "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "strnum": "^2.1.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -6425,6 +9597,25 @@ "node": ">= 0.4" } }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -6670,6 +9861,77 @@ "node": ">= 0.4" } }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -6680,6 +9942,16 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -6722,6 +9994,15 @@ "node": ">= 0.4" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -6740,13 +10021,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-arrayish": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", - "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", - "license": "MIT", - "optional": true - }, "node_modules/is-async-function": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", @@ -6910,6 +10184,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-generator-function": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", @@ -6994,6 +10277,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -7210,6 +10500,59 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "27.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.0.1.tgz", + "integrity": "sha512-SNSQteBL1IlV2zqhwwolaG9CwhIhTvVHWg3kTss/cLE7H/X4644mtPQqYvCfsSrGQWt9hSZcgOXX8bOZaMN+kA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/dom-selector": "^6.7.2", + "cssstyle": "^5.3.1", + "data-urls": "^6.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^8.0.0", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^15.1.0", + "ws": "^8.18.3", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -7358,12 +10701,15 @@ "loose-envify": "cli.js" } }, - "node_modules/loupe": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", - "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", "dev": true, - "license": "MIT" + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } }, "node_modules/lucide-react": { "version": "0.469.0", @@ -7374,6 +10720,17 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -7394,6 +10751,13 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", + "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -7457,6 +10821,13 @@ "thenify-all": "^1.0.0" } }, + "node_modules/nan": { + "version": "2.24.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.24.0.tgz", + "integrity": "sha512-Vpf9qnVW1RaDkoNKFUvfxqAbtI8ncb8OJlqZ9wwpXzWPEsvsB1nvdUi6oYrHIkQ1Y/tMDnr1h4nczS0VB9Xykg==", + "license": "MIT", + "optional": true + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -7499,16 +10870,14 @@ "license": "MIT" }, "node_modules/next": { - "version": "15.1.4", - "resolved": "https://registry.npmjs.org/next/-/next-15.1.4.tgz", - "integrity": "sha512-mTaq9dwaSuwwOrcu3ebjDYObekkxRnXpuVL21zotM8qE2W0HBOdVIdg2Li9QjMEZrj73LN96LcWcz62V19FjAg==", - "deprecated": "This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/CVE-2025-66478 for more details.", + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/next/-/next-16.1.1.tgz", + "integrity": "sha512-QI+T7xrxt1pF6SQ/JYFz95ro/mg/1Znk5vBebsWwbpejj1T0A23hO7GYEaVac9QUOT2BIMiuzm0L99ooq7k0/w==", "license": "MIT", "dependencies": { - "@next/env": "15.1.4", - "@swc/counter": "0.1.3", + "@next/env": "16.1.1", "@swc/helpers": "0.5.15", - "busboy": "1.6.0", + "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" @@ -7517,22 +10886,22 @@ "next": "dist/bin/next" }, "engines": { - "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" + "node": ">=20.9.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "15.1.4", - "@next/swc-darwin-x64": "15.1.4", - "@next/swc-linux-arm64-gnu": "15.1.4", - "@next/swc-linux-arm64-musl": "15.1.4", - "@next/swc-linux-x64-gnu": "15.1.4", - "@next/swc-linux-x64-musl": "15.1.4", - "@next/swc-win32-arm64-msvc": "15.1.4", - "@next/swc-win32-x64-msvc": "15.1.4", - "sharp": "^0.33.5" + "@next/swc-darwin-arm64": "16.1.1", + "@next/swc-darwin-x64": "16.1.1", + "@next/swc-linux-arm64-gnu": "16.1.1", + "@next/swc-linux-arm64-musl": "16.1.1", + "@next/swc-linux-x64-gnu": "16.1.1", + "@next/swc-linux-x64-musl": "16.1.1", + "@next/swc-win32-arm64-msvc": "16.1.1", + "@next/swc-win32-x64-msvc": "16.1.1", + "sharp": "^0.34.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", - "@playwright/test": "^1.41.2", + "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", @@ -7554,19 +10923,19 @@ } }, "node_modules/next-auth": { - "version": "5.0.0-beta.25", - "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-5.0.0-beta.25.tgz", - "integrity": "sha512-2dJJw1sHQl2qxCrRk+KTQbeH+izFbGFPuJj5eGgBZFYyiYYtvlrBeUw1E/OJJxTRjuxbSYGnCTkUIRsIIW0bog==", + "version": "5.0.0-beta.30", + "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-5.0.0-beta.30.tgz", + "integrity": "sha512-+c51gquM3F6nMVmoAusRJ7RIoY0K4Ts9HCCwyy/BRoe4mp3msZpOzYMyb5LAYc1wSo74PMQkGDcaghIO7W6Xjg==", "license": "ISC", "dependencies": { - "@auth/core": "0.37.2" + "@auth/core": "0.41.0" }, "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.2", - "next": "^14.0.0-0 || ^15.0.0-0", - "nodemailer": "^6.6.5", - "react": "^18.2.0 || ^19.0.0-0" + "next": "^14.0.0-0 || ^15.0.0 || ^16.0.0", + "nodemailer": "^7.0.7", + "react": "^18.2.0 || ^19.0.0" }, "peerDependenciesMeta": { "@simplewebauthn/browser": { @@ -7581,18 +10950,16 @@ } }, "node_modules/next-auth/node_modules/@auth/core": { - "version": "0.37.2", - "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.37.2.tgz", - "integrity": "sha512-kUvzyvkcd6h1vpeMAojK2y7+PAV5H+0Cc9+ZlKYDFhDY31AlvsB+GW5vNO4qE3Y07KeQgvNO9U0QUx/fN62kBw==", + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.41.0.tgz", + "integrity": "sha512-Wd7mHPQ/8zy6Qj7f4T46vg3aoor8fskJm6g2Zyj064oQ3+p0xNZXAV60ww0hY+MbTesfu29kK14Zk5d5JTazXQ==", "license": "ISC", "dependencies": { "@panva/hkdf": "^1.2.1", - "@types/cookie": "0.6.0", - "cookie": "0.7.1", - "jose": "^5.9.3", - "oauth4webapi": "^3.0.0", - "preact": "10.11.3", - "preact-render-to-string": "5.2.3" + "jose": "^6.0.6", + "oauth4webapi": "^3.3.0", + "preact": "10.24.3", + "preact-render-to-string": "6.5.11" }, "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", @@ -7611,37 +10978,6 @@ } } }, - "node_modules/next-auth/node_modules/jose": { - "version": "5.10.0", - "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", - "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, - "node_modules/next-auth/node_modules/preact": { - "version": "10.11.3", - "resolved": "https://registry.npmjs.org/preact/-/preact-10.11.3.tgz", - "integrity": "sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/preact" - } - }, - "node_modules/next-auth/node_modules/preact-render-to-string": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.3.tgz", - "integrity": "sha512-aPDxUn5o3GhWdtJtW0svRC2SS/l8D9MAgo2+AWml+BhDImb27ALf04Q2d+AHqUUOc6RdSXFIBVa2gxzgMKgtZA==", - "license": "MIT", - "dependencies": { - "pretty-format": "^3.8.0" - }, - "peerDependencies": { - "preact": ">=10" - } - }, "node_modules/next-themes": { "version": "0.4.6", "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz", @@ -7694,6 +11030,15 @@ "dev": true, "license": "MIT" }, + "node_modules/nodemailer": { + "version": "7.0.12", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.12.tgz", + "integrity": "sha512-H+rnK5bX2Pi/6ms3sN4/jRQvYSMltV6vqup/0SFOrxYYY/qoNvhXPlYq3e+Pm9RFJRwrMGbMIwi81M4dxpomhA==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -7863,6 +11208,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/ohash": { "version": "2.0.11", "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", @@ -7888,6 +11244,20 @@ "node": ">= 0.8.0" } }, + "node_modules/otplib": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/otplib/-/otplib-13.1.0.tgz", + "integrity": "sha512-QQ8tHgo6j1yo4Jpq5yOGmqpyYbgmjGOBoNZKMLTzZ2sdGKJ4GUN/7P557/5Ti9GNUH/WMsnToQ2gqmPtI1hz0Q==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.1.0", + "@otplib/hotp": "13.1.0", + "@otplib/plugin-base32-scure": "13.1.0", + "@otplib/plugin-crypto-noble": "13.1.0", + "@otplib/totp": "13.1.0", + "@otplib/uri": "13.1.0" + } + }, "node_modules/own-keys": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", @@ -7938,6 +11308,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -7951,11 +11330,23 @@ "node": ">=6" } }, + "node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -7984,16 +11375,6 @@ "devOptional": true, "license": "MIT" }, - "node_modules/pathval": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", - "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.16" - } - }, "node_modules/perfect-debounce": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", @@ -8049,6 +11430,15 @@ "pathe": "^2.0.3" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -8261,10 +11651,42 @@ } }, "node_modules/pretty-format": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz", - "integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==", - "license": "MIT" + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true }, "node_modules/prisma": { "version": "6.19.1", @@ -8304,6 +11726,13 @@ "react-is": "^16.13.1" } }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -8331,6 +11760,23 @@ ], "license": "MIT" }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -8384,9 +11830,9 @@ } }, "node_modules/react-hook-form": { - "version": "7.70.0", - "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.70.0.tgz", - "integrity": "sha512-COOMajS4FI3Wuwrs3GPpi/Jeef/5W1DRR84Yl5/ShlT3dKVFUfoGiEZ/QE6Uw8P4T2/CLJdcTVYKvWBMQTEpvw==", + "version": "7.71.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.0.tgz", + "integrity": "sha512-oFDt/iIFMV9ZfV52waONXzg4xuSlbwKUPvXVH2jumL1me5qFhBMc4knZxuXiZ2+j6h546sYe3ZKJcg/900/iHw==", "license": "MIT", "engines": { "node": ">=18.0.0" @@ -8400,11 +11846,44 @@ } }, "node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.3.tgz", + "integrity": "sha512-qJNJfu81ByyabuG7hPFEbXqNcWSU3+eVus+KJs+0ncpGfMyYdvSmxiJxbWR65lYi1I+/0HBcliO029gc4F+PnA==", + "license": "MIT", + "peer": true + }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } }, "node_modules/react-remove-scroll": { "version": "2.7.2", @@ -8498,6 +11977,51 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/recharts": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.6.0.tgz", + "integrity": "sha512-L5bjxvQRAe26RlToBAziKUB7whaGKEwD3znoM6fz3DrTowCIC/FnJYnuq1GEzB8Zv2kdTfaxQfi5GoH0tBinyg==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "1.x.x || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -8542,6 +12066,37 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -8637,6 +12192,13 @@ "fsevents": "~2.3.2" } }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -8715,6 +12277,25 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -8734,6 +12315,12 @@ "node": ">=10" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -8784,16 +12371,16 @@ } }, "node_modules/sharp": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", - "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", "hasInstallScript": true, "license": "Apache-2.0", "optional": true, "dependencies": { - "color": "^4.2.3", - "detect-libc": "^2.0.3", - "semver": "^7.6.3" + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" }, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" @@ -8802,25 +12389,30 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.33.5", - "@img/sharp-darwin-x64": "0.33.5", - "@img/sharp-libvips-darwin-arm64": "1.0.4", - "@img/sharp-libvips-darwin-x64": "1.0.4", - "@img/sharp-libvips-linux-arm": "1.0.5", - "@img/sharp-libvips-linux-arm64": "1.0.4", - "@img/sharp-libvips-linux-s390x": "1.0.4", - "@img/sharp-libvips-linux-x64": "1.0.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", - "@img/sharp-libvips-linuxmusl-x64": "1.0.4", - "@img/sharp-linux-arm": "0.33.5", - "@img/sharp-linux-arm64": "0.33.5", - "@img/sharp-linux-s390x": "0.33.5", - "@img/sharp-linux-x64": "0.33.5", - "@img/sharp-linuxmusl-arm64": "0.33.5", - "@img/sharp-linuxmusl-x64": "0.33.5", - "@img/sharp-wasm32": "0.33.5", - "@img/sharp-win32-ia32": "0.33.5", - "@img/sharp-win32-x64": "0.33.5" + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" } }, "node_modules/shebang-command": { @@ -8929,16 +12521,6 @@ "dev": true, "license": "ISC" }, - "node_modules/simple-swizzle": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", - "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", - "license": "MIT", - "optional": true, - "dependencies": { - "is-arrayish": "^0.3.1" - } - }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -8948,6 +12530,23 @@ "node": ">=0.10.0" } }, + "node_modules/ssh2": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz", + "integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==", + "hasInstallScript": true, + "dependencies": { + "asn1": "^0.2.6", + "bcrypt-pbkdf": "^1.0.2" + }, + "engines": { + "node": ">=10.16.0" + }, + "optionalDependencies": { + "cpu-features": "~0.0.10", + "nan": "^2.23.0" + } + }, "node_modules/stable-hash": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", @@ -8983,14 +12582,26 @@ "node": ">= 0.4" } }, - "node_modules/streamsearch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", - "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, "engines": { - "node": ">=10.0.0" + "node": ">=8" } }, + "node_modules/string-width/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, "node_modules/string.prototype.includes": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", @@ -9104,6 +12715,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-bom": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", @@ -9127,6 +12750,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strnum": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz", + "integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/styled-jsx": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", @@ -9197,6 +12832,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tailwind-merge": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.0.tgz", @@ -9359,6 +13001,12 @@ "node": ">=0.8" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -9421,36 +13069,36 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/tinypool": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", - "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" - } - }, "node_modules/tinyrainbow": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", - "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", "dev": true, "license": "MIT", "engines": { "node": ">=14.0.0" } }, - "node_modules/tinyspy": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", - "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "node_modules/tldts": { + "version": "7.0.19", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.19.tgz", + "integrity": "sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==", "dev": true, "license": "MIT", - "engines": { - "node": ">=14.0.0" + "dependencies": { + "tldts-core": "^7.0.19" + }, + "bin": { + "tldts": "bin/cli.js" } }, + "node_modules/tldts-core": { + "version": "7.0.19", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.19.tgz", + "integrity": "sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A==", + "dev": true, + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -9463,6 +13111,32 @@ "node": ">=8.0" } }, + "node_modules/tough-cookie": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", + "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/ts-api-utils": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", @@ -9476,6 +13150,21 @@ "typescript": ">=4.8.4" } }, + "node_modules/ts-essentials": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/ts-essentials/-/ts-essentials-10.1.1.tgz", + "integrity": "sha512-4aTB7KLHKmUvkjNj8V+EdnmuVTiECzn3K+zIbRthumvHu+j44x3w63xpfs0JL3NGIzGXqoQ7AV591xHO+XrOTw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "typescript": ">=4.5.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", @@ -9521,6 +13210,12 @@ "fsevents": "~2.3.3" } }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", + "license": "Unlicense" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -9626,6 +13321,30 @@ "node": ">=14.17" } }, + "node_modules/typescript-eslint": { + "version": "8.52.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.52.0.tgz", + "integrity": "sha512-atlQQJ2YkO4pfTVQmQ+wvYQwexPDOIgo+RaVcD7gHgzy/IQA+XTyuxNM9M9TVXvttkF7koBHmcwisKdOAf2EcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.52.0", + "@typescript-eslint/parser": "8.52.0", + "@typescript-eslint/typescript-estree": "8.52.0", + "@typescript-eslint/utils": "8.52.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, "node_modules/unbox-primitive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", @@ -9645,6 +13364,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/undici": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.18.2.tgz", + "integrity": "sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -9786,22 +13514,47 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/vite": { - "version": "5.4.21", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", - "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "^0.21.3", - "postcss": "^8.4.43", - "rollup": "^4.20.0" + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" }, "bin": { "vite": "bin/vite.js" }, "engines": { - "node": "^18.0.0 || >=20.0.0" + "node": "^20.19.0 || >=22.12.0" }, "funding": { "url": "https://github.com/vitejs/vite?sponsor=1" @@ -9810,19 +13563,25 @@ "fsevents": "~2.3.3" }, "peerDependencies": { - "@types/node": "^18.0.0 || >=20.0.0", - "less": "*", + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.4.0" + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" }, "peerDependenciesMeta": { "@types/node": { "optional": true }, + "jiti": { + "optional": true + }, "less": { "optional": true }, @@ -9843,511 +13602,91 @@ }, "terser": { "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true } } }, - "node_modules/vite-node": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", - "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, "license": "MIT", - "dependencies": { - "cac": "^6.7.14", - "debug": "^4.3.7", - "es-module-lexer": "^1.5.4", - "pathe": "^1.1.2", - "vite": "^5.0.0" + "engines": { + "node": ">=12.0.0" }, - "bin": { - "vite-node": "vite-node.mjs" + "peerDependencies": { + "picomatch": "^3 || ^4" }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } } }, - "node_modules/vite-node/node_modules/pathe": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", - "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/vite/node_modules/@esbuild/aix-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", - "cpu": [ - "ppc64" - ], + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/win32-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/esbuild": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, "engines": { "node": ">=12" }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.21.5", - "@esbuild/android-arm": "0.21.5", - "@esbuild/android-arm64": "0.21.5", - "@esbuild/android-x64": "0.21.5", - "@esbuild/darwin-arm64": "0.21.5", - "@esbuild/darwin-x64": "0.21.5", - "@esbuild/freebsd-arm64": "0.21.5", - "@esbuild/freebsd-x64": "0.21.5", - "@esbuild/linux-arm": "0.21.5", - "@esbuild/linux-arm64": "0.21.5", - "@esbuild/linux-ia32": "0.21.5", - "@esbuild/linux-loong64": "0.21.5", - "@esbuild/linux-mips64el": "0.21.5", - "@esbuild/linux-ppc64": "0.21.5", - "@esbuild/linux-riscv64": "0.21.5", - "@esbuild/linux-s390x": "0.21.5", - "@esbuild/linux-x64": "0.21.5", - "@esbuild/netbsd-x64": "0.21.5", - "@esbuild/openbsd-x64": "0.21.5", - "@esbuild/sunos-x64": "0.21.5", - "@esbuild/win32-arm64": "0.21.5", - "@esbuild/win32-ia32": "0.21.5", - "@esbuild/win32-x64": "0.21.5" + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" } }, "node_modules/vitest": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", - "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.16.tgz", + "integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "2.1.9", - "@vitest/mocker": "2.1.9", - "@vitest/pretty-format": "^2.1.9", - "@vitest/runner": "2.1.9", - "@vitest/snapshot": "2.1.9", - "@vitest/spy": "2.1.9", - "@vitest/utils": "2.1.9", - "chai": "^5.1.2", - "debug": "^4.3.7", - "expect-type": "^1.1.0", - "magic-string": "^0.30.12", - "pathe": "^1.1.2", - "std-env": "^3.8.0", + "@vitest/expect": "4.0.16", + "@vitest/mocker": "4.0.16", + "@vitest/pretty-format": "4.0.16", + "@vitest/runner": "4.0.16", + "@vitest/snapshot": "4.0.16", + "@vitest/spy": "4.0.16", + "@vitest/utils": "4.0.16", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", "tinybench": "^2.9.0", - "tinyexec": "^0.3.1", - "tinypool": "^1.0.1", - "tinyrainbow": "^1.2.0", - "vite": "^5.0.0", - "vite-node": "2.1.9", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", "why-is-node-running": "^2.3.0" }, "bin": { "vitest": "vitest.mjs" }, "engines": { - "node": "^18.0.0 || >=20.0.0" + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { "@edge-runtime/vm": "*", - "@types/node": "^18.0.0 || >=20.0.0", - "@vitest/browser": "2.1.9", - "@vitest/ui": "2.1.9", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.16", + "@vitest/browser-preview": "4.0.16", + "@vitest/browser-webdriverio": "4.0.16", + "@vitest/ui": "4.0.16", "happy-dom": "*", "jsdom": "*" }, @@ -10355,10 +13694,19 @@ "@edge-runtime/vm": { "optional": true }, + "@opentelemetry/api": { + "optional": true + }, "@types/node": { "optional": true }, - "@vitest/browser": { + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { "optional": true }, "@vitest/ui": { @@ -10372,19 +13720,93 @@ } } }, - "node_modules/vitest/node_modules/pathe": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", - "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "node_modules/vitest-mock-extended": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vitest-mock-extended/-/vitest-mock-extended-3.1.0.tgz", + "integrity": "sha512-vCM0VkuocOUBwwqwV7JB7YStw07pqeKvEIrZnR8l3PtwYi6rAAJAyJACeC1UYNfbQWi85nz7EdiXWBFI5hll2g==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "ts-essentials": ">=10.0.0" + }, + "peerDependencies": { + "typescript": "3.x || 4.x || 5.x", + "vitest": ">=3.0.0" + } }, - "node_modules/vitest/node_modules/tinyexec": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", - "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz", + "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.0" + }, + "engines": { + "node": ">=20" + } }, "node_modules/which": { "version": "2.0.2", @@ -10469,6 +13891,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/which-typed-array": { "version": "1.1.19", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", @@ -10518,6 +13946,159 @@ "node": ">=0.10.0" } }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/yargs/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yargs/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -10540,6 +14121,19 @@ "url": "https://github.com/sponsors/colinhacks" } }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, "node_modules/zustand": { "version": "5.0.9", "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.9.tgz", diff --git a/package.json b/package.json index a687366..2cb0bd5 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "next lint", + "lint": "eslint .", "typecheck": "tsc --noEmit", "format": "prettier --write .", "db:generate": "prisma generate", @@ -19,52 +19,69 @@ }, "dependencies": { "@auth/prisma-adapter": "^2.7.4", + "@aws-sdk/client-s3": "^3.968.0", "@hookform/resolvers": "^3.9.1", "@prisma/client": "^6.2.1", - "@radix-ui/react-avatar": "^1.1.2", + "@radix-ui/react-avatar": "^1.1.11", + "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.4", "@radix-ui/react-dropdown-menu": "^2.1.4", "@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-label": "^2.1.1", "@radix-ui/react-popover": "^1.1.4", "@radix-ui/react-select": "^2.1.4", - "@radix-ui/react-separator": "^1.1.1", + "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.1.1", - "@radix-ui/react-tabs": "^1.1.2", + "@radix-ui/react-switch": "^1.2.6", + "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-toast": "^1.2.4", "@radix-ui/react-tooltip": "^1.1.6", "@tanstack/react-query": "^5.64.2", "@tanstack/react-table": "^8.20.6", + "@types/ssh2": "^1.15.5", "bcryptjs": "^2.4.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "date-fns": "^4.1.0", "lucide-react": "^0.469.0", - "next": "15.1.4", - "next-auth": "5.0.0-beta.25", + "next": "16.1.1", + "next-auth": "5.0.0-beta.30", "next-themes": "^0.4.4", - "react": "^19.0.0", - "react-dom": "^19.0.0", + "nodemailer": "^7.0.12", + "otplib": "^13.1.0", + "qrcode": "^1.5.4", + "react": "19.2.3", + "react-dom": "19.2.3", "react-hook-form": "^7.54.2", + "recharts": "^3.6.0", + "ssh2": "^1.17.0", "tailwind-merge": "^2.6.0", "tailwindcss-animate": "^1.0.7", + "undici": "^7.18.2", "zod": "^3.24.1", "zustand": "^5.0.3" }, "devDependencies": { "@eslint/eslintrc": "^3.3.3", + "@testing-library/react": "^16.3.1", "@types/bcryptjs": "^2.4.6", "@types/node": "^22.10.5", + "@types/nodemailer": "^7.0.5", + "@types/qrcode": "^1.5.6", "@types/react": "^19.0.4", "@types/react-dom": "^19.0.2", + "@vitejs/plugin-react": "^5.1.2", "autoprefixer": "^10.4.20", "eslint": "^9.17.0", - "eslint-config-next": "15.1.4", + "eslint-config-next": "16.1.1", + "jsdom": "^27.0.1", "postcss": "^8.4.49", "prettier": "^3.4.2", "prisma": "^6.2.1", "tailwindcss": "^3.4.17", "tsx": "^4.19.2", "typescript": "^5.7.3", - "vitest": "^2.1.8" + "vitest": "^4.0.16", + "vitest-mock-extended": "^3.1.0" } } diff --git a/prisma/migrations/20260106134433_init/migration.sql b/prisma/migrations/20260106134433_init/migration.sql new file mode 100644 index 0000000..f70c443 --- /dev/null +++ b/prisma/migrations/20260106134433_init/migration.sql @@ -0,0 +1,272 @@ +-- CreateEnum +CREATE TYPE "UserStatus" AS ENUM ('PENDING_VERIFICATION', 'ACTIVE', 'SUSPENDED'); + +-- CreateEnum +CREATE TYPE "StaffRole" AS ENUM ('ADMIN', 'SUPPORT'); + +-- CreateEnum +CREATE TYPE "SubscriptionPlan" AS ENUM ('TRIAL', 'STARTER', 'PRO', 'ENTERPRISE'); + +-- CreateEnum +CREATE TYPE "SubscriptionTier" AS ENUM ('HUB_DASHBOARD', 'ADVANCED'); + +-- CreateEnum +CREATE TYPE "SubscriptionStatus" AS ENUM ('TRIAL', 'ACTIVE', 'CANCELED', 'PAST_DUE'); + +-- CreateEnum +CREATE TYPE "OrderStatus" AS ENUM ('PAYMENT_CONFIRMED', 'AWAITING_SERVER', 'SERVER_READY', 'DNS_PENDING', 'DNS_READY', 'PROVISIONING', 'FULFILLED', 'EMAIL_CONFIGURED', 'FAILED'); + +-- CreateEnum +CREATE TYPE "JobStatus" AS ENUM ('PENDING', 'CLAIMED', 'RUNNING', 'COMPLETED', 'FAILED', 'DEAD'); + +-- CreateEnum +CREATE TYPE "LogLevel" AS ENUM ('DEBUG', 'INFO', 'WARN', 'ERROR'); + +-- CreateEnum +CREATE TYPE "ServerConnectionStatus" AS ENUM ('PENDING', 'REGISTERED', 'ONLINE', 'OFFLINE'); + +-- CreateEnum +CREATE TYPE "CommandStatus" AS ENUM ('PENDING', 'SENT', 'EXECUTING', 'COMPLETED', 'FAILED'); + +-- CreateTable +CREATE TABLE "users" ( + "id" TEXT NOT NULL, + "email" TEXT NOT NULL, + "password_hash" TEXT NOT NULL, + "name" TEXT, + "company" TEXT, + "status" "UserStatus" NOT NULL DEFAULT 'PENDING_VERIFICATION', + "email_verified" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "users_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "staff" ( + "id" TEXT NOT NULL, + "email" TEXT NOT NULL, + "password_hash" TEXT NOT NULL, + "name" TEXT NOT NULL, + "role" "StaffRole" NOT NULL DEFAULT 'SUPPORT', + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "staff_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "subscriptions" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "plan" "SubscriptionPlan" NOT NULL DEFAULT 'TRIAL', + "tier" "SubscriptionTier" NOT NULL DEFAULT 'HUB_DASHBOARD', + "token_limit" INTEGER NOT NULL DEFAULT 10000, + "tokens_used" INTEGER NOT NULL DEFAULT 0, + "trial_ends_at" TIMESTAMP(3), + "stripe_customer_id" TEXT, + "stripe_subscription_id" TEXT, + "status" "SubscriptionStatus" NOT NULL DEFAULT 'TRIAL', + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "subscriptions_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "orders" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "status" "OrderStatus" NOT NULL DEFAULT 'PAYMENT_CONFIRMED', + "tier" "SubscriptionTier" NOT NULL, + "domain" TEXT NOT NULL, + "tools" TEXT[], + "config_json" JSONB NOT NULL, + "server_ip" TEXT, + "server_password_encrypted" TEXT, + "ssh_port" INTEGER NOT NULL DEFAULT 22, + "portainer_url" TEXT, + "dashboard_url" TEXT, + "failure_reason" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "server_ready_at" TIMESTAMP(3), + "provisioning_started_at" TIMESTAMP(3), + "completed_at" TIMESTAMP(3), + + CONSTRAINT "orders_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "provisioning_logs" ( + "id" TEXT NOT NULL, + "order_id" TEXT NOT NULL, + "level" "LogLevel" NOT NULL DEFAULT 'INFO', + "message" TEXT NOT NULL, + "step" TEXT, + "timestamp" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "provisioning_logs_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "provisioning_jobs" ( + "id" TEXT NOT NULL, + "order_id" TEXT NOT NULL, + "job_type" TEXT NOT NULL, + "status" "JobStatus" NOT NULL DEFAULT 'PENDING', + "priority" INTEGER NOT NULL DEFAULT 0, + "claimed_at" TIMESTAMP(3), + "claimed_by" TEXT, + "container_name" TEXT, + "attempt" INTEGER NOT NULL DEFAULT 1, + "max_attempts" INTEGER NOT NULL DEFAULT 3, + "next_retry_at" TIMESTAMP(3), + "config_snapshot" JSONB NOT NULL, + "runner_token_hash" TEXT, + "result" JSONB, + "error" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "completed_at" TIMESTAMP(3), + + CONSTRAINT "provisioning_jobs_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "job_logs" ( + "id" TEXT NOT NULL, + "job_id" TEXT NOT NULL, + "level" "LogLevel" NOT NULL DEFAULT 'INFO', + "message" TEXT NOT NULL, + "step" TEXT, + "progress" INTEGER, + "timestamp" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "job_logs_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "token_usage" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "instance_id" TEXT, + "operation" TEXT NOT NULL, + "tokens_input" INTEGER NOT NULL, + "tokens_output" INTEGER NOT NULL, + "model" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "token_usage_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "runner_tokens" ( + "id" TEXT NOT NULL, + "token_hash" TEXT NOT NULL, + "name" TEXT NOT NULL, + "is_active" BOOLEAN NOT NULL DEFAULT true, + "last_used" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "runner_tokens_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "server_connections" ( + "id" TEXT NOT NULL, + "order_id" TEXT NOT NULL, + "registration_token" TEXT NOT NULL, + "hub_api_key" TEXT, + "orchestrator_url" TEXT, + "agent_version" TEXT, + "status" "ServerConnectionStatus" NOT NULL DEFAULT 'PENDING', + "registered_at" TIMESTAMP(3), + "last_heartbeat" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "server_connections_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "remote_commands" ( + "id" TEXT NOT NULL, + "server_connection_id" TEXT NOT NULL, + "type" TEXT NOT NULL, + "payload" JSONB NOT NULL, + "status" "CommandStatus" NOT NULL DEFAULT 'PENDING', + "result" JSONB, + "error_message" TEXT, + "queued_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "sent_at" TIMESTAMP(3), + "executed_at" TIMESTAMP(3), + "completed_at" TIMESTAMP(3), + "initiated_by" TEXT, + + CONSTRAINT "remote_commands_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "users_email_key" ON "users"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "staff_email_key" ON "staff"("email"); + +-- CreateIndex +CREATE INDEX "provisioning_logs_order_id_timestamp_idx" ON "provisioning_logs"("order_id", "timestamp"); + +-- CreateIndex +CREATE INDEX "provisioning_jobs_status_priority_created_at_idx" ON "provisioning_jobs"("status", "priority", "created_at"); + +-- CreateIndex +CREATE INDEX "provisioning_jobs_order_id_idx" ON "provisioning_jobs"("order_id"); + +-- CreateIndex +CREATE INDEX "job_logs_job_id_timestamp_idx" ON "job_logs"("job_id", "timestamp"); + +-- CreateIndex +CREATE INDEX "token_usage_user_id_created_at_idx" ON "token_usage"("user_id", "created_at"); + +-- CreateIndex +CREATE UNIQUE INDEX "runner_tokens_token_hash_key" ON "runner_tokens"("token_hash"); + +-- CreateIndex +CREATE UNIQUE INDEX "server_connections_order_id_key" ON "server_connections"("order_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "server_connections_registration_token_key" ON "server_connections"("registration_token"); + +-- CreateIndex +CREATE UNIQUE INDEX "server_connections_hub_api_key_key" ON "server_connections"("hub_api_key"); + +-- CreateIndex +CREATE INDEX "remote_commands_server_connection_id_status_idx" ON "remote_commands"("server_connection_id", "status"); + +-- CreateIndex +CREATE INDEX "remote_commands_status_queued_at_idx" ON "remote_commands"("status", "queued_at"); + +-- AddForeignKey +ALTER TABLE "subscriptions" ADD CONSTRAINT "subscriptions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "orders" ADD CONSTRAINT "orders_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "provisioning_logs" ADD CONSTRAINT "provisioning_logs_order_id_fkey" FOREIGN KEY ("order_id") REFERENCES "orders"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "provisioning_jobs" ADD CONSTRAINT "provisioning_jobs_order_id_fkey" FOREIGN KEY ("order_id") REFERENCES "orders"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "job_logs" ADD CONSTRAINT "job_logs_job_id_fkey" FOREIGN KEY ("job_id") REFERENCES "provisioning_jobs"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "token_usage" ADD CONSTRAINT "token_usage_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "server_connections" ADD CONSTRAINT "server_connections_order_id_fkey" FOREIGN KEY ("order_id") REFERENCES "orders"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "remote_commands" ADD CONSTRAINT "remote_commands_server_connection_id_fkey" FOREIGN KEY ("server_connection_id") REFERENCES "server_connections"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260106214805_add_system_settings_and_order_provisioning_fields/migration.sql b/prisma/migrations/20260106214805_add_system_settings_and_order_provisioning_fields/migration.sql new file mode 100644 index 0000000..ab81e54 --- /dev/null +++ b/prisma/migrations/20260106214805_add_system_settings_and_order_provisioning_fields/migration.sql @@ -0,0 +1,20 @@ +-- AlterTable +ALTER TABLE "orders" ADD COLUMN "company_name" TEXT, +ADD COLUMN "customer" TEXT, +ADD COLUMN "license_key" TEXT; + +-- CreateTable +CREATE TABLE "system_settings" ( + "id" TEXT NOT NULL, + "key" TEXT NOT NULL, + "value" TEXT NOT NULL, + "encrypted" BOOLEAN NOT NULL DEFAULT false, + "category" TEXT NOT NULL DEFAULT 'general', + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "system_settings_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "system_settings_key_key" ON "system_settings"("key"); diff --git a/prisma/migrations/20260107115149_phase2_automation_dns_netcup/migration.sql b/prisma/migrations/20260107115149_phase2_automation_dns_netcup/migration.sql new file mode 100644 index 0000000..3d2d14e --- /dev/null +++ b/prisma/migrations/20260107115149_phase2_automation_dns_netcup/migration.sql @@ -0,0 +1,57 @@ +-- CreateEnum +CREATE TYPE "AutomationMode" AS ENUM ('AUTO', 'MANUAL', 'PAUSED'); + +-- CreateEnum +CREATE TYPE "DnsRecordStatus" AS ENUM ('PENDING', 'VERIFIED', 'MISMATCH', 'NOT_FOUND', 'ERROR', 'SKIPPED'); + +-- AlterTable +ALTER TABLE "orders" ADD COLUMN "automationMode" "AutomationMode" NOT NULL DEFAULT 'MANUAL', +ADD COLUMN "automation_paused_at" TIMESTAMP(3), +ADD COLUMN "automation_paused_reason" TEXT, +ADD COLUMN "dns_verified_at" TIMESTAMP(3), +ADD COLUMN "netcup_server_id" TEXT, +ADD COLUMN "source" TEXT; + +-- CreateTable +CREATE TABLE "dns_verifications" ( + "id" TEXT NOT NULL, + "order_id" TEXT NOT NULL, + "wildcard_passed" BOOLEAN NOT NULL DEFAULT false, + "manual_override" BOOLEAN NOT NULL DEFAULT false, + "all_passed" BOOLEAN NOT NULL DEFAULT false, + "total_subdomains" INTEGER NOT NULL DEFAULT 0, + "passed_count" INTEGER NOT NULL DEFAULT 0, + "last_checked_at" TIMESTAMP(3), + "verified_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "dns_verifications_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "dns_records" ( + "id" TEXT NOT NULL, + "dns_verification_id" TEXT NOT NULL, + "subdomain" TEXT NOT NULL, + "full_domain" TEXT NOT NULL, + "expected_ip" TEXT NOT NULL, + "resolved_ip" TEXT, + "status" "DnsRecordStatus" NOT NULL DEFAULT 'PENDING', + "error_message" TEXT, + "checked_at" TIMESTAMP(3), + + CONSTRAINT "dns_records_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "dns_verifications_order_id_key" ON "dns_verifications"("order_id"); + +-- CreateIndex +CREATE INDEX "dns_records_dns_verification_id_idx" ON "dns_records"("dns_verification_id"); + +-- AddForeignKey +ALTER TABLE "dns_verifications" ADD CONSTRAINT "dns_verifications_order_id_fkey" FOREIGN KEY ("order_id") REFERENCES "orders"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "dns_records" ADD CONSTRAINT "dns_records_dns_verification_id_fkey" FOREIGN KEY ("dns_verification_id") REFERENCES "dns_verifications"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260107145050_add_portainer_credentials/migration.sql b/prisma/migrations/20260107145050_add_portainer_credentials/migration.sql new file mode 100644 index 0000000..c06161c --- /dev/null +++ b/prisma/migrations/20260107145050_add_portainer_credentials/migration.sql @@ -0,0 +1,4 @@ +-- AlterTable +ALTER TABLE "orders" ADD COLUMN "credentials_synced_at" TIMESTAMP(3), +ADD COLUMN "portainer_password_enc" TEXT, +ADD COLUMN "portainer_username" TEXT; diff --git a/prisma/migrations/20260111160942_add_enterprise_clients/migration.sql b/prisma/migrations/20260111160942_add_enterprise_clients/migration.sql new file mode 100644 index 0000000..282698d --- /dev/null +++ b/prisma/migrations/20260111160942_add_enterprise_clients/migration.sql @@ -0,0 +1,143 @@ +-- CreateEnum +CREATE TYPE "ErrorSeverity" AS ENUM ('INFO', 'WARNING', 'ERROR', 'CRITICAL'); + +-- CreateTable +CREATE TABLE "enterprise_clients" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "company_name" TEXT, + "contact_email" TEXT NOT NULL, + "contact_phone" TEXT, + "notes" TEXT, + "is_active" BOOLEAN NOT NULL DEFAULT true, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "enterprise_clients_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "enterprise_servers" ( + "id" TEXT NOT NULL, + "client_id" TEXT NOT NULL, + "netcup_server_id" TEXT NOT NULL, + "nickname" TEXT, + "purpose" TEXT, + "is_active" BOOLEAN NOT NULL DEFAULT true, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "portainer_url" TEXT, + "portainer_username" TEXT, + "portainer_password_enc" TEXT, + + CONSTRAINT "enterprise_servers_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "server_stats_snapshots" ( + "id" TEXT NOT NULL, + "server_id" TEXT NOT NULL, + "client_id" TEXT NOT NULL, + "timestamp" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "cpu_percent" DOUBLE PRECISION, + "memory_used_mb" DOUBLE PRECISION, + "memory_total_mb" DOUBLE PRECISION, + "disk_read_mbps" DOUBLE PRECISION, + "disk_write_mbps" DOUBLE PRECISION, + "network_in_mbps" DOUBLE PRECISION, + "network_out_mbps" DOUBLE PRECISION, + "containers_running" INTEGER, + "containers_stopped" INTEGER, + + CONSTRAINT "server_stats_snapshots_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "error_detection_rules" ( + "id" TEXT NOT NULL, + "client_id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "pattern" TEXT NOT NULL, + "severity" "ErrorSeverity" NOT NULL DEFAULT 'WARNING', + "is_active" BOOLEAN NOT NULL DEFAULT true, + "description" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "error_detection_rules_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "detected_errors" ( + "id" TEXT NOT NULL, + "server_id" TEXT NOT NULL, + "rule_id" TEXT NOT NULL, + "container_id" TEXT, + "container_name" TEXT, + "log_line" TEXT NOT NULL, + "context" TEXT, + "timestamp" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "acknowledged_at" TIMESTAMP(3), + "acknowledged_by" TEXT, + + CONSTRAINT "detected_errors_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "security_verification_codes" ( + "id" TEXT NOT NULL, + "client_id" TEXT NOT NULL, + "code" TEXT NOT NULL, + "action" TEXT NOT NULL, + "target_server_id" TEXT NOT NULL, + "expires_at" TIMESTAMP(3) NOT NULL, + "used_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "security_verification_codes_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "enterprise_servers_client_id_idx" ON "enterprise_servers"("client_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "enterprise_servers_client_id_netcup_server_id_key" ON "enterprise_servers"("client_id", "netcup_server_id"); + +-- CreateIndex +CREATE INDEX "server_stats_snapshots_server_id_timestamp_idx" ON "server_stats_snapshots"("server_id", "timestamp"); + +-- CreateIndex +CREATE INDEX "server_stats_snapshots_client_id_timestamp_idx" ON "server_stats_snapshots"("client_id", "timestamp"); + +-- CreateIndex +CREATE INDEX "error_detection_rules_client_id_idx" ON "error_detection_rules"("client_id"); + +-- CreateIndex +CREATE INDEX "detected_errors_server_id_timestamp_idx" ON "detected_errors"("server_id", "timestamp"); + +-- CreateIndex +CREATE INDEX "detected_errors_rule_id_timestamp_idx" ON "detected_errors"("rule_id", "timestamp"); + +-- CreateIndex +CREATE INDEX "security_verification_codes_client_id_code_idx" ON "security_verification_codes"("client_id", "code"); + +-- AddForeignKey +ALTER TABLE "enterprise_servers" ADD CONSTRAINT "enterprise_servers_client_id_fkey" FOREIGN KEY ("client_id") REFERENCES "enterprise_clients"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "server_stats_snapshots" ADD CONSTRAINT "server_stats_snapshots_server_id_fkey" FOREIGN KEY ("server_id") REFERENCES "enterprise_servers"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "server_stats_snapshots" ADD CONSTRAINT "server_stats_snapshots_client_id_fkey" FOREIGN KEY ("client_id") REFERENCES "enterprise_clients"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "error_detection_rules" ADD CONSTRAINT "error_detection_rules_client_id_fkey" FOREIGN KEY ("client_id") REFERENCES "enterprise_clients"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "detected_errors" ADD CONSTRAINT "detected_errors_server_id_fkey" FOREIGN KEY ("server_id") REFERENCES "enterprise_servers"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "detected_errors" ADD CONSTRAINT "detected_errors_rule_id_fkey" FOREIGN KEY ("rule_id") REFERENCES "error_detection_rules"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "security_verification_codes" ADD CONSTRAINT "security_verification_codes_client_id_fkey" FOREIGN KEY ("client_id") REFERENCES "enterprise_clients"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260111201955_add_intelligent_error_tracking/migration.sql b/prisma/migrations/20260111201955_add_intelligent_error_tracking/migration.sql new file mode 100644 index 0000000..0ebb723 --- /dev/null +++ b/prisma/migrations/20260111201955_add_intelligent_error_tracking/migration.sql @@ -0,0 +1,84 @@ +-- CreateEnum +CREATE TYPE "ContainerEventType" AS ENUM ('CRASH', 'OOM_KILLED', 'RESTART', 'STOPPED'); + +-- CreateTable +CREATE TABLE "log_scan_positions" ( + "id" TEXT NOT NULL, + "server_id" TEXT NOT NULL, + "container_id" TEXT NOT NULL, + "last_line_count" INTEGER NOT NULL DEFAULT 0, + "last_log_hash" TEXT, + "last_scanned_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "log_scan_positions_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "container_state_snapshots" ( + "id" TEXT NOT NULL, + "server_id" TEXT NOT NULL, + "container_id" TEXT NOT NULL, + "container_name" TEXT NOT NULL, + "state" TEXT NOT NULL, + "exit_code" INTEGER, + "captured_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "container_state_snapshots_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "container_events" ( + "id" TEXT NOT NULL, + "server_id" TEXT NOT NULL, + "container_id" TEXT NOT NULL, + "container_name" TEXT NOT NULL, + "event_type" "ContainerEventType" NOT NULL, + "exit_code" INTEGER, + "details" TEXT, + "acknowledged_at" TIMESTAMP(3), + "acknowledged_by" TEXT, + "timestamp" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "container_events_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "notification_settings" ( + "id" TEXT NOT NULL, + "client_id" TEXT NOT NULL, + "enabled" BOOLEAN NOT NULL DEFAULT false, + "critical_errors_only" BOOLEAN NOT NULL DEFAULT true, + "container_crashes" BOOLEAN NOT NULL DEFAULT true, + "recipients" TEXT[] DEFAULT ARRAY[]::TEXT[], + "cooldown_minutes" INTEGER NOT NULL DEFAULT 30, + "last_notified_at" TIMESTAMP(3), + + CONSTRAINT "notification_settings_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "log_scan_positions_server_id_container_id_key" ON "log_scan_positions"("server_id", "container_id"); + +-- CreateIndex +CREATE INDEX "container_state_snapshots_server_id_container_id_captured_a_idx" ON "container_state_snapshots"("server_id", "container_id", "captured_at"); + +-- CreateIndex +CREATE INDEX "container_events_server_id_timestamp_idx" ON "container_events"("server_id", "timestamp"); + +-- CreateIndex +CREATE INDEX "container_events_event_type_acknowledged_at_idx" ON "container_events"("event_type", "acknowledged_at"); + +-- CreateIndex +CREATE UNIQUE INDEX "notification_settings_client_id_key" ON "notification_settings"("client_id"); + +-- AddForeignKey +ALTER TABLE "log_scan_positions" ADD CONSTRAINT "log_scan_positions_server_id_fkey" FOREIGN KEY ("server_id") REFERENCES "enterprise_servers"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "container_state_snapshots" ADD CONSTRAINT "container_state_snapshots_server_id_fkey" FOREIGN KEY ("server_id") REFERENCES "enterprise_servers"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "container_events" ADD CONSTRAINT "container_events_server_id_fkey" FOREIGN KEY ("server_id") REFERENCES "enterprise_servers"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "notification_settings" ADD CONSTRAINT "notification_settings_client_id_fkey" FOREIGN KEY ("client_id") REFERENCES "enterprise_clients"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260114112809_add_2fa_and_staff_management/migration.sql b/prisma/migrations/20260114112809_add_2fa_and_staff_management/migration.sql new file mode 100644 index 0000000..d52cac5 --- /dev/null +++ b/prisma/migrations/20260114112809_add_2fa_and_staff_management/migration.sql @@ -0,0 +1,46 @@ +-- CreateEnum +CREATE TYPE "StaffStatus" AS ENUM ('ACTIVE', 'SUSPENDED'); + +-- AlterEnum +-- This migration adds more than one value to an enum. +-- With PostgreSQL versions 11 and earlier, this is not possible +-- in a single migration. This can be worked around by creating +-- multiple migrations, each migration adding only one value to +-- the enum. + + +ALTER TYPE "StaffRole" ADD VALUE 'OWNER'; +ALTER TYPE "StaffRole" ADD VALUE 'MANAGER'; + +-- AlterTable +ALTER TABLE "staff" ADD COLUMN "backup_codes_enc" TEXT, +ADD COLUMN "invited_by" TEXT, +ADD COLUMN "status" "StaffStatus" NOT NULL DEFAULT 'ACTIVE', +ADD COLUMN "two_factor_enabled" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "two_factor_secret_enc" TEXT, +ADD COLUMN "two_factor_verified_at" TIMESTAMP(3); + +-- AlterTable +ALTER TABLE "users" ADD COLUMN "backup_codes_enc" TEXT, +ADD COLUMN "two_factor_enabled" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "two_factor_secret_enc" TEXT, +ADD COLUMN "two_factor_verified_at" TIMESTAMP(3); + +-- CreateTable +CREATE TABLE "staff_invitations" ( + "id" TEXT NOT NULL, + "email" TEXT NOT NULL, + "role" "StaffRole" NOT NULL DEFAULT 'SUPPORT', + "token" TEXT NOT NULL, + "expires_at" TIMESTAMP(3) NOT NULL, + "invited_by" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "staff_invitations_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "staff_invitations_email_key" ON "staff_invitations"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "staff_invitations_token_key" ON "staff_invitations"("token"); diff --git a/prisma/migrations/20260114115959_add_notification_cooldown/migration.sql b/prisma/migrations/20260114115959_add_notification_cooldown/migration.sql new file mode 100644 index 0000000..4e53088 --- /dev/null +++ b/prisma/migrations/20260114115959_add_notification_cooldown/migration.sql @@ -0,0 +1,11 @@ +-- CreateTable +CREATE TABLE "notification_cooldowns" ( + "id" TEXT NOT NULL, + "type" TEXT NOT NULL, + "last_sent_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "notification_cooldowns_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "notification_cooldowns_type_key" ON "notification_cooldowns"("type"); diff --git a/prisma/migrations/20260114123526_add_staff_profile_photo/migration.sql b/prisma/migrations/20260114123526_add_staff_profile_photo/migration.sql new file mode 100644 index 0000000..ea5be9c --- /dev/null +++ b/prisma/migrations/20260114123526_add_staff_profile_photo/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "staff" ADD COLUMN "profile_photo_key" TEXT; diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..044d57c --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "postgresql" diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4e979fb..928c6fb 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -21,8 +21,15 @@ enum UserStatus { } enum StaffRole { - ADMIN - SUPPORT + OWNER // Full access, cannot be deleted + ADMIN // Full access, can manage staff + MANAGER // Orders + servers, no staff/settings + SUPPORT // View only + limited actions +} + +enum StaffStatus { + ACTIVE + SUSPENDED } enum SubscriptionPlan { @@ -56,6 +63,21 @@ enum OrderStatus { FAILED } +enum AutomationMode { + AUTO // Website orders - self-executing + MANUAL // Staff-created - step-by-step + PAUSED // Stopped for intervention +} + +enum DnsRecordStatus { + PENDING + VERIFIED + MISMATCH + NOT_FOUND + ERROR + SKIPPED // For wildcard pass or manual override +} + enum JobStatus { PENDING CLAIMED @@ -72,6 +94,37 @@ enum LogLevel { ERROR } +enum ServerConnectionStatus { + PENDING // Awaiting orchestrator registration + REGISTERED // Orchestrator has registered + ONLINE // Recent heartbeat received + OFFLINE // No recent heartbeat +} + +enum CommandStatus { + PENDING + SENT + EXECUTING + COMPLETED + FAILED +} + +// ============================================================================ +// SYSTEM SETTINGS +// ============================================================================ + +model SystemSetting { + id String @id @default(cuid()) + key String @unique + value String // Encrypted for sensitive values + encrypted Boolean @default(false) + category String @default("general") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@map("system_settings") +} + // ============================================================================ // USER & STAFF MODELS // ============================================================================ @@ -87,6 +140,12 @@ model User { createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") + // 2FA fields + twoFactorEnabled Boolean @default(false) @map("two_factor_enabled") + twoFactorSecretEnc String? @map("two_factor_secret_enc") + twoFactorVerifiedAt DateTime? @map("two_factor_verified_at") + backupCodesEnc String? @map("backup_codes_enc") + subscriptions Subscription[] orders Order[] tokenUsage TokenUsage[] @@ -95,17 +154,40 @@ model User { } model Staff { - id String @id @default(cuid()) - email String @unique - passwordHash String @map("password_hash") + id String @id @default(cuid()) + email String @unique + passwordHash String @map("password_hash") name String - role StaffRole @default(SUPPORT) - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") + role StaffRole @default(SUPPORT) + status StaffStatus @default(ACTIVE) + invitedBy String? @map("invited_by") // Staff ID who sent invite + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + // Profile + profilePhotoKey String? @map("profile_photo_key") // S3/MinIO key for profile photo + + // 2FA fields + twoFactorEnabled Boolean @default(false) @map("two_factor_enabled") + twoFactorSecretEnc String? @map("two_factor_secret_enc") + twoFactorVerifiedAt DateTime? @map("two_factor_verified_at") + backupCodesEnc String? @map("backup_codes_enc") @@map("staff") } +model StaffInvitation { + id String @id @default(cuid()) + email String @unique + role StaffRole @default(SUPPORT) + token String @unique + expiresAt DateTime @map("expires_at") + invitedBy String @map("invited_by") + createdAt DateTime @default(now()) @map("created_at") + + @@map("staff_invitations") +} + // ============================================================================ // SUBSCRIPTION & BILLING // ============================================================================ @@ -134,38 +216,98 @@ model Subscription { // ============================================================================ model Order { - id String @id @default(cuid()) - userId String @map("user_id") - status OrderStatus @default(PAYMENT_CONFIRMED) + id String @id @default(cuid()) + userId String @map("user_id") + status OrderStatus @default(PAYMENT_CONFIRMED) tier SubscriptionTier domain String tools String[] - configJson Json @map("config_json") + configJson Json @map("config_json") + + // Automation mode + automationMode AutomationMode @default(MANUAL) + automationPausedAt DateTime? @map("automation_paused_at") + automationPausedReason String? @map("automation_paused_reason") + source String? // "website" | "staff" | "api" + + // Customer/provisioning config + customer String? @map("customer") // Short name for subdomains (e.g., "acme") + companyName String? @map("company_name") // Display name (e.g., "Acme Corporation") + licenseKey String? @map("license_key") // Generated: lb_inst_xxx // Server credentials (entered by staff) - serverIp String? @map("server_ip") - serverPasswordEncrypted String? @map("server_password_encrypted") - sshPort Int @default(22) @map("ssh_port") + serverIp String? @map("server_ip") + serverPasswordEncrypted String? @map("server_password_encrypted") + sshPort Int @default(22) @map("ssh_port") + netcupServerId String? @map("netcup_server_id") // Netcup API server ID for linking // Generated after provisioning - portainerUrl String? @map("portainer_url") - dashboardUrl String? @map("dashboard_url") - failureReason String? @map("failure_reason") + portainerUrl String? @map("portainer_url") + dashboardUrl String? @map("dashboard_url") + failureReason String? @map("failure_reason") + + // Portainer credentials (encrypted, synced from agent) + portainerUsername String? @map("portainer_username") // e.g., "admin-xyz123" + portainerPasswordEnc String? @map("portainer_password_enc") // AES-256-CBC encrypted + credentialsSyncedAt DateTime? @map("credentials_synced_at") // Last sync from agent // Timestamps - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") - serverReadyAt DateTime? @map("server_ready_at") - provisioningStartedAt DateTime? @map("provisioning_started_at") - completedAt DateTime? @map("completed_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + serverReadyAt DateTime? @map("server_ready_at") + provisioningStartedAt DateTime? @map("provisioning_started_at") + completedAt DateTime? @map("completed_at") + dnsVerifiedAt DateTime? @map("dns_verified_at") - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) provisioningLogs ProvisioningLog[] jobs ProvisioningJob[] + serverConnection ServerConnection? + dnsVerification DnsVerification? @@map("orders") } +// ============================================================================ +// DNS VERIFICATION +// ============================================================================ + +model DnsVerification { + id String @id @default(cuid()) + orderId String @unique @map("order_id") + wildcardPassed Boolean @default(false) @map("wildcard_passed") + manualOverride Boolean @default(false) @map("manual_override") // Staff skipped check + allPassed Boolean @default(false) @map("all_passed") + totalSubdomains Int @default(0) @map("total_subdomains") + passedCount Int @default(0) @map("passed_count") + lastCheckedAt DateTime? @map("last_checked_at") + verifiedAt DateTime? @map("verified_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + order Order @relation(fields: [orderId], references: [id], onDelete: Cascade) + records DnsRecord[] + + @@map("dns_verifications") +} + +model DnsRecord { + id String @id @default(cuid()) + dnsVerificationId String @map("dns_verification_id") + subdomain String // "cloud" + fullDomain String @map("full_domain") // "cloud.example.com" + expectedIp String @map("expected_ip") + resolvedIp String? @map("resolved_ip") + status DnsRecordStatus @default(PENDING) + errorMessage String? @map("error_message") + checkedAt DateTime? @map("checked_at") + + dnsVerification DnsVerification @relation(fields: [dnsVerificationId], references: [id], onDelete: Cascade) + + @@index([dnsVerificationId]) + @@map("dns_records") +} + model ProvisioningLog { id String @id @default(cuid()) orderId String @map("order_id") @@ -261,3 +403,317 @@ model RunnerToken { @@map("runner_tokens") } + +// ============================================================================ +// SERVER CONNECTION (Phone-Home System) +// ============================================================================ + +model ServerConnection { + id String @id @default(cuid()) + orderId String @unique @map("order_id") + + // Registration token (generated during provisioning, used by orchestrator to register) + registrationToken String @unique @map("registration_token") + + // Hub API key (issued after successful registration, used for heartbeats/commands) + hubApiKey String? @unique @map("hub_api_key") + + // Orchestrator connection info (provided during registration) + orchestratorUrl String? @map("orchestrator_url") + agentVersion String? @map("agent_version") + + // Status tracking + status ServerConnectionStatus @default(PENDING) + registeredAt DateTime? @map("registered_at") + lastHeartbeat DateTime? @map("last_heartbeat") + + // Timestamps + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + order Order @relation(fields: [orderId], references: [id], onDelete: Cascade) + remoteCommands RemoteCommand[] + + @@map("server_connections") +} + +// ============================================================================ +// REMOTE COMMANDS (Support Backdoor) +// ============================================================================ + +model RemoteCommand { + id String @id @default(cuid()) + serverConnectionId String @map("server_connection_id") + + // Command details + type String // SHELL, RESTART_SERVICE, UPDATE, ECHO, etc. + payload Json // Command-specific payload + + // Execution tracking + status CommandStatus @default(PENDING) + result Json? // Command result + errorMessage String? @map("error_message") + + // Timestamps + queuedAt DateTime @default(now()) @map("queued_at") + sentAt DateTime? @map("sent_at") + executedAt DateTime? @map("executed_at") + completedAt DateTime? @map("completed_at") + + // Staff who initiated (for audit) + initiatedBy String? @map("initiated_by") + + serverConnection ServerConnection @relation(fields: [serverConnectionId], references: [id], onDelete: Cascade) + + @@index([serverConnectionId, status]) + @@index([status, queuedAt]) + @@map("remote_commands") +} + +// ============================================================================ +// ENTERPRISE CLIENTS +// ============================================================================ + +enum ErrorSeverity { + INFO + WARNING + ERROR + CRITICAL +} + +model EnterpriseClient { + id String @id @default(cuid()) + name String + companyName String? @map("company_name") + contactEmail String @map("contact_email") // For security codes + contactPhone String? @map("contact_phone") + notes String? @db.Text + isActive Boolean @default(true) @map("is_active") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + // Relations + servers EnterpriseServer[] + errorRules ErrorDetectionRule[] + securityCodes SecurityVerificationCode[] + statsHistory ServerStatsSnapshot[] + notificationSetting NotificationSetting? + + @@map("enterprise_clients") +} + +model EnterpriseServer { + id String @id @default(cuid()) + clientId String @map("client_id") + netcupServerId String @map("netcup_server_id") // Link to Netcup server + nickname String? // Optional friendly name + purpose String? // e.g., "Production", "Staging", "Database" + isActive Boolean @default(true) @map("is_active") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + // Portainer credentials (encrypted) + portainerUrl String? @map("portainer_url") + portainerUsername String? @map("portainer_username") + portainerPasswordEnc String? @map("portainer_password_enc") + + // Relations + client EnterpriseClient @relation(fields: [clientId], references: [id], onDelete: Cascade) + statsSnapshots ServerStatsSnapshot[] + errorLogs DetectedError[] + logScanPositions LogScanPosition[] + stateSnapshots ContainerStateSnapshot[] + containerEvents ContainerEvent[] + + @@unique([clientId, netcupServerId]) + @@index([clientId]) + @@map("enterprise_servers") +} + +// ============================================================================ +// ENTERPRISE STATS HISTORY (90-day retention) +// ============================================================================ + +model ServerStatsSnapshot { + id String @id @default(cuid()) + serverId String @map("server_id") + clientId String @map("client_id") + timestamp DateTime @default(now()) + + // Server metrics (from Netcup) + cpuPercent Float? @map("cpu_percent") + memoryUsedMb Float? @map("memory_used_mb") + memoryTotalMb Float? @map("memory_total_mb") + diskReadMbps Float? @map("disk_read_mbps") + diskWriteMbps Float? @map("disk_write_mbps") + networkInMbps Float? @map("network_in_mbps") + networkOutMbps Float? @map("network_out_mbps") + + // Container summary + containersRunning Int? @map("containers_running") + containersStopped Int? @map("containers_stopped") + + // Relations + server EnterpriseServer @relation(fields: [serverId], references: [id], onDelete: Cascade) + client EnterpriseClient @relation(fields: [clientId], references: [id], onDelete: Cascade) + + @@index([serverId, timestamp]) + @@index([clientId, timestamp]) + @@map("server_stats_snapshots") +} + +// ============================================================================ +// ERROR DETECTION +// ============================================================================ + +model ErrorDetectionRule { + id String @id @default(cuid()) + clientId String @map("client_id") + name String // e.g., "Database Connection Failed" + pattern String // Regex pattern + severity ErrorSeverity @default(WARNING) + isActive Boolean @default(true) @map("is_active") + description String? // What this rule detects + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + // Relations + client EnterpriseClient @relation(fields: [clientId], references: [id], onDelete: Cascade) + detectedErrors DetectedError[] + + @@index([clientId]) + @@map("error_detection_rules") +} + +model DetectedError { + id String @id @default(cuid()) + serverId String @map("server_id") + ruleId String @map("rule_id") + containerId String? @map("container_id") // Optional: which container + containerName String? @map("container_name") + logLine String @db.Text @map("log_line") // The actual log line that matched + context String? @db.Text // Surrounding log context + timestamp DateTime @default(now()) + acknowledgedAt DateTime? @map("acknowledged_at") + acknowledgedBy String? @map("acknowledged_by") // User ID who acknowledged + + // Relations + server EnterpriseServer @relation(fields: [serverId], references: [id], onDelete: Cascade) + rule ErrorDetectionRule @relation(fields: [ruleId], references: [id], onDelete: Cascade) + + @@index([serverId, timestamp]) + @@index([ruleId, timestamp]) + @@map("detected_errors") +} + +// ============================================================================ +// SECURITY VERIFICATION (for destructive actions) +// ============================================================================ + +model SecurityVerificationCode { + id String @id @default(cuid()) + clientId String @map("client_id") + code String // 6-digit code + action String // "WIPE" | "REINSTALL" + targetServerId String @map("target_server_id") // Which server + expiresAt DateTime @map("expires_at") + usedAt DateTime? @map("used_at") + createdAt DateTime @default(now()) @map("created_at") + + // Relations + client EnterpriseClient @relation(fields: [clientId], references: [id], onDelete: Cascade) + + @@index([clientId, code]) + @@map("security_verification_codes") +} + +// ============================================================================ +// INTELLIGENT ERROR TRACKING +// ============================================================================ + +enum ContainerEventType { + CRASH // Was running, now exited with non-zero exit code + OOM_KILLED // Out of memory kill + RESTART // Container restarted + STOPPED // Intentional stop (exit code 0 or manual) +} + +// Track log scanning position to avoid re-scanning same content +model LogScanPosition { + id String @id @default(cuid()) + serverId String @map("server_id") + containerId String @map("container_id") + lastLineCount Int @default(0) @map("last_line_count") + lastLogHash String? @map("last_log_hash") // Detect log rotation + lastScannedAt DateTime @default(now()) @map("last_scanned_at") + + server EnterpriseServer @relation(fields: [serverId], references: [id], onDelete: Cascade) + + @@unique([serverId, containerId]) + @@map("log_scan_positions") +} + +// Track container state over time for crash detection +model ContainerStateSnapshot { + id String @id @default(cuid()) + serverId String @map("server_id") + containerId String @map("container_id") + containerName String @map("container_name") + state String // "running", "exited", "dead" + exitCode Int? @map("exit_code") + capturedAt DateTime @default(now()) @map("captured_at") + + server EnterpriseServer @relation(fields: [serverId], references: [id], onDelete: Cascade) + + @@index([serverId, containerId, capturedAt]) + @@map("container_state_snapshots") +} + +// Record significant container lifecycle events +model ContainerEvent { + id String @id @default(cuid()) + serverId String @map("server_id") + containerId String @map("container_id") + containerName String @map("container_name") + eventType ContainerEventType @map("event_type") + exitCode Int? @map("exit_code") + details String? @db.Text + acknowledgedAt DateTime? @map("acknowledged_at") + acknowledgedBy String? @map("acknowledged_by") + timestamp DateTime @default(now()) + + server EnterpriseServer @relation(fields: [serverId], references: [id], onDelete: Cascade) + + @@index([serverId, timestamp]) + @@index([eventType, acknowledgedAt]) + @@map("container_events") +} + +// Email notification settings per client +model NotificationSetting { + id String @id @default(cuid()) + clientId String @unique @map("client_id") + enabled Boolean @default(false) + criticalErrorsOnly Boolean @default(true) @map("critical_errors_only") + containerCrashes Boolean @default(true) @map("container_crashes") + recipients String[] @default([]) + cooldownMinutes Int @default(30) @map("cooldown_minutes") + lastNotifiedAt DateTime? @map("last_notified_at") + + client EnterpriseClient @relation(fields: [clientId], references: [id], onDelete: Cascade) + + @@map("notification_settings") +} + +// ============================================================================ +// SYSTEM-WIDE NOTIFICATION COOLDOWN +// ============================================================================ + +// Track last notification time per notification type for system-wide cooldown +model NotificationCooldown { + id String @id @default(cuid()) + type String @unique // e.g., "container_crash", "critical_error", "stats_cpu" + lastSentAt DateTime @map("last_sent_at") + + @@map("notification_cooldowns") +} diff --git a/src/__tests__/fixtures/orders.ts b/src/__tests__/fixtures/orders.ts new file mode 100644 index 0000000..533dbb8 --- /dev/null +++ b/src/__tests__/fixtures/orders.ts @@ -0,0 +1,246 @@ +import { Order, OrderStatus, AutomationMode, LogLevel, ProvisioningLog, SubscriptionTier } from '@prisma/client' +import { customerUser, customerUser2 } from './users' + +// Payment confirmed order (just created) +export const paymentConfirmedOrder: Order = { + id: 'order-001', + userId: customerUser.id, + domain: 'test.letsbe.cloud', + tier: SubscriptionTier.HUB_DASHBOARD, + status: OrderStatus.PAYMENT_CONFIRMED, + automationMode: AutomationMode.MANUAL, + serverIp: null, + serverPasswordEncrypted: null, + sshPort: 22, + netcupServerId: null, + tools: ['orchestrator', 'sysadmin-agent'], + configJson: {}, + failureReason: null, + provisioningStartedAt: null, + completedAt: null, + customer: 'Test Customer', + companyName: 'Test Company', + licenseKey: null, + automationPausedAt: null, + automationPausedReason: null, + source: null, + portainerUrl: null, + dashboardUrl: null, + portainerUsername: null, + portainerPasswordEnc: null, + credentialsSyncedAt: null, + serverReadyAt: null, + dnsVerifiedAt: null, + createdAt: new Date('2024-01-20T10:00:00Z'), + updatedAt: new Date('2024-01-20T10:00:00Z'), +} + +// Server ready order +export const serverReadyOrder: Order = { + id: 'order-002', + userId: customerUser.id, + domain: 'ready.letsbe.cloud', + tier: SubscriptionTier.ADVANCED, + status: OrderStatus.SERVER_READY, + automationMode: AutomationMode.MANUAL, + serverIp: '192.168.1.100', + serverPasswordEncrypted: 'encrypted-password-here', + sshPort: 22, + netcupServerId: 'netcup-12345', + tools: ['orchestrator', 'sysadmin-agent', 'nextcloud'], + configJson: {}, + failureReason: null, + provisioningStartedAt: null, + completedAt: null, + customer: 'Test Customer', + companyName: 'Test Company', + licenseKey: null, + automationPausedAt: null, + automationPausedReason: null, + source: null, + portainerUrl: null, + dashboardUrl: null, + portainerUsername: null, + portainerPasswordEnc: null, + credentialsSyncedAt: null, + serverReadyAt: new Date('2024-01-21T12:00:00Z'), + dnsVerifiedAt: null, + createdAt: new Date('2024-01-21T10:00:00Z'), + updatedAt: new Date('2024-01-21T14:00:00Z'), +} + +// Order currently provisioning +export const provisioningOrder: Order = { + id: 'order-003', + userId: customerUser.id, + domain: 'provisioning.letsbe.cloud', + tier: SubscriptionTier.ADVANCED, + status: OrderStatus.PROVISIONING, + automationMode: AutomationMode.MANUAL, + serverIp: '192.168.1.101', + serverPasswordEncrypted: 'encrypted-password-here', + sshPort: 22, + netcupServerId: 'netcup-12346', + tools: ['orchestrator', 'sysadmin-agent', 'nextcloud', 'keycloak'], + configJson: {}, + failureReason: null, + provisioningStartedAt: new Date('2024-01-22T10:00:00Z'), + completedAt: null, + customer: 'Test Customer', + companyName: 'Test Company', + licenseKey: 'lb_inst_abc123', + automationPausedAt: null, + automationPausedReason: null, + source: null, + portainerUrl: null, + dashboardUrl: null, + portainerUsername: null, + portainerPasswordEnc: null, + credentialsSyncedAt: null, + serverReadyAt: new Date('2024-01-22T09:30:00Z'), + dnsVerifiedAt: null, + createdAt: new Date('2024-01-22T09:00:00Z'), + updatedAt: new Date('2024-01-22T10:00:00Z'), +} + +// Successfully fulfilled order +export const fulfilledOrder: Order = { + id: 'order-004', + userId: customerUser2.id, + domain: 'complete.letsbe.cloud', + tier: SubscriptionTier.HUB_DASHBOARD, + status: OrderStatus.FULFILLED, + automationMode: AutomationMode.MANUAL, + serverIp: '192.168.1.102', + serverPasswordEncrypted: null, // Cleared after provisioning + sshPort: 22022, // Updated after provisioning + netcupServerId: 'netcup-12347', + tools: ['orchestrator', 'sysadmin-agent'], + configJson: {}, + failureReason: null, + provisioningStartedAt: new Date('2024-01-19T10:00:00Z'), + completedAt: new Date('2024-01-19T10:30:00Z'), + customer: 'Another Customer', + companyName: 'Another Company', + licenseKey: 'lb_inst_def456', + automationPausedAt: null, + automationPausedReason: null, + source: null, + portainerUrl: 'https://portainer.complete.letsbe.cloud', + dashboardUrl: 'https://dashboard.complete.letsbe.cloud', + portainerUsername: 'admin-complete', + portainerPasswordEnc: 'encrypted-portainer-password', + credentialsSyncedAt: new Date('2024-01-19T10:30:00Z'), + serverReadyAt: new Date('2024-01-19T09:30:00Z'), + dnsVerifiedAt: new Date('2024-01-19T09:45:00Z'), + createdAt: new Date('2024-01-19T09:00:00Z'), + updatedAt: new Date('2024-01-19T10:30:00Z'), +} + +// Failed order +export const failedOrder: Order = { + id: 'order-005', + userId: customerUser.id, + domain: 'failed.letsbe.cloud', + tier: SubscriptionTier.ADVANCED, + status: OrderStatus.FAILED, + automationMode: AutomationMode.MANUAL, + serverIp: '192.168.1.103', + serverPasswordEncrypted: 'encrypted-password-here', + sshPort: 22, + netcupServerId: 'netcup-12348', + tools: ['orchestrator', 'sysadmin-agent'], + configJson: {}, + failureReason: 'SSH connection timeout after 3 retries', + provisioningStartedAt: new Date('2024-01-18T10:00:00Z'), + completedAt: null, + customer: 'Test Customer', + companyName: 'Test Company', + licenseKey: null, + automationPausedAt: null, + automationPausedReason: null, + source: null, + portainerUrl: null, + dashboardUrl: null, + portainerUsername: null, + portainerPasswordEnc: null, + credentialsSyncedAt: null, + serverReadyAt: new Date('2024-01-18T09:30:00Z'), + dnsVerifiedAt: null, + createdAt: new Date('2024-01-18T09:00:00Z'), + updatedAt: new Date('2024-01-18T10:15:00Z'), +} + +// All orders +export const allOrders: Order[] = [ + paymentConfirmedOrder, + serverReadyOrder, + provisioningOrder, + fulfilledOrder, + failedOrder, +] + +// Sample provisioning logs +export const provisioningLogs: ProvisioningLog[] = [ + { + id: 'log-001', + orderId: provisioningOrder.id, + level: LogLevel.INFO, + message: 'Starting provisioning for provisioning.letsbe.cloud', + step: 'init', + timestamp: new Date('2024-01-22T10:00:00Z'), + }, + { + id: 'log-002', + orderId: provisioningOrder.id, + level: LogLevel.INFO, + message: 'SSH connection established', + step: 'ssh-connect', + timestamp: new Date('2024-01-22T10:00:05Z'), + }, + { + id: 'log-003', + orderId: provisioningOrder.id, + level: LogLevel.INFO, + message: 'Running server hardening playbook', + step: 'hardening', + timestamp: new Date('2024-01-22T10:00:10Z'), + }, +] + +// Factory function for creating custom orders +export function createOrder(overrides: Partial = {}): Order { + return { + id: `order-${Date.now()}`, + userId: customerUser.id, + domain: `test-${Date.now()}.letsbe.cloud`, + tier: SubscriptionTier.HUB_DASHBOARD, + status: OrderStatus.PAYMENT_CONFIRMED, + automationMode: AutomationMode.MANUAL, + serverIp: null, + serverPasswordEncrypted: null, + sshPort: 22, + netcupServerId: null, + tools: ['orchestrator', 'sysadmin-agent'], + configJson: {}, + failureReason: null, + provisioningStartedAt: null, + completedAt: null, + customer: 'Test Customer', + companyName: 'Test Company', + licenseKey: null, + automationPausedAt: null, + automationPausedReason: null, + source: null, + portainerUrl: null, + dashboardUrl: null, + portainerUsername: null, + portainerPasswordEnc: null, + credentialsSyncedAt: null, + serverReadyAt: null, + dnsVerifiedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + } +} diff --git a/src/__tests__/fixtures/subscriptions.ts b/src/__tests__/fixtures/subscriptions.ts new file mode 100644 index 0000000..e984f26 --- /dev/null +++ b/src/__tests__/fixtures/subscriptions.ts @@ -0,0 +1,95 @@ +import { Subscription, SubscriptionStatus, SubscriptionPlan, SubscriptionTier } from '@prisma/client' +import { customerUser, customerUser2 } from './users' + +// Active subscription +export const activeSubscription: Subscription = { + id: 'sub-001', + userId: customerUser.id, + status: SubscriptionStatus.ACTIVE, + plan: SubscriptionPlan.PRO, + tier: SubscriptionTier.ADVANCED, + tokenLimit: 50000, + tokensUsed: 12500, + trialEndsAt: null, + stripeCustomerId: 'cus_test123', + stripeSubscriptionId: 'sub_test123', + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-01T00:00:00Z'), +} + +// Trial subscription +export const trialSubscription: Subscription = { + id: 'sub-002', + userId: customerUser2.id, + status: SubscriptionStatus.TRIAL, + plan: SubscriptionPlan.TRIAL, + tier: SubscriptionTier.HUB_DASHBOARD, + tokenLimit: 10000, + tokensUsed: 500, + trialEndsAt: new Date('2024-01-29T00:00:00Z'), // 14-day trial + stripeCustomerId: null, + stripeSubscriptionId: null, + createdAt: new Date('2024-01-15T00:00:00Z'), + updatedAt: new Date('2024-01-15T00:00:00Z'), +} + +// Cancelled subscription +export const cancelledSubscription: Subscription = { + id: 'sub-003', + userId: customerUser.id, + status: SubscriptionStatus.CANCELED, + plan: SubscriptionPlan.PRO, + tier: SubscriptionTier.ADVANCED, + tokenLimit: 50000, + tokensUsed: 45000, + trialEndsAt: null, + stripeCustomerId: 'cus_test789', + stripeSubscriptionId: 'sub_test789', + createdAt: new Date('2023-12-01T00:00:00Z'), + updatedAt: new Date('2024-01-20T00:00:00Z'), +} + +// Past due subscription +export const pastDueSubscription: Subscription = { + id: 'sub-004', + userId: customerUser.id, + status: SubscriptionStatus.PAST_DUE, + plan: SubscriptionPlan.ENTERPRISE, + tier: SubscriptionTier.ADVANCED, + tokenLimit: 100000, + tokensUsed: 78000, + trialEndsAt: null, + stripeCustomerId: 'cus_testABC', + stripeSubscriptionId: 'sub_testABC', + createdAt: new Date('2023-06-01T00:00:00Z'), + updatedAt: new Date('2024-01-20T00:00:00Z'), +} + +// All subscriptions +export const allSubscriptions: Subscription[] = [ + activeSubscription, + trialSubscription, + cancelledSubscription, + pastDueSubscription, +] + +// Factory function for creating custom subscriptions +export function createSubscription(overrides: Partial = {}): Subscription { + const now = new Date() + + return { + id: `sub-${Date.now()}`, + userId: customerUser.id, + status: SubscriptionStatus.ACTIVE, + plan: SubscriptionPlan.STARTER, + tier: SubscriptionTier.HUB_DASHBOARD, + tokenLimit: 25000, + tokensUsed: 0, + trialEndsAt: null, + stripeCustomerId: `cus_${Date.now()}`, + stripeSubscriptionId: `sub_${Date.now()}`, + createdAt: now, + updatedAt: now, + ...overrides, + } +} diff --git a/src/__tests__/fixtures/users.ts b/src/__tests__/fixtures/users.ts new file mode 100644 index 0000000..c37c0eb --- /dev/null +++ b/src/__tests__/fixtures/users.ts @@ -0,0 +1,75 @@ +import { User, UserStatus } from '@prisma/client' + +// Customer user fixture +export const customerUser: User = { + id: 'customer-user-001', + email: 'customer@example.com', + passwordHash: '$2a$10$hashedpassword', + name: 'Test Customer', + company: 'Test Company', + status: UserStatus.ACTIVE, + emailVerified: new Date('2024-01-01T00:00:00Z'), + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-01T00:00:00Z'), + twoFactorEnabled: false, + twoFactorSecretEnc: null, + twoFactorVerifiedAt: null, + backupCodesEnc: null, +} + +// Second customer for testing lists +export const customerUser2: User = { + id: 'customer-user-002', + email: 'another@example.com', + passwordHash: '$2a$10$hashedpassword', + name: 'Another Customer', + company: 'Another Company', + status: UserStatus.ACTIVE, + emailVerified: new Date('2024-02-01T00:00:00Z'), + createdAt: new Date('2024-02-01T00:00:00Z'), + updatedAt: new Date('2024-02-01T00:00:00Z'), + twoFactorEnabled: false, + twoFactorSecretEnc: null, + twoFactorVerifiedAt: null, + backupCodesEnc: null, +} + +// Pending verification user +export const pendingUser: User = { + id: 'customer-user-003', + email: 'pending@example.com', + passwordHash: '$2a$10$hashedpassword', + name: 'Pending User', + company: null, + status: UserStatus.PENDING_VERIFICATION, + emailVerified: null, + createdAt: new Date('2024-03-01T00:00:00Z'), + updatedAt: new Date('2024-03-01T00:00:00Z'), + twoFactorEnabled: false, + twoFactorSecretEnc: null, + twoFactorVerifiedAt: null, + backupCodesEnc: null, +} + +// All users +export const allUsers: User[] = [customerUser, customerUser2, pendingUser] + +// Factory function for creating custom users +export function createUser(overrides: Partial = {}): User { + return { + id: `user-${Date.now()}`, + email: `user-${Date.now()}@example.com`, + passwordHash: '$2a$10$hashedpassword', + name: 'Test User', + company: null, + status: UserStatus.ACTIVE, + emailVerified: null, + createdAt: new Date(), + updatedAt: new Date(), + twoFactorEnabled: false, + twoFactorSecretEnc: null, + twoFactorVerifiedAt: null, + backupCodesEnc: null, + ...overrides, + } +} diff --git a/src/__tests__/mocks/fetch.ts b/src/__tests__/mocks/fetch.ts new file mode 100644 index 0000000..da1d6e6 --- /dev/null +++ b/src/__tests__/mocks/fetch.ts @@ -0,0 +1,69 @@ +import { vi } from 'vitest' + +// Type for mock response configuration +export interface MockResponseConfig { + status?: number + ok?: boolean + json?: unknown + text?: string + headers?: Record +} + +// Create a mock fetch response +export function createMockResponse(config: MockResponseConfig = {}): Response { + const { + status = 200, + ok = status >= 200 && status < 300, + json = {}, + text = '', + headers = {}, + } = config + + return { + ok, + status, + statusText: ok ? 'OK' : 'Error', + headers: new Headers(headers), + json: vi.fn().mockResolvedValue(json), + text: vi.fn().mockResolvedValue(text || JSON.stringify(json)), + blob: vi.fn().mockResolvedValue(new Blob()), + arrayBuffer: vi.fn().mockResolvedValue(new ArrayBuffer(0)), + formData: vi.fn().mockResolvedValue(new FormData()), + clone: vi.fn(), + body: null, + bodyUsed: false, + redirected: false, + type: 'basic' as ResponseType, + url: '', + } as unknown as Response +} + +// Mock fetch function +export const mockFetch = vi.fn() + +// Setup fetch to return a specific response +export function setMockFetchResponse(config: MockResponseConfig) { + mockFetch.mockResolvedValue(createMockResponse(config)) +} + +// Setup fetch to return different responses based on URL +export function setMockFetchResponses(responses: Record) { + mockFetch.mockImplementation((url: string) => { + const config = responses[url] || { status: 404, ok: false } + return Promise.resolve(createMockResponse(config)) + }) +} + +// Setup fetch to throw an error +export function setMockFetchError(error: Error) { + mockFetch.mockRejectedValue(error) +} + +// Reset fetch mock +export function resetFetchMock() { + mockFetch.mockReset() + mockFetch.mockResolvedValue(createMockResponse()) +} + +// Replace global fetch +vi.stubGlobal('fetch', mockFetch) diff --git a/src/__tests__/mocks/next-auth.ts b/src/__tests__/mocks/next-auth.ts new file mode 100644 index 0000000..10c3359 --- /dev/null +++ b/src/__tests__/mocks/next-auth.ts @@ -0,0 +1,45 @@ +import { vi } from 'vitest' +import type { Session } from 'next-auth' + +// Default mock session for staff users +export const mockStaffSession: Session = { + user: { + id: 'staff-user-id', + email: 'admin@letsbe.cloud', + name: 'Test Admin', + userType: 'staff', + }, + expires: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), +} + +// Mock session for customer users +export const mockCustomerSession: Session = { + user: { + id: 'customer-user-id', + email: 'customer@example.com', + name: 'Test Customer', + userType: 'customer', + }, + expires: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), +} + +// No session (unauthenticated) +export const mockNoSession = null + +// Create auth mock function +export const mockAuth = vi.fn() + +// Setup auth mock with a specific session +export function setMockSession(session: Session | null) { + mockAuth.mockResolvedValue(session) +} + +// Reset to staff session (default) +export function resetAuthMock() { + mockAuth.mockResolvedValue(mockStaffSession) +} + +// Mock the auth module +vi.mock('@/lib/auth', () => ({ + auth: mockAuth, +})) diff --git a/src/__tests__/mocks/prisma.ts b/src/__tests__/mocks/prisma.ts new file mode 100644 index 0000000..4be4529 --- /dev/null +++ b/src/__tests__/mocks/prisma.ts @@ -0,0 +1,18 @@ +import { vi } from 'vitest' +import { PrismaClient } from '@prisma/client' +import { mockDeep, mockReset, DeepMockProxy } from 'vitest-mock-extended' + +// Create a deep mock of PrismaClient +export const prismaMock = mockDeep() + +// Reset mock between tests +export function resetPrismaMock() { + mockReset(prismaMock) +} + +// Mock the prisma module +vi.mock('@/lib/prisma', () => ({ + prisma: prismaMock, +})) + +export type MockPrismaClient = DeepMockProxy diff --git a/src/__tests__/setup.ts b/src/__tests__/setup.ts new file mode 100644 index 0000000..486bb78 --- /dev/null +++ b/src/__tests__/setup.ts @@ -0,0 +1,20 @@ +import { beforeAll, afterAll, beforeEach, vi } from 'vitest' + +// Mock environment variables +beforeAll(() => { + process.env.ENCRYPTION_KEY = 'test-encryption-key-32-chars-long' + process.env.CREDENTIAL_ENCRYPTION_KEY = 'test-credential-encryption-key!!' + process.env.SETTINGS_ENCRYPTION_KEY = 'test-settings-encryption-key!!!' + process.env.NEXTAUTH_SECRET = 'test-secret' + process.env.NEXTAUTH_URL = 'http://localhost:3000' +}) + +// Reset mocks between tests +beforeEach(() => { + vi.clearAllMocks() +}) + +// Clean up after all tests +afterAll(() => { + vi.restoreAllMocks() +}) diff --git a/src/__tests__/unit/lib/api/client.test.ts b/src/__tests__/unit/lib/api/client.test.ts new file mode 100644 index 0000000..e4f8b85 --- /dev/null +++ b/src/__tests__/unit/lib/api/client.test.ts @@ -0,0 +1,266 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { apiGet, apiPost, apiPatch, apiPut, apiDelete, ApiError } from '@/lib/api/client' +import { mockFetch, setMockFetchResponse, setMockFetchError, resetFetchMock } from '../../../mocks/fetch' + +// Mock window.location for URL building +const mockLocation = { + origin: 'http://localhost:3000', +} +vi.stubGlobal('location', mockLocation) + +describe('API Client', () => { + beforeEach(() => { + resetFetchMock() + }) + + describe('apiGet', () => { + it('should make GET request and return JSON response', async () => { + const mockData = { id: '1', name: 'Test' } + setMockFetchResponse({ json: mockData }) + + const result = await apiGet('/api/test') + + expect(mockFetch).toHaveBeenCalledWith( + 'http://localhost:3000/api/test', + expect.objectContaining({ + method: 'GET', + headers: expect.objectContaining({ + 'Content-Type': 'application/json', + }), + }) + ) + expect(result).toEqual(mockData) + }) + + it('should append query params to URL', async () => { + setMockFetchResponse({ json: {} }) + + await apiGet('/api/test', { params: { page: 1, limit: 10, active: true } }) + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('page=1'), + expect.anything() + ) + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('limit=10'), + expect.anything() + ) + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('active=true'), + expect.anything() + ) + }) + + it('should skip undefined params', async () => { + setMockFetchResponse({ json: {} }) + + await apiGet('/api/test', { params: { page: 1, filter: undefined } }) + + const calledUrl = mockFetch.mock.calls[0][0] as string + expect(calledUrl).toContain('page=1') + expect(calledUrl).not.toContain('filter') + }) + + it('should throw ApiError on non-OK response', async () => { + setMockFetchResponse({ status: 404, ok: false, json: { error: 'Not found' } }) + + await expect(apiGet('/api/test')).rejects.toThrow(ApiError) + await expect(apiGet('/api/test')).rejects.toMatchObject({ + status: 404, + statusText: 'Error', + }) + }) + + it('should handle network errors', async () => { + setMockFetchError(new Error('Network error')) + + await expect(apiGet('/api/test')).rejects.toThrow('Network error') + }) + }) + + describe('apiPost', () => { + it('should make POST request with JSON body', async () => { + const mockData = { id: '1' } + const requestBody = { name: 'New Item', value: 42 } + setMockFetchResponse({ json: mockData }) + + const result = await apiPost('/api/test', requestBody) + + expect(mockFetch).toHaveBeenCalledWith( + 'http://localhost:3000/api/test', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify(requestBody), + }) + ) + expect(result).toEqual(mockData) + }) + + it('should handle POST without body', async () => { + setMockFetchResponse({ json: { success: true } }) + + await apiPost('/api/test') + + expect(mockFetch).toHaveBeenCalledWith( + 'http://localhost:3000/api/test', + expect.objectContaining({ + method: 'POST', + body: undefined, + }) + ) + }) + + it('should throw ApiError on 400 response', async () => { + setMockFetchResponse({ + status: 400, + ok: false, + json: { error: 'Validation failed' } + }) + + await expect(apiPost('/api/test', { invalid: 'data' })).rejects.toThrow(ApiError) + }) + + it('should throw ApiError on 401 response', async () => { + setMockFetchResponse({ + status: 401, + ok: false, + json: { error: 'Unauthorized' } + }) + + await expect(apiPost('/api/test')).rejects.toMatchObject({ + status: 401, + }) + }) + }) + + describe('apiPatch', () => { + it('should make PATCH request with JSON body', async () => { + const mockData = { id: '1', updated: true } + const patchBody = { name: 'Updated Name' } + setMockFetchResponse({ json: mockData }) + + const result = await apiPatch('/api/test/1', patchBody) + + expect(mockFetch).toHaveBeenCalledWith( + 'http://localhost:3000/api/test/1', + expect.objectContaining({ + method: 'PATCH', + body: JSON.stringify(patchBody), + }) + ) + expect(result).toEqual(mockData) + }) + }) + + describe('apiPut', () => { + it('should make PUT request with JSON body', async () => { + const mockData = { id: '1', replaced: true } + const putBody = { name: 'Replaced Item', value: 100 } + setMockFetchResponse({ json: mockData }) + + const result = await apiPut('/api/test/1', putBody) + + expect(mockFetch).toHaveBeenCalledWith( + 'http://localhost:3000/api/test/1', + expect.objectContaining({ + method: 'PUT', + body: JSON.stringify(putBody), + }) + ) + expect(result).toEqual(mockData) + }) + }) + + describe('apiDelete', () => { + it('should make DELETE request', async () => { + setMockFetchResponse({ json: { deleted: true } }) + + const result = await apiDelete('/api/test/1') + + expect(mockFetch).toHaveBeenCalledWith( + 'http://localhost:3000/api/test/1', + expect.objectContaining({ + method: 'DELETE', + }) + ) + expect(result).toEqual({ deleted: true }) + }) + + it('should handle 204 No Content response', async () => { + // Simulate empty response + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 204, + statusText: 'No Content', + text: vi.fn().mockResolvedValue(''), + json: vi.fn().mockRejectedValue(new Error('No JSON')), + } as unknown as Response) + + const result = await apiDelete('/api/test/1') + + expect(result).toBeNull() + }) + }) + + describe('ApiError', () => { + it('should include status and data', async () => { + const errorData = { message: 'Validation failed', errors: ['field required'] } + setMockFetchResponse({ + status: 422, + ok: false, + json: errorData + }) + + try { + await apiPost('/api/test', {}) + } catch (error) { + expect(error).toBeInstanceOf(ApiError) + expect((error as ApiError).status).toBe(422) + expect((error as ApiError).data).toEqual(errorData) + } + }) + + it('should handle non-JSON error responses', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + json: vi.fn().mockRejectedValue(new Error('Invalid JSON')), + text: vi.fn().mockResolvedValue('Internal Server Error'), + } as unknown as Response) + + try { + await apiGet('/api/test') + } catch (error) { + expect(error).toBeInstanceOf(ApiError) + expect((error as ApiError).status).toBe(500) + expect((error as ApiError).data).toBeUndefined() + } + }) + }) + + describe('custom headers', () => { + it('should merge custom headers with defaults', async () => { + setMockFetchResponse({ json: {} }) + + await apiGet('/api/test', { + headers: { + 'X-Custom-Header': 'custom-value', + }, + }) + + expect(mockFetch).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + headers: expect.objectContaining({ + 'Content-Type': 'application/json', + 'X-Custom-Header': 'custom-value', + }), + }) + ) + }) + }) +}) diff --git a/src/__tests__/unit/lib/services/credential-service.test.ts b/src/__tests__/unit/lib/services/credential-service.test.ts new file mode 100644 index 0000000..6a1eb0b --- /dev/null +++ b/src/__tests__/unit/lib/services/credential-service.test.ts @@ -0,0 +1,261 @@ +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest' +import { credentialService } from '@/lib/services/credential-service' + +describe('CredentialService', () => { + // Store original env values + const originalEnv = { ...process.env } + + beforeAll(() => { + // Set up test encryption keys + process.env.CREDENTIAL_ENCRYPTION_KEY = 'test-credential-encryption-key!!' + process.env.ENCRYPTION_KEY = 'test-encryption-key-32-chars-long' + }) + + afterAll(() => { + // Restore original env + process.env = originalEnv + }) + + describe('encrypt', () => { + it('should encrypt plaintext to iv:ciphertext format', () => { + const plaintext = 'my-secret-password' + + const encrypted = credentialService.encrypt(plaintext) + + // Should have iv:ciphertext format + expect(encrypted).toContain(':') + const [iv, ciphertext] = encrypted.split(':') + + // IV should be 32 hex chars (16 bytes) + expect(iv).toHaveLength(32) + expect(iv).toMatch(/^[0-9a-f]+$/) + + // Ciphertext should be hex encoded + expect(ciphertext).toMatch(/^[0-9a-f]+$/) + }) + + it('should produce different ciphertexts for same plaintext (random IV)', () => { + const plaintext = 'same-password' + + const encrypted1 = credentialService.encrypt(plaintext) + const encrypted2 = credentialService.encrypt(plaintext) + + // Different IVs mean different ciphertexts + expect(encrypted1).not.toBe(encrypted2) + }) + + it('should handle empty strings', () => { + const encrypted = credentialService.encrypt('') + + expect(encrypted).toContain(':') + // Empty string still produces ciphertext (padding) + const [, ciphertext] = encrypted.split(':') + expect(ciphertext.length).toBeGreaterThan(0) + }) + + it('should handle special characters', () => { + const plaintext = 'p@$$w0rd!#$%^&*()_+-=[]{}|;:,.<>?' + + const encrypted = credentialService.encrypt(plaintext) + const decrypted = credentialService.decrypt(encrypted) + + expect(decrypted).toBe(plaintext) + }) + + it('should handle unicode characters', () => { + const plaintext = '密码123🔐' + + const encrypted = credentialService.encrypt(plaintext) + const decrypted = credentialService.decrypt(encrypted) + + expect(decrypted).toBe(plaintext) + }) + + it('should handle very long strings', () => { + const plaintext = 'a'.repeat(10000) + + const encrypted = credentialService.encrypt(plaintext) + const decrypted = credentialService.decrypt(encrypted) + + expect(decrypted).toBe(plaintext) + }) + }) + + describe('decrypt', () => { + it('should decrypt ciphertext to original plaintext', () => { + const plaintext = 'my-secret-password' + const encrypted = credentialService.encrypt(plaintext) + + const decrypted = credentialService.decrypt(encrypted) + + expect(decrypted).toBe(plaintext) + }) + + it('should throw on invalid format (no colon)', () => { + expect(() => credentialService.decrypt('invalid-no-colon')).toThrow( + 'Invalid ciphertext format' + ) + }) + + it('should throw on invalid format (empty parts)', () => { + expect(() => credentialService.decrypt(':encrypted')).toThrow( + 'Invalid ciphertext format' + ) + expect(() => credentialService.decrypt('iv:')).toThrow( + 'Invalid ciphertext format' + ) + }) + + it('should throw on invalid hex in IV', () => { + expect(() => credentialService.decrypt('not-hex:abcdef')).toThrow() + }) + + it('should throw on tampered ciphertext', () => { + const encrypted = credentialService.encrypt('secret') + // Tamper with the ciphertext + const [iv, ciphertext] = encrypted.split(':') + const tampered = `${iv}:ff${ciphertext.slice(2)}` + + expect(() => credentialService.decrypt(tampered)).toThrow() + }) + + it('should throw on wrong key', () => { + // Encrypt with current key + const encrypted = credentialService.encrypt('secret') + + // Change the key + const originalKey = process.env.CREDENTIAL_ENCRYPTION_KEY + process.env.CREDENTIAL_ENCRYPTION_KEY = 'different-key-32-characters!!!!!' + + // Try to decrypt - should fail due to key mismatch + expect(() => credentialService.decrypt(encrypted)).toThrow() + + // Restore + process.env.CREDENTIAL_ENCRYPTION_KEY = originalKey + }) + }) + + describe('isConfigured', () => { + it('should return true when CREDENTIAL_ENCRYPTION_KEY is set', () => { + process.env.CREDENTIAL_ENCRYPTION_KEY = 'some-key' + + expect(credentialService.isConfigured()).toBe(true) + }) + + it('should return false when CREDENTIAL_ENCRYPTION_KEY is not set', () => { + const original = process.env.CREDENTIAL_ENCRYPTION_KEY + delete process.env.CREDENTIAL_ENCRYPTION_KEY + + expect(credentialService.isConfigured()).toBe(false) + + process.env.CREDENTIAL_ENCRYPTION_KEY = original + }) + }) + + describe('decryptLegacy', () => { + it('should decrypt values encrypted with legacy ENCRYPTION_KEY', () => { + // Create a value encrypted with legacy format + // Legacy uses ENCRYPTION_KEY with 'salt' as the scrypt salt + const crypto = require('crypto') + const legacyKey = crypto.scryptSync(process.env.ENCRYPTION_KEY!, 'salt', 32) + const iv = crypto.randomBytes(16) + const cipher = crypto.createCipheriv('aes-256-cbc', legacyKey, iv) + let encrypted = cipher.update('legacy-secret', 'utf8', 'hex') + encrypted += cipher.final('hex') + const legacyCiphertext = `${iv.toString('hex')}:${encrypted}` + + const decrypted = credentialService.decryptLegacy(legacyCiphertext) + + expect(decrypted).toBe('legacy-secret') + }) + + it('should throw on invalid format', () => { + expect(() => credentialService.decryptLegacy('invalid')).toThrow( + 'Invalid ciphertext format' + ) + }) + }) + + describe('migrateFromLegacy', () => { + it('should re-encrypt from legacy format to new format', () => { + // Create legacy encrypted value + const crypto = require('crypto') + const legacyKey = crypto.scryptSync(process.env.ENCRYPTION_KEY!, 'salt', 32) + const iv = crypto.randomBytes(16) + const cipher = crypto.createCipheriv('aes-256-cbc', legacyKey, iv) + let encrypted = cipher.update('migrate-me', 'utf8', 'hex') + encrypted += cipher.final('hex') + const legacyCiphertext = `${iv.toString('hex')}:${encrypted}` + + // Migrate to new format + const newCiphertext = credentialService.migrateFromLegacy(legacyCiphertext) + + // Should be decryptable with new format + const decrypted = credentialService.decrypt(newCiphertext) + expect(decrypted).toBe('migrate-me') + + // Should NOT be decryptable with legacy format (different key derivation) + expect(newCiphertext).not.toBe(legacyCiphertext) + }) + }) + + describe('isLegacyConfigured', () => { + it('should return true when ENCRYPTION_KEY is set', () => { + process.env.ENCRYPTION_KEY = 'some-legacy-key' + + expect(credentialService.isLegacyConfigured()).toBe(true) + }) + + it('should return false when ENCRYPTION_KEY is not set', () => { + const original = process.env.ENCRYPTION_KEY + delete process.env.ENCRYPTION_KEY + + expect(credentialService.isLegacyConfigured()).toBe(false) + + process.env.ENCRYPTION_KEY = original + }) + }) + + describe('error handling without keys', () => { + it('should throw when encrypting without CREDENTIAL_ENCRYPTION_KEY', () => { + const original = process.env.CREDENTIAL_ENCRYPTION_KEY + delete process.env.CREDENTIAL_ENCRYPTION_KEY + + expect(() => credentialService.encrypt('test')).toThrow( + 'CREDENTIAL_ENCRYPTION_KEY environment variable is required' + ) + + process.env.CREDENTIAL_ENCRYPTION_KEY = original + }) + + it('should throw when decrypting legacy without ENCRYPTION_KEY', () => { + const original = process.env.ENCRYPTION_KEY + delete process.env.ENCRYPTION_KEY + + expect(() => credentialService.decryptLegacy('abc:def')).toThrow( + 'ENCRYPTION_KEY environment variable is required for legacy decryption' + ) + + process.env.ENCRYPTION_KEY = original + }) + }) + + describe('round-trip encryption', () => { + it('should successfully round-trip various data types', () => { + const testCases = [ + 'simple-password', + 'with spaces and tabs\t', + 'multi\nline\nstring', + JSON.stringify({ user: 'admin', pass: 'secret123' }), + '12345', + '!@#$%^&*()', + ] + + for (const plaintext of testCases) { + const encrypted = credentialService.encrypt(plaintext) + const decrypted = credentialService.decrypt(encrypted) + expect(decrypted).toBe(plaintext) + } + }) + }) +}) diff --git a/src/__tests__/unit/lib/services/job-service.test.ts b/src/__tests__/unit/lib/services/job-service.test.ts new file mode 100644 index 0000000..1f97756 --- /dev/null +++ b/src/__tests__/unit/lib/services/job-service.test.ts @@ -0,0 +1,428 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { prismaMock, resetPrismaMock } from '../../../mocks/prisma' +import { JobStatus, OrderStatus, LogLevel } from '@prisma/client' +import crypto from 'crypto' + +// Mock the credential service +vi.mock('@/lib/services/credential-service', () => ({ + credentialService: { + decrypt: vi.fn().mockReturnValue('decrypted-password'), + decryptLegacy: vi.fn().mockReturnValue('legacy-decrypted-password'), + }, +})) + +// Import after mocking +import { JobService, JobConfig } from '@/lib/services/job-service' + +describe('JobService', () => { + let jobService: JobService + + beforeEach(() => { + resetPrismaMock() + jobService = new JobService() + }) + + describe('createJobForOrder', () => { + const mockOrder = { + id: 'order-123', + serverIp: '192.168.1.100', + serverPasswordEncrypted: 'encrypted-password', + sshPort: 22, + domain: 'test.letsbe.cloud', + tier: 'professional', + tools: ['orchestrator', 'sysadmin-agent'], + user: { + id: 'user-123', + email: 'customer@example.com', + name: 'Test Customer', + company: 'Test Company', + subscriptions: [{ id: 'sub-1', status: 'ACTIVE' }], + }, + } + + it('should create a job for a valid order', async () => { + prismaMock.order.findUnique.mockResolvedValue(mockOrder as any) + prismaMock.provisioningJob.create.mockResolvedValue({ + id: 'job-456', + orderId: 'order-123', + jobType: 'provision', + status: JobStatus.PENDING, + configSnapshot: {}, + runnerTokenHash: 'hash', + createdAt: new Date(), + updatedAt: new Date(), + } as any) + prismaMock.order.update.mockResolvedValue({} as any) + + const result = await jobService.createJobForOrder('order-123') + const parsed = JSON.parse(result) + + expect(parsed.jobId).toBe('job-456') + expect(parsed.runnerToken).toBeDefined() + expect(parsed.runnerToken.length).toBe(64) // 32 bytes hex + + // Verify order was looked up + expect(prismaMock.order.findUnique).toHaveBeenCalledWith({ + where: { id: 'order-123' }, + include: expect.any(Object), + }) + + // Verify job was created with config snapshot + expect(prismaMock.provisioningJob.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + orderId: 'order-123', + jobType: 'provision', + configSnapshot: expect.any(Object), + runnerTokenHash: expect.any(String), + }), + }) + + // Verify order status was updated + expect(prismaMock.order.update).toHaveBeenCalledWith({ + where: { id: 'order-123' }, + data: expect.objectContaining({ + status: OrderStatus.PROVISIONING, + }), + }) + }) + + it('should throw if order not found', async () => { + prismaMock.order.findUnique.mockResolvedValue(null) + + await expect(jobService.createJobForOrder('invalid-id')).rejects.toThrow( + 'Order invalid-id not found' + ) + }) + + it('should throw if order missing server credentials', async () => { + prismaMock.order.findUnique.mockResolvedValue({ + id: 'order-123', + serverIp: null, + serverPasswordEncrypted: null, + user: { email: 'test@example.com' }, + } as any) + + await expect(jobService.createJobForOrder('order-123')).rejects.toThrow( + 'missing server credentials' + ) + }) + }) + + describe('verifyRunnerToken', () => { + it('should return true for valid token', async () => { + const token = 'test-token' + const hash = crypto.createHash('sha256').update(token).digest('hex') + + prismaMock.provisioningJob.findUnique.mockResolvedValue({ + id: 'job-123', + runnerTokenHash: hash, + } as any) + + const result = await jobService.verifyRunnerToken('job-123', token) + + expect(result).toBe(true) + }) + + it('should return false for invalid token', async () => { + const correctHash = crypto.createHash('sha256').update('correct-token').digest('hex') + + prismaMock.provisioningJob.findUnique.mockResolvedValue({ + id: 'job-123', + runnerTokenHash: correctHash, + } as any) + + const result = await jobService.verifyRunnerToken('job-123', 'wrong-token') + + expect(result).toBe(false) + }) + + it('should return false for non-existent job', async () => { + prismaMock.provisioningJob.findUnique.mockResolvedValue(null) + + const result = await jobService.verifyRunnerToken('invalid-job', 'any-token') + + expect(result).toBe(false) + }) + + it('should return false if job has no token hash', async () => { + prismaMock.provisioningJob.findUnique.mockResolvedValue({ + id: 'job-123', + runnerTokenHash: null, + } as any) + + const result = await jobService.verifyRunnerToken('job-123', 'any-token') + + expect(result).toBe(false) + }) + }) + + describe('addLog', () => { + it('should create job log and provisioning log', async () => { + prismaMock.jobLog.create.mockResolvedValue({} as any) + prismaMock.provisioningJob.findUnique.mockResolvedValue({ + id: 'job-123', + orderId: 'order-456', + } as any) + prismaMock.provisioningLog.create.mockResolvedValue({} as any) + + await jobService.addLog('job-123', 'info', 'Test message', 'test-step', 50) + + expect(prismaMock.jobLog.create).toHaveBeenCalledWith({ + data: { + jobId: 'job-123', + level: LogLevel.INFO, + message: 'Test message', + step: 'test-step', + progress: 50, + }, + }) + + expect(prismaMock.provisioningLog.create).toHaveBeenCalledWith({ + data: { + orderId: 'order-456', + level: LogLevel.INFO, + message: 'Test message', + step: 'test-step', + }, + }) + }) + + it('should map log levels correctly', async () => { + prismaMock.jobLog.create.mockResolvedValue({} as any) + prismaMock.provisioningJob.findUnique.mockResolvedValue(null) + + await jobService.addLog('job-123', 'info', 'Info message') + expect(prismaMock.jobLog.create).toHaveBeenLastCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ level: LogLevel.INFO }), + }) + ) + + await jobService.addLog('job-123', 'warn', 'Warn message') + expect(prismaMock.jobLog.create).toHaveBeenLastCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ level: LogLevel.WARN }), + }) + ) + + await jobService.addLog('job-123', 'error', 'Error message') + expect(prismaMock.jobLog.create).toHaveBeenLastCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ level: LogLevel.ERROR }), + }) + ) + }) + }) + + describe('completeJob', () => { + it('should mark job as completed and update order', async () => { + prismaMock.provisioningJob.update.mockResolvedValue({ + id: 'job-123', + orderId: 'order-456', + } as any) + prismaMock.order.update.mockResolvedValue({} as any) + + await jobService.completeJob('job-123', { outputFiles: ['file1.txt'] }) + + expect(prismaMock.provisioningJob.update).toHaveBeenCalledWith({ + where: { id: 'job-123' }, + data: expect.objectContaining({ + status: JobStatus.COMPLETED, + result: { outputFiles: ['file1.txt'] }, + }), + }) + + expect(prismaMock.order.update).toHaveBeenCalledWith({ + where: { id: 'order-456' }, + data: expect.objectContaining({ + status: OrderStatus.FULFILLED, + sshPort: 22022, // SSH_PORT_AFTER_PROVISION + serverPasswordEncrypted: null, // Cleared for security + }), + }) + }) + }) + + describe('failJob', () => { + const mockJob = { + id: 'job-123', + orderId: 'order-456', + attempt: 1, + maxAttempts: 3, + } + + beforeEach(() => { + // Mock addLog to prevent it from making real calls + vi.spyOn(jobService, 'addLog').mockResolvedValue() + }) + + it('should retry job if attempts remaining', async () => { + prismaMock.provisioningJob.findUnique.mockResolvedValue(mockJob as any) + prismaMock.provisioningJob.update.mockResolvedValue({} as any) + + const result = await jobService.failJob('job-123', 'Connection timeout') + + expect(result.willRetry).toBe(true) + expect(result.nextRetryAt).toBeDefined() + + expect(prismaMock.provisioningJob.update).toHaveBeenCalledWith({ + where: { id: 'job-123' }, + data: expect.objectContaining({ + status: JobStatus.PENDING, + attempt: 2, + nextRetryAt: expect.any(Date), + claimedAt: null, + claimedBy: null, + runnerTokenHash: null, + }), + }) + }) + + it('should mark job as dead after max attempts', async () => { + prismaMock.provisioningJob.findUnique.mockResolvedValue({ + ...mockJob, + attempt: 3, // Already at max + } as any) + prismaMock.provisioningJob.update.mockResolvedValue({} as any) + prismaMock.order.update.mockResolvedValue({} as any) + + const result = await jobService.failJob('job-123', 'Final failure') + + expect(result.willRetry).toBe(false) + expect(result.nextRetryAt).toBeUndefined() + + expect(prismaMock.provisioningJob.update).toHaveBeenCalledWith({ + where: { id: 'job-123' }, + data: expect.objectContaining({ + status: JobStatus.DEAD, + error: 'Final failure', + }), + }) + + expect(prismaMock.order.update).toHaveBeenCalledWith({ + where: { id: 'order-456' }, + data: expect.objectContaining({ + status: OrderStatus.FAILED, + failureReason: 'Final failure', + }), + }) + }) + + it('should throw if job not found', async () => { + prismaMock.provisioningJob.findUnique.mockResolvedValue(null) + + await expect(jobService.failJob('invalid-job', 'error')).rejects.toThrow( + 'Job invalid-job not found' + ) + }) + }) + + describe('getJobStatus', () => { + it('should return job status with progress', async () => { + prismaMock.provisioningJob.findUnique.mockResolvedValue({ + status: JobStatus.RUNNING, + attempt: 1, + maxAttempts: 3, + error: null, + } as any) + prismaMock.jobLog.findFirst.mockResolvedValue({ + progress: 75, + } as any) + + const result = await jobService.getJobStatus('job-123') + + expect(result).toEqual({ + status: JobStatus.RUNNING, + attempt: 1, + maxAttempts: 3, + progress: 75, + error: undefined, + }) + }) + + it('should return null for non-existent job', async () => { + prismaMock.provisioningJob.findUnique.mockResolvedValue(null) + + const result = await jobService.getJobStatus('invalid-job') + + expect(result).toBeNull() + }) + + it('should include error if present', async () => { + prismaMock.provisioningJob.findUnique.mockResolvedValue({ + status: JobStatus.DEAD, + attempt: 3, + maxAttempts: 3, + error: 'Connection refused', + } as any) + prismaMock.jobLog.findFirst.mockResolvedValue(null) + + const result = await jobService.getJobStatus('job-123') + + expect(result?.error).toBe('Connection refused') + }) + }) + + describe('getPendingJobCount', () => { + it('should return count of pending jobs ready to process', async () => { + prismaMock.provisioningJob.count.mockResolvedValue(5) + + const result = await jobService.getPendingJobCount() + + expect(result).toBe(5) + expect(prismaMock.provisioningJob.count).toHaveBeenCalledWith({ + where: expect.objectContaining({ + status: JobStatus.PENDING, + }), + }) + }) + }) + + describe('getRunningJobCount', () => { + it('should return count of running jobs', async () => { + prismaMock.provisioningJob.count.mockResolvedValue(2) + + const result = await jobService.getRunningJobCount() + + expect(result).toBe(2) + expect(prismaMock.provisioningJob.count).toHaveBeenCalledWith({ + where: { status: JobStatus.RUNNING }, + }) + }) + }) + + describe('getLogs', () => { + it('should return logs for a job', async () => { + const mockLogs = [ + { id: 'log-1', timestamp: new Date(), level: 'INFO', message: 'Started', step: 'init', progress: 0 }, + { id: 'log-2', timestamp: new Date(), level: 'INFO', message: 'Running', step: 'execute', progress: 50 }, + ] + prismaMock.jobLog.findMany.mockResolvedValue(mockLogs as any) + + const result = await jobService.getLogs('job-123') + + expect(result).toEqual(mockLogs) + expect(prismaMock.jobLog.findMany).toHaveBeenCalledWith({ + where: { jobId: 'job-123' }, + orderBy: { timestamp: 'asc' }, + select: expect.any(Object), + }) + }) + + it('should filter logs by since date', async () => { + const since = new Date('2024-01-01') + prismaMock.jobLog.findMany.mockResolvedValue([]) + + await jobService.getLogs('job-123', since) + + expect(prismaMock.jobLog.findMany).toHaveBeenCalledWith({ + where: { + jobId: 'job-123', + timestamp: { gt: since }, + }, + orderBy: { timestamp: 'asc' }, + select: expect.any(Object), + }) + }) + }) +}) diff --git a/src/app/(auth)/invite/[token]/page.tsx b/src/app/(auth)/invite/[token]/page.tsx new file mode 100644 index 0000000..1aaef2b --- /dev/null +++ b/src/app/(auth)/invite/[token]/page.tsx @@ -0,0 +1,265 @@ +'use client' + +import { useState, useEffect, use } from 'react' +import { useRouter } from 'next/navigation' +import Link from 'next/link' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { AlertCircle, CheckCircle2, Loader2 } from 'lucide-react' + +interface InviteInfo { + valid: boolean + email: string + role: string + expiresAt: string + invitedBy: string +} + +export default function AcceptInvitePage({ + params, +}: { + params: Promise<{ token: string }> +}) { + const { token } = use(params) + const router = useRouter() + + const [inviteInfo, setInviteInfo] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + const [name, setName] = useState('') + const [password, setPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + const [submitting, setSubmitting] = useState(false) + const [submitError, setSubmitError] = useState(null) + const [success, setSuccess] = useState(false) + + useEffect(() => { + async function validateToken() { + try { + const res = await fetch(`/api/v1/auth/invite/${token}`) + const data = await res.json() + + if (!res.ok) { + setError(data.error || 'Invalid invitation') + return + } + + setInviteInfo(data) + } catch { + setError('Failed to validate invitation') + } finally { + setLoading(false) + } + } + + validateToken() + }, [token]) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setSubmitError(null) + + if (password !== confirmPassword) { + setSubmitError('Passwords do not match') + return + } + + if (password.length < 8) { + setSubmitError('Password must be at least 8 characters') + return + } + + setSubmitting(true) + + try { + const res = await fetch('/api/v1/auth/accept-invite', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token, name, password }), + }) + + const data = await res.json() + + if (!res.ok) { + setSubmitError(data.error || 'Failed to create account') + return + } + + setSuccess(true) + setTimeout(() => { + router.push('/login') + }, 3000) + } catch { + setSubmitError('Failed to create account') + } finally { + setSubmitting(false) + } + } + + if (loading) { + return ( +
+ + + + + +
+ ) + } + + if (error) { + return ( +
+ + +
+ +
+ Invalid Invitation + {error} +
+ + + +
+
+ ) + } + + if (success) { + return ( +
+ + +
+ +
+ Account Created! + + Your account has been created successfully. Redirecting to login... + +
+ + + +
+
+ ) + } + + return ( +
+ + + + Join LetsBe Hub + + + Complete your registration to get started + + + +
+ +
+
+ Email + {inviteInfo?.email} +
+
+ Role + {inviteInfo?.role} +
+
+ Invited by + {inviteInfo?.invitedBy} +
+
+ +
+ + setName(e.target.value)} + required + minLength={2} + /> +
+ +
+ + setPassword(e.target.value)} + required + minLength={8} + /> +

+ Must be at least 8 characters +

+
+ +
+ + setConfirmPassword(e.target.value)} + required + /> +
+ + {submitError && ( +
+ {submitError} +
+ )} +
+ + + +

+ Already have an account?{' '} + + Sign in + +

+
+
+
+
+ ) +} diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx index ee8a987..bec17b9 100644 --- a/src/app/(auth)/login/page.tsx +++ b/src/app/(auth)/login/page.tsx @@ -27,6 +27,12 @@ function LoginForm() { const [isLoading, setIsLoading] = useState(false) const [loginError, setLoginError] = useState(null) + // 2FA state + const [show2FA, setShow2FA] = useState(false) + const [pendingToken, setPendingToken] = useState(null) + const [twoFactorCode, setTwoFactorCode] = useState('') + const [useBackupCode, setUseBackupCode] = useState(false) + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() setIsLoading(true) @@ -41,6 +47,40 @@ function LoginForm() { callbackUrl, }) + if (result?.error) { + // Check if 2FA is required + if (result.error.startsWith('2FA_REQUIRED:')) { + const token = result.error.replace('2FA_REQUIRED:', '') + setPendingToken(token) + setShow2FA(true) + setLoginError(null) + } else { + setLoginError(result.error) + } + } else if (result?.ok) { + router.push(userType === 'staff' ? '/admin' : '/') + router.refresh() + } + } catch { + setLoginError('An unexpected error occurred') + } finally { + setIsLoading(false) + } + } + + const handle2FASubmit = async (e: React.FormEvent) => { + e.preventDefault() + setIsLoading(true) + setLoginError(null) + + try { + const result = await signIn('credentials', { + pendingToken, + twoFactorToken: twoFactorCode.replace(/[\s-]/g, ''), // Remove spaces and dashes + redirect: false, + callbackUrl, + }) + if (result?.error) { setLoginError(result.error) } else if (result?.ok) { @@ -54,6 +94,88 @@ function LoginForm() { } } + const handleBack = () => { + setShow2FA(false) + setPendingToken(null) + setTwoFactorCode('') + setUseBackupCode(false) + setLoginError(null) + } + + // 2FA verification form + if (show2FA) { + return ( + + + + Two-Factor Authentication + + + {useBackupCode + ? 'Enter one of your backup codes' + : 'Enter the code from your authenticator app'} + + +
+ + {loginError && ( +
+ {loginError} +
+ )} + +
+ + setTwoFactorCode(e.target.value)} + required + autoComplete="one-time-code" + autoFocus + /> +
+ + +
+ + + + + +
+
+ ) + } + + // Regular login form return ( diff --git a/src/app/admin/analytics/page.tsx b/src/app/admin/analytics/page.tsx new file mode 100644 index 0000000..671e03b --- /dev/null +++ b/src/app/admin/analytics/page.tsx @@ -0,0 +1,328 @@ +'use client' + +import { useState } from 'react' +import { Button } from '@/components/ui/button' +import { Skeleton } from '@/components/ui/skeleton' +import { useAnalytics, TimeRange } from '@/hooks/use-analytics' +import { StatCard } from '@/components/analytics/stat-card' +import { LineChart } from '@/components/analytics/line-chart' +import { BarChart } from '@/components/analytics/bar-chart' +import { DonutChart } from '@/components/analytics/donut-chart' +import { AnalyticsSection } from '@/components/analytics/analytics-section' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { + ShoppingCart, + Users, + CreditCard, + CheckCircle, + RefreshCw, + AlertCircle, + TrendingUp, +} from 'lucide-react' +import { cn } from '@/lib/utils' + +const TIME_RANGES: { value: TimeRange; label: string }[] = [ + { value: '7d', label: '7 Days' }, + { value: '30d', label: '30 Days' }, + { value: '90d', label: '90 Days' }, +] + +// Status colors +const STATUS_COLORS: Record = { + PENDING_PAYMENT: 'hsl(45, 90%, 50%)', + AWAITING_DNS: 'hsl(30, 80%, 55%)', + DNS_VERIFIED: 'hsl(190, 70%, 45%)', + PROVISIONING: 'hsl(220, 70%, 50%)', + FULFILLED: 'hsl(160, 60%, 45%)', + EMAIL_CONFIGURED: 'hsl(140, 70%, 40%)', + FAILED: 'hsl(350, 70%, 50%)', + CANCELLED: 'hsl(0, 0%, 50%)', +} + +// Plan colors +const PLAN_COLORS: Record = { + TRIAL: 'hsl(45, 90%, 50%)', + STARTER: 'hsl(220, 70%, 50%)', + PRO: 'hsl(160, 60%, 45%)', + ENTERPRISE: 'hsl(280, 60%, 50%)', +} + +export default function AnalyticsPage() { + const [timeRange, setTimeRange] = useState('30d') + const { data, isLoading, error, refetch, isFetching } = useAnalytics(timeRange) + + if (error) { + return ( +
+ +

Failed to load analytics

+ +
+ ) + } + + return ( +
+ {/* Header */} +
+
+

Analytics

+

+ Platform performance and insights +

+
+
+
+ {TIME_RANGES.map((range) => ( + + ))} +
+ +
+
+ + {/* Overview Stats */} + {isLoading ? ( +
+ {[...Array(4)].map((_, i) => ( + + ))} +
+ ) : data ? ( +
+ } + /> + } + /> + } + /> + } + /> +
+ ) : null} + + {/* Order Analytics */} + + {isLoading ? ( + <> + + + + ) : data ? ( + <> + ({ + date: d.date, + orders: d.count, + }))} + lines={[{ dataKey: 'orders', color: 'hsl(var(--primary))' }]} + /> + count > 0) + .map(([status, count]) => ({ + name: formatStatus(status), + value: count, + color: STATUS_COLORS[status], + }))} + centerValue={data.overview.totalOrders} + centerLabel="Total" + /> + + ) : null} + + + {/* Customer Insights */} + + {isLoading ? ( + <> + + + + ) : data ? ( + <> + ({ + date: d.date, + customers: d.count, + }))} + lines={[{ dataKey: 'customers', color: 'hsl(160, 60%, 45%)' }]} + /> + count > 0) + .map(([plan, count]) => ({ + name: formatPlan(plan), + value: count, + color: PLAN_COLORS[plan], + }))} + /> + + ) : null} + + + {/* Token Usage */} + + {isLoading ? ( + <> + + + + ) : data ? ( + <> + ({ + date: d.date, + tokens: d.tokens, + }))} + lines={[{ dataKey: 'tokens', color: 'hsl(280, 60%, 50%)' }]} + /> + ({ + name: c.name.length > 15 ? c.name.substring(0, 15) + '...' : c.name, + value: c.tokens, + }))} + horizontal + /> + + ) : null} + + + {/* Provisioning Performance */} + + {isLoading ? ( + <> + + + + ) : data ? ( + <> + count > 0) + .map(([mode, count]) => ({ + name: formatMode(mode), + value: count, + }))} + centerValue={`${data.provisioning.successRate}%`} + centerLabel="Success Rate" + /> + + + + + Recent Failures + + + + {data.provisioning.recentFailures.length === 0 ? ( +
+ +

No recent failures

+
+ ) : ( +
+ {data.provisioning.recentFailures.slice(0, 5).map((failure) => ( +
+
+

{failure.domain}

+

+ {failure.reason} +

+
+

+ {formatDate(failure.date)} +

+
+ ))} +
+ )} +
+
+ + ) : null} +
+
+ ) +} + +function formatStatus(status: string): string { + return status + .replace(/_/g, ' ') + .toLowerCase() + .replace(/\b\w/g, (l) => l.toUpperCase()) +} + +function formatPlan(plan: string): string { + return plan.charAt(0) + plan.slice(1).toLowerCase() +} + +function formatMode(mode: string): string { + return mode.charAt(0) + mode.slice(1).toLowerCase() +} + +function formatDate(dateStr: string): string { + const date = new Date(dateStr) + const now = new Date() + const diffMs = now.getTime() - date.getTime() + const diffHours = Math.floor(diffMs / (1000 * 60 * 60)) + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)) + + if (diffHours < 1) return 'Just now' + if (diffHours < 24) return `${diffHours}h ago` + if (diffDays < 7) return `${diffDays}d ago` + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) +} diff --git a/src/app/admin/customers/[id]/page.tsx b/src/app/admin/customers/[id]/page.tsx index 9db8cf8..8a9c4cd 100644 --- a/src/app/admin/customers/[id]/page.tsx +++ b/src/app/admin/customers/[id]/page.tsx @@ -13,9 +13,10 @@ import { import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' -import { useCustomer } from '@/hooks/use-customers' +import { useCustomer, useDeleteCustomer } from '@/hooks/use-customers' import { useQueryClient } from '@tanstack/react-query' import { customerKeys } from '@/hooks/use-customers' +import { SliderConfirmDialog } from '@/components/ui/slider-confirm-dialog' import { ArrowLeft, User, @@ -35,62 +36,196 @@ import { Package, X, Save, + ShoppingCart, + Sparkles, + TrendingUp, + Trash2, + Plus, } from 'lucide-react' +import { CreateOrderDialog } from '@/components/admin/create-order-dialog' type UserStatus = 'ACTIVE' | 'SUSPENDED' | 'PENDING_VERIFICATION' type SubscriptionStatus = 'TRIAL' | 'ACTIVE' | 'CANCELED' | 'PAST_DUE' type OrderStatus = 'PAYMENT_CONFIRMED' | 'AWAITING_SERVER' | 'SERVER_READY' | 'DNS_PENDING' | 'DNS_READY' | 'PROVISIONING' | 'FULFILLED' | 'EMAIL_CONFIGURED' | 'FAILED' -// Status badge components +// Status badge components with enhanced styling function UserStatusBadge({ status }: { status: UserStatus }) { - const statusConfig: Record = { - ACTIVE: { label: 'Active', className: 'bg-green-100 text-green-800' }, - SUSPENDED: { label: 'Suspended', className: 'bg-red-100 text-red-800' }, - PENDING_VERIFICATION: { label: 'Pending', className: 'bg-yellow-100 text-yellow-800' }, + const statusConfig: Record = { + ACTIVE: { + label: 'Active', + bgColor: 'bg-emerald-50 dark:bg-emerald-900/20', + textColor: 'text-emerald-700 dark:text-emerald-400', + borderColor: 'border-emerald-200 dark:border-emerald-800', + dotColor: 'bg-emerald-500' + }, + SUSPENDED: { + label: 'Suspended', + bgColor: 'bg-red-50 dark:bg-red-900/20', + textColor: 'text-red-700 dark:text-red-400', + borderColor: 'border-red-200 dark:border-red-800', + dotColor: 'bg-red-500' + }, + PENDING_VERIFICATION: { + label: 'Pending', + bgColor: 'bg-amber-50 dark:bg-amber-900/20', + textColor: 'text-amber-700 dark:text-amber-400', + borderColor: 'border-amber-200 dark:border-amber-800', + dotColor: 'bg-amber-500' + }, } const config = statusConfig[status] return ( - + + {config.label} ) } function SubscriptionBadge({ status }: { status: SubscriptionStatus }) { - const statusConfig: Record = { - TRIAL: { label: 'Trial', className: 'bg-blue-100 text-blue-800' }, - ACTIVE: { label: 'Active', className: 'bg-green-100 text-green-800' }, - CANCELED: { label: 'Canceled', className: 'bg-gray-100 text-gray-800' }, - PAST_DUE: { label: 'Past Due', className: 'bg-red-100 text-red-800' }, + const statusConfig: Record = { + TRIAL: { + label: 'Trial', + bgColor: 'bg-blue-50 dark:bg-blue-900/20', + textColor: 'text-blue-700 dark:text-blue-400', + borderColor: 'border-blue-200 dark:border-blue-800' + }, + ACTIVE: { + label: 'Active', + bgColor: 'bg-emerald-50 dark:bg-emerald-900/20', + textColor: 'text-emerald-700 dark:text-emerald-400', + borderColor: 'border-emerald-200 dark:border-emerald-800' + }, + CANCELED: { + label: 'Canceled', + bgColor: 'bg-slate-50 dark:bg-slate-900/20', + textColor: 'text-slate-600 dark:text-slate-400', + borderColor: 'border-slate-200 dark:border-slate-700' + }, + PAST_DUE: { + label: 'Past Due', + bgColor: 'bg-red-50 dark:bg-red-900/20', + textColor: 'text-red-700 dark:text-red-400', + borderColor: 'border-red-200 dark:border-red-800' + }, } const config = statusConfig[status] return ( - + {config.label} ) } function OrderStatusBadge({ status }: { status: OrderStatus }) { - const statusConfig: Record = { - PAYMENT_CONFIRMED: { label: 'Payment Confirmed', className: 'bg-blue-100 text-blue-800' }, - AWAITING_SERVER: { label: 'Awaiting Server', className: 'bg-yellow-100 text-yellow-800' }, - SERVER_READY: { label: 'Server Ready', className: 'bg-cyan-100 text-cyan-800' }, - DNS_PENDING: { label: 'DNS Pending', className: 'bg-orange-100 text-orange-800' }, - DNS_READY: { label: 'DNS Ready', className: 'bg-teal-100 text-teal-800' }, - PROVISIONING: { label: 'Provisioning', className: 'bg-purple-100 text-purple-800' }, - FULFILLED: { label: 'Fulfilled', className: 'bg-green-100 text-green-800' }, - EMAIL_CONFIGURED: { label: 'Email Configured', className: 'bg-emerald-100 text-emerald-800' }, - FAILED: { label: 'Failed', className: 'bg-red-100 text-red-800' }, + const statusConfig: Record = { + PAYMENT_CONFIRMED: { + label: 'Payment Confirmed', + bgColor: 'bg-blue-50 dark:bg-blue-900/20', + textColor: 'text-blue-700 dark:text-blue-400', + borderColor: 'border-blue-200 dark:border-blue-800' + }, + AWAITING_SERVER: { + label: 'Awaiting Server', + bgColor: 'bg-amber-50 dark:bg-amber-900/20', + textColor: 'text-amber-700 dark:text-amber-400', + borderColor: 'border-amber-200 dark:border-amber-800' + }, + SERVER_READY: { + label: 'Server Ready', + bgColor: 'bg-cyan-50 dark:bg-cyan-900/20', + textColor: 'text-cyan-700 dark:text-cyan-400', + borderColor: 'border-cyan-200 dark:border-cyan-800' + }, + DNS_PENDING: { + label: 'DNS Pending', + bgColor: 'bg-orange-50 dark:bg-orange-900/20', + textColor: 'text-orange-700 dark:text-orange-400', + borderColor: 'border-orange-200 dark:border-orange-800' + }, + DNS_READY: { + label: 'DNS Ready', + bgColor: 'bg-teal-50 dark:bg-teal-900/20', + textColor: 'text-teal-700 dark:text-teal-400', + borderColor: 'border-teal-200 dark:border-teal-800' + }, + PROVISIONING: { + label: 'Provisioning', + bgColor: 'bg-purple-50 dark:bg-purple-900/20', + textColor: 'text-purple-700 dark:text-purple-400', + borderColor: 'border-purple-200 dark:border-purple-800' + }, + FULFILLED: { + label: 'Fulfilled', + bgColor: 'bg-emerald-50 dark:bg-emerald-900/20', + textColor: 'text-emerald-700 dark:text-emerald-400', + borderColor: 'border-emerald-200 dark:border-emerald-800' + }, + EMAIL_CONFIGURED: { + label: 'Email Configured', + bgColor: 'bg-emerald-50 dark:bg-emerald-900/20', + textColor: 'text-emerald-700 dark:text-emerald-400', + borderColor: 'border-emerald-200 dark:border-emerald-800' + }, + FAILED: { + label: 'Failed', + bgColor: 'bg-red-50 dark:bg-red-900/20', + textColor: 'text-red-700 dark:text-red-400', + borderColor: 'border-red-200 dark:border-red-800' + }, + } + const config = statusConfig[status] || { + label: status, + bgColor: 'bg-slate-50 dark:bg-slate-900/20', + textColor: 'text-slate-600 dark:text-slate-400', + borderColor: 'border-slate-200 dark:border-slate-700' } - const config = statusConfig[status] || { label: status, className: 'bg-gray-100 text-gray-800' } return ( - + {config.label} ) } +// Token usage progress bar with threshold colors +function TokenUsageBar({ used, limit }: { used: number; limit: number }) { + const percentage = Math.min((used / limit) * 100, 100) + + const getBarColor = () => { + if (percentage > 90) return 'bg-gradient-to-r from-red-500 to-red-600' + if (percentage > 75) return 'bg-gradient-to-r from-amber-500 to-orange-500' + if (percentage > 50) return 'bg-gradient-to-r from-yellow-400 to-amber-500' + return 'bg-gradient-to-r from-emerald-500 to-emerald-600' + } + + const getTextColor = () => { + if (percentage > 90) return 'text-red-600 dark:text-red-400' + if (percentage > 75) return 'text-amber-600 dark:text-amber-400' + return 'text-emerald-600 dark:text-emerald-400' + } + + return ( +
+
+ Token Usage + + {used.toLocaleString()} / {limit.toLocaleString()} + +
+
+
+
+
+ {percentage.toFixed(1)}% used + {(100 - percentage).toFixed(1)}% remaining +
+
+ ) +} + export default function CustomerDetailPage() { const params = useParams() const router = useRouter() @@ -100,8 +235,11 @@ export default function CustomerDetailPage() { const [isEditing, setIsEditing] = useState(false) const [editForm, setEditForm] = useState({ name: '', company: '' }) const [isUpdating, setIsUpdating] = useState(false) + const [showDeleteDialog, setShowDeleteDialog] = useState(false) + const [showCreateOrderDialog, setShowCreateOrderDialog] = useState(false) const { data: customer, isLoading, isError, error, refetch, isFetching } = useCustomer(customerId) + const deleteCustomer = useDeleteCustomer() const handleEdit = () => { if (customer) { @@ -156,31 +294,43 @@ export default function CustomerDetailPage() { } } - // Loading state + const handleDeleteCustomer = async () => { + await deleteCustomer.mutateAsync(customerId) + router.push('/admin/customers') + } + + // Loading state with enhanced styling if (isLoading) { return ( -
-
- -

Loading customer details...

+
+
+
+
+ +
+

Loading customer details...

) } - // Error state + // Error state with enhanced styling if (isError) { return ( -
-
- -
-

Failed to load customer

-

- {error instanceof Error ? error.message : 'An error occurred'} -

+
+ +
+
+
-
+

Failed to load customer

+

+ {error instanceof Error ? error.message : 'An error occurred'} +

+
+
+ +
+
+ +
+

Customer not found

+

+ The customer you are looking for does not exist or you do not have access. +

+ + +
) @@ -217,284 +377,386 @@ export default function CustomerDetailPage() { : 0 return ( -
- {/* Header */} -
-
- -
-

{customer.name || customer.email}

-

{customer.email}

+
+ {/* Hero Header Section */} +
+ {/* Background decoration */} +
+
+ +
+ {/* Back link */} + + + Back to Customers + + +
+ {/* Customer identity */} +
+
+ +
+
+
+

{customer.name || customer.email}

+ +
+

{customer.email}

+
+
+ + {/* Action buttons */} +
+ + + {customer.status === 'ACTIVE' ? ( + + ) : ( + + )} + +
- -
-
- - {customer.status === 'ACTIVE' ? ( - - ) : ( - - )}
- {/* Stats Row */} + {/* Delete confirmation dialog */} + + + {/* Create Order dialog */} + { + queryClient.invalidateQueries({ queryKey: customerKeys.detail(customerId) }) + }} + /> + + {/* Stats Row with colored icon backgrounds */}
- - -
- -
-
{customer._count?.orders || 0}
-

Total Orders

-
+
+
+
+
- - - - -
- -
-
- {customer.orders?.filter((o: { status: string }) => o.status === 'FULFILLED').length || 0} -
-

Active Servers

-
+
+
{customer._count?.orders || 0}
+

Total Orders

- - - - -
- -
-
{totalTokensUsed.toLocaleString()}
-

Tokens Used

-
+
+
+ +
+
+
+
- - - - -
- -
-
- {currentSubscription?.plan.toLowerCase() || 'None'} -
-

Current Plan

+
+
+ {customer.orders?.filter((o: { status: string }) => o.status === 'FULFILLED').length || 0}
+

Active Servers

- - +
+
+ +
+
+
+ +
+
+
{totalTokensUsed.toLocaleString()}
+

Tokens Used

+
+
+
+ +
+
+
+ +
+
+
+ {currentSubscription?.plan.toLowerCase() || 'None'} +
+

Current Plan

+
+
+
{/* Customer Profile Card */} - - +
+
- Profile +
+
+ +
+
+

Profile

+

Customer information

+
+
{!isEditing && ( - )}
- - - {isEditing ? ( - <> -
- - setEditForm({ ...editForm, name: e.target.value })} - /> -
-
- - setEditForm({ ...editForm, company: e.target.value })} - /> -
-
- - -
- - ) : ( - <> -
-
- -
-
-

{customer.name || 'No name'}

-

Name

-
-
-
-
- -
-
-

{customer.email}

-

Email

-
-
-
-
- -
-
-

{customer.company || 'Not set'}

-

Company

-
-
-
-
- -
-
-

- {new Date(customer.createdAt).toLocaleDateString()} -

-

Member Since

-
-
- - )} -
- - {/* Subscription Card */} - - - Subscription - Current plan and token usage - - - {currentSubscription ? ( -
-
-
-

- {currentSubscription.plan.toLowerCase()} Plan -

-

- {currentSubscription.tier.replace('_', ' ').toLowerCase()} tier -

-
- -
- - {currentSubscription.trialEndsAt && ( -
-

- Trial ends {new Date(currentSubscription.trialEndsAt).toLocaleDateString()} -

-
- )} - -
-
- Token Usage - - {totalTokensUsed.toLocaleString()} / {currentSubscription.tokenLimit.toLocaleString()} - -
-
-
90 ? 'bg-red-500' : tokenUsagePercent > 70 ? 'bg-yellow-500' : 'bg-primary' - }`} - style={{ width: `${tokenUsagePercent}%` }} +
+ {isEditing ? ( +
+
+ + setEditForm({ ...editForm, name: e.target.value })} + className="bg-background" />
-

- {(100 - tokenUsagePercent).toFixed(1)}% remaining +

+ + setEditForm({ ...editForm, company: e.target.value })} + className="bg-background" + /> +
+
+ + +
+
+ ) : ( +
+
+
+ +
+
+

{customer.name || 'No name'}

+

Name

+
+
+ +
+
+ +
+
+

{customer.email}

+

Email

+
+
+ +
+
+ +
+
+

{customer.company || 'Not set'}

+

Company

+
+
+ +
+
+ +
+
+

+ {new Date(customer.createdAt).toLocaleDateString()} +

+

Member Since

+
+
+
+ )} +
+
+
+ + {/* Subscription Card */} +
+
+
+
+ +
+
+

Subscription

+

Current plan and token usage

+
+
+ +
+ {currentSubscription ? ( +
+
+
+
+ +
+
+

+ {currentSubscription.plan.toLowerCase()} Plan +

+

+ {currentSubscription.tier.replace('_', ' ').toLowerCase()} tier +

+
+
+ +
+ + {currentSubscription.trialEndsAt && ( +
+
+
+ +
+
+

Trial Period Active

+

+ Ends {new Date(currentSubscription.trialEndsAt).toLocaleDateString()} +

+
+
+
+ )} + +
+ +
+
+ ) : ( +
+
+ +
+

No Active Subscription

+

+ This customer does not have an active subscription plan

-
- ) : ( -
-

No active subscription

-
- )} - - + )} +
+ +
{/* Orders History */} - - -
-
- Orders History - - {customer.orders?.length || 0} order{(customer.orders?.length || 0) !== 1 ? 's' : ''} - +
+
+
+
+ +
+
+

Orders History

+

+ {customer.orders?.length || 0} order{(customer.orders?.length || 0) !== 1 ? 's' : ''} total +

- - -
- - + + + +
+ +
{customer.orders && customer.orders.length > 0 ? (
- - - - - - - + + + + + + + - + {customer.orders.map((order: { id: string domain: string @@ -503,24 +765,40 @@ export default function CustomerDetailPage() { serverIp: string | null createdAt: Date | string }) => ( - - - + - + - - - @@ -530,12 +808,18 @@ export default function CustomerDetailPage() {
DomainTierStatusServer IPCreatedActions
DomainTierStatusServer IPCreatedActions
{order.domain} - {order.tier.replace('_', ' ').toLowerCase()} +
+ {order.domain} + + + {order.tier.replace('_', ' ').toLowerCase()} + + - {order.serverIp || '-'} + + + {order.serverIp || '-'} + - {new Date(order.createdAt).toLocaleDateString()} + + + {new Date(order.createdAt).toLocaleDateString()} + + -
) : ( -
-

No orders yet

+
+
+ +
+

No Orders Yet

+

+ This customer has not placed any orders +

)} - - +
+
) } diff --git a/src/app/admin/customers/page.tsx b/src/app/admin/customers/page.tsx index e5008e1..fea6443 100644 --- a/src/app/admin/customers/page.tsx +++ b/src/app/admin/customers/page.tsx @@ -12,6 +12,7 @@ import { import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { useCustomers } from '@/hooks/use-customers' +import { AddCustomerDialog } from '@/components/admin/AddCustomerDialog' import { UserStatus as ApiUserStatus } from '@/types/api' import { Search, @@ -28,6 +29,11 @@ import { Loader2, AlertCircle, RefreshCw, + Users, + UserCheck, + Clock, + Zap, + UserX, } from 'lucide-react' type UserStatus = 'ACTIVE' | 'SUSPENDED' | 'PENDING_VERIFICATION' @@ -50,81 +56,171 @@ interface Customer { createdAt: string } -// Status badge component +// Status badge component with dot indicator and animations function UserStatusBadge({ status }: { status: UserStatus }) { - const statusConfig: Record = { - ACTIVE: { label: 'Active', className: 'bg-green-100 text-green-800' }, - SUSPENDED: { label: 'Suspended', className: 'bg-red-100 text-red-800' }, - PENDING_VERIFICATION: { label: 'Pending', className: 'bg-yellow-100 text-yellow-800' }, + const statusConfig: Record = { + ACTIVE: { + label: 'Active', + bgColor: 'bg-emerald-50 dark:bg-emerald-950/30', + textColor: 'text-emerald-700 dark:text-emerald-400', + borderColor: 'border-emerald-200 dark:border-emerald-800', + dotColor: 'bg-emerald-500', + animate: true + }, + SUSPENDED: { + label: 'Suspended', + bgColor: 'bg-red-50 dark:bg-red-950/30', + textColor: 'text-red-700 dark:text-red-400', + borderColor: 'border-red-200 dark:border-red-800', + dotColor: 'bg-red-500', + animate: false + }, + PENDING_VERIFICATION: { + label: 'Pending', + bgColor: 'bg-amber-50 dark:bg-amber-950/30', + textColor: 'text-amber-700 dark:text-amber-400', + borderColor: 'border-amber-200 dark:border-amber-800', + dotColor: 'bg-amber-500', + animate: true + }, } const config = statusConfig[status] return ( - + + {config.label} ) } function SubscriptionBadge({ status }: { status: SubscriptionStatus }) { - const statusConfig: Record = { - TRIAL: { label: 'Trial', className: 'bg-blue-100 text-blue-800' }, - ACTIVE: { label: 'Active', className: 'bg-green-100 text-green-800' }, - CANCELED: { label: 'Canceled', className: 'bg-gray-100 text-gray-800' }, - PAST_DUE: { label: 'Past Due', className: 'bg-red-100 text-red-800' }, + const statusConfig: Record = { + TRIAL: { + label: 'Trial', + bgColor: 'bg-blue-50 dark:bg-blue-950/30', + textColor: 'text-blue-700 dark:text-blue-400', + borderColor: 'border-blue-200 dark:border-blue-800', + dotColor: 'bg-blue-500' + }, + ACTIVE: { + label: 'Active', + bgColor: 'bg-emerald-50 dark:bg-emerald-950/30', + textColor: 'text-emerald-700 dark:text-emerald-400', + borderColor: 'border-emerald-200 dark:border-emerald-800', + dotColor: 'bg-emerald-500' + }, + CANCELED: { + label: 'Canceled', + bgColor: 'bg-slate-50 dark:bg-slate-950/30', + textColor: 'text-slate-600 dark:text-slate-400', + borderColor: 'border-slate-200 dark:border-slate-700', + dotColor: 'bg-slate-400' + }, + PAST_DUE: { + label: 'Past Due', + bgColor: 'bg-red-50 dark:bg-red-950/30', + textColor: 'text-red-700 dark:text-red-400', + borderColor: 'border-red-200 dark:border-red-800', + dotColor: 'bg-red-500' + }, } const config = statusConfig[status] return ( - + + {config.label} ) } -// Customer table row component +// Token usage progress bar with gradient colors based on threshold +function TokenUsageBar({ used, limit }: { used: number; limit: number }) { + const percentage = limit > 0 ? Math.min((used / limit) * 100, 100) : 0 + + // Determine gradient based on usage threshold + const getGradientClass = () => { + if (percentage > 90) return 'bg-gradient-to-r from-red-500 to-red-600' + if (percentage > 75) return 'bg-gradient-to-r from-amber-500 to-orange-500' + if (percentage > 50) return 'bg-gradient-to-r from-blue-500 to-blue-600' + return 'bg-gradient-to-r from-emerald-500 to-emerald-600' + } + + return ( +
+
+ {used.toLocaleString()} + {percentage.toFixed(0)}% +
+
+
+
+
+ of {limit.toLocaleString()} +
+
+ ) +} + +// Customer card-style row component function CustomerRow({ customer }: { customer: Customer }) { return ( - - -
-
- -
-
- - {customer.name} - -
- - {customer.email} -
-
+
+ {/* Customer Info */} +
+
+
- - - {customer.company ? ( -
- - {customer.company} +
+ + {customer.name} + +
+ + {customer.email}
- ) : ( - - - )} - - + {customer.company && ( +
+ + {customer.company} +
+ )} +
+
+ + {/* Status */} +
- - +
+ + {/* Subscription */} +
{customer.subscription ? (
- {customer.subscription.plan.toLowerCase()} + {customer.subscription.plan.toLowerCase()}
@@ -132,57 +228,157 @@ function CustomerRow({ customer }: { customer: Customer }) {
) : ( - No subscription + No subscription )} - - - {customer.subscription ? ( -
-
- {customer.subscription.tokensUsed.toLocaleString()} /{' '} - {customer.subscription.tokenLimit.toLocaleString()} -
-
-
-
-
+
+ + {/* Token Usage */} +
+ {customer.subscription && customer.subscription.tokenLimit > 0 ? ( + ) : ( - - + - )} - - -
- - {customer.activeServers} +
+ + {/* Servers & Date */} +
+
+
+ +
+ {customer.activeServers}
- - -
- +
+ {new Date(customer.createdAt).toLocaleDateString()}
- - -
- - - -
+ + {/* Actions */} +
+ + + + +
+
+ ) +} + +// Stats card component with colored icon backgrounds +function StatsCard({ + title, + value, + icon: Icon, + iconBg, + iconColor, + subtitle +}: { + title: string + value: number | string + icon: typeof Users + iconBg: string + iconColor: string + subtitle?: string +}) { + return ( + + +
+
+

{title}

+

{value}

+ {subtitle && ( +

{subtitle}

+ )} +
+
+ +
- - +
+ {/* Decorative gradient */} +
+ + ) +} + +// Filter pill toggle component +function FilterPill({ + label, + isActive, + onClick, + count +}: { + label: string + isActive: boolean + onClick: () => void + count?: number +}) { + return ( + + ) +} + +// Empty state component +function EmptyState({ + hasFilters, + onClearFilters +}: { + hasFilters: boolean + onClearFilters: () => void +}) { + return ( +
+
+
+
+ +
+
+

No customers found

+

+ {hasFilters + ? "We couldn't find any customers matching your current filters. Try adjusting your search criteria." + : "You haven't added any customers yet. Get started by adding your first customer." + } +

+ {hasFilters && ( + + )} +
) } @@ -190,6 +386,7 @@ export default function CustomersPage() { const [search, setSearch] = useState('') const [statusFilter, setStatusFilter] = useState('all') const [currentPage, setCurrentPage] = useState(1) + const [showAddDialog, setShowAddDialog] = useState(false) const itemsPerPage = 10 // Fetch customers from API @@ -237,15 +434,25 @@ export default function CustomersPage() { }), [customers, data?.pagination?.total]) const totalPages = data?.pagination?.totalPages || 1 + const hasFilters = search !== '' || statusFilter !== 'all' + + const clearFilters = () => { + setSearch('') + setStatusFilter('all') + setCurrentPage(1) + } // Loading state if (isLoading) { return ( -
-
- -

Loading customers...

+
+
+
+
+ +
+

Loading customers...

) } @@ -253,90 +460,114 @@ export default function CustomersPage() { // Error state if (isError) { return ( -
-
- -
-

Failed to load customers

-

- {error instanceof Error ? error.message : 'An error occurred'} -

+
+
+
+
+
-
+
+

Failed to load customers

+

+ {error instanceof Error ? error.message : 'An error occurred'} +

+
+
) } return ( -
- {/* Page header */} -
-
-

Customers

-

- Manage customer accounts and subscriptions -

-
-
- - +
+ {/* Hero Header */} +
+ {/* Background decoration */} +
+
+ +
+
+
+ +
+
+

Customers

+

+ Manage customer accounts and subscriptions +

+
+
+
+ + +
{/* Stats cards */} -
- - -
{stats.total}
-

Total Customers

-
-
- - -
{stats.active}
-

Active

-
-
- - -
{stats.trial}
-

On Trial

-
-
- - -
{stats.totalServers}
-

Total Servers

-
-
+
+ + + +
- {/* Filters and table */} - - -
+ {/* Filters and list */} + + +
- All Customers + All Customers {data?.pagination?.total || 0} customer{(data?.pagination?.total || 0) !== 1 ? 's' : ''} found
-
+
+ {/* Search input */}
{ setSearch(e.target.value) - setCurrentPage(1) // Reset to first page on search + setCurrentPage(1) }} - className="pl-9 w-full sm:w-64" + className="pl-10 w-full sm:w-72 bg-background" + /> +
+ {/* Status filter pills */} +
+ { setStatusFilter('all'); setCurrentPage(1) }} + /> + { setStatusFilter('ACTIVE'); setCurrentPage(1) }} + /> + { setStatusFilter('SUSPENDED'); setCurrentPage(1) }} + /> + { setStatusFilter('PENDING_VERIFICATION'); setCurrentPage(1) }} />
-
- + {customers.length === 0 ? ( -
-

No customers found

- {(search || statusFilter !== 'all') && ( - - )} -
+ ) : ( <> -
- - - - - - - - - - - - - - - {customers.map((customer) => ( - - ))} - -
CustomerCompanyStatusSubscriptionToken UsageServersJoinedActions
+ {/* Customer list */} +
+ {customers.map((customer) => ( + + ))}
- {/* Pagination */} + {/* Enhanced Pagination */} {totalPages > 1 && ( -
+

- Page {currentPage} of {totalPages} + Showing {(currentPage - 1) * itemsPerPage + 1} to{' '} + + {Math.min(currentPage * itemsPerPage, data?.pagination?.total || 0)} + {' '} + of {data?.pagination?.total || 0} customers

+ + {/* Page numbers */} +
+ {Array.from({ length: Math.min(5, totalPages) }, (_, i) => { + let pageNum: number + if (totalPages <= 5) { + pageNum = i + 1 + } else if (currentPage <= 3) { + pageNum = i + 1 + } else if (currentPage >= totalPages - 2) { + pageNum = totalPages - 4 + i + } else { + pageNum = currentPage - 2 + i + } + return ( + + ) + })} +
+
) } diff --git a/src/app/admin/enterprise-clients/[id]/page.tsx b/src/app/admin/enterprise-clients/[id]/page.tsx new file mode 100644 index 0000000..416d737 --- /dev/null +++ b/src/app/admin/enterprise-clients/[id]/page.tsx @@ -0,0 +1,908 @@ +'use client' + +import { use, useState } from 'react' +import Link from 'next/link' +import { useRouter } from 'next/navigation' +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { + useEnterpriseClient, + useUpdateEnterpriseClient, + useDeleteEnterpriseClient, + useClientServers, + useServerAction, + useErrorRules, + useCreateErrorRule, + useDetectedErrors, + useAcknowledgeError, + useAddServerToClient, +} from '@/hooks/use-enterprise-clients' +import { useNetcupServers } from '@/hooks/use-netcup' +import { + ArrowLeft, + Building2, + Server, + AlertTriangle, + Mail, + Phone, + FileText, + Edit, + Trash2, + Power, + RotateCcw, + Plus, + Check, + X, + Loader2, + AlertCircle, + RefreshCw, + Activity, + HardDrive, + Cpu, + MemoryStick, + Clock, + ShieldAlert, + Eye, +} from 'lucide-react' +import type { EnterpriseServerWithStatus, ErrorDetectionRule, DetectedError, ErrorSeverity } from '@/types/api' + +// Overview stat card +function OverviewCard({ + title, + value, + icon: Icon, + className = '' +}: { + title: string + value: string | number + icon: typeof Cpu + className?: string +}) { + return ( + + +
+
+ +
+
+

{title}

+

{value}

+
+
+
+
+ ) +} + +// Server card component +function ServerCard({ + server, + clientId, + onPowerAction +}: { + server: EnterpriseServerWithStatus + clientId: string + onPowerAction: (serverId: string, command: 'ON' | 'OFF' | 'POWERCYCLE') => void +}) { + const statusColors: Record = { + running: { bg: 'bg-emerald-50 dark:bg-emerald-950/30', text: 'text-emerald-700 dark:text-emerald-400', dot: 'bg-emerald-500 animate-pulse' }, + stopped: { bg: 'bg-slate-50 dark:bg-slate-950/30', text: 'text-slate-600 dark:text-slate-400', dot: 'bg-slate-400' }, + error: { bg: 'bg-red-50 dark:bg-red-950/30', text: 'text-red-700 dark:text-red-400', dot: 'bg-red-500' }, + unknown: { bg: 'bg-amber-50 dark:bg-amber-950/30', text: 'text-amber-700 dark:text-amber-400', dot: 'bg-amber-500' } + } + + const status = server.netcupStatus?.toLowerCase() || 'unknown' + const colors = statusColors[status] || statusColors.unknown + + return ( + + +
+
+
+ +
+
+ + {server.nickname || server.netcupServerId} + + {server.purpose && ( +

{server.purpose}

+ )} +
+ + + {status.charAt(0).toUpperCase() + status.slice(1)} + +
+ {server.netcupIps?.length > 0 && ( +

+ {server.netcupIps[0]} +

+ )} +
+
+
+ + + + {status === 'running' ? ( + <> + + + + ) : ( + + )} +
+
+
+
+ ) +} + +// Error rule row +function ErrorRuleRow({ rule }: { rule: ErrorDetectionRule }) { + const severityColors: Record = { + INFO: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400', + WARNING: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400', + ERROR: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400', + CRITICAL: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400' + } + + return ( +
+
+
+ {rule.isActive ? ( + + ) : ( + + )} +
+
+

{rule.name}

+

+ {rule.pattern} +

+
+
+
+ + {rule.severity} + + + {rule._count?.detectedErrors || 0} matches + +
+
+ ) +} + +// Detected error row +function DetectedErrorRow({ + error, + onAcknowledge +}: { + error: DetectedError + onAcknowledge: () => void +}) { + const severityColors: Record = { + INFO: 'border-l-blue-500', + WARNING: 'border-l-amber-500', + ERROR: 'border-l-red-500', + CRITICAL: 'border-l-purple-500' + } + + const isAcknowledged = !!error.acknowledgedAt + + return ( +
+
+
+
+ {error.rule?.name} + {error.containerName && ( + + in {error.containerName} + + )} +
+

+ {error.logLine} +

+

+ {new Date(error.timestamp).toLocaleString()} + {error.server && ` • ${error.server.nickname || error.server.netcupServerId}`} +

+
+ {!isAcknowledged && ( + + )} +
+
+ ) +} + +// Add rule dialog +function AddRuleDialog({ + open, + onOpenChange, + clientId +}: { + open: boolean + onOpenChange: (open: boolean) => void + clientId: string +}) { + const [formData, setFormData] = useState({ + name: '', + pattern: '', + severity: 'WARNING' as ErrorSeverity, + description: '' + }) + + const createRule = useCreateErrorRule() + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + try { + await createRule.mutateAsync({ + clientId, + data: { + name: formData.name, + pattern: formData.pattern, + severity: formData.severity, + description: formData.description || undefined + } + }) + setFormData({ name: '', pattern: '', severity: 'WARNING', description: '' }) + onOpenChange(false) + } catch { + // Error handled by mutation + } + } + + return ( + + + + Add Error Detection Rule + + Create a regex pattern to detect errors in container logs. + + +
+
+
+ + setFormData({ ...formData, name: e.target.value })} + placeholder="Database Connection Failed" + required + /> +
+
+ + setFormData({ ...formData, pattern: e.target.value })} + placeholder="error|ERROR|failed|FAILED" + className="font-mono text-sm" + required + /> +
+
+ + +
+
+ + setFormData({ ...formData, description: e.target.value })} + placeholder="Detects database connection errors" + /> +
+
+ + + + +
+
+
+ ) +} + +// Add server dialog +function AddServerDialog({ + open, + onOpenChange, + clientId, + existingServerIds +}: { + open: boolean + onOpenChange: (open: boolean) => void + clientId: string + existingServerIds: string[] +}) { + const [formData, setFormData] = useState({ + netcupServerId: '', + nickname: '', + purpose: '', + portainerUrl: '', + portainerUsername: '', + portainerPassword: '' + }) + + const { data: netcupServers, isLoading: loadingServers } = useNetcupServers(false) + const addServer = useAddServerToClient() + + // Filter out servers that are already linked + const availableServers = netcupServers?.servers?.filter( + (s: { id: string }) => !existingServerIds.includes(s.id) + ) || [] + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + if (!formData.netcupServerId) return + + try { + await addServer.mutateAsync({ + clientId, + data: { + netcupServerId: formData.netcupServerId, + nickname: formData.nickname || undefined, + purpose: formData.purpose || undefined, + portainerUrl: formData.portainerUrl || undefined, + portainerUsername: formData.portainerUsername || undefined, + portainerPassword: formData.portainerPassword || undefined + } + }) + setFormData({ + netcupServerId: '', + nickname: '', + purpose: '', + portainerUrl: '', + portainerUsername: '', + portainerPassword: '' + }) + onOpenChange(false) + } catch { + // Error handled by mutation + } + } + + return ( + + + + Add Server to Client + + Link a Netcup server to this enterprise client. + + +
+
+
+ + {loadingServers ? ( +
+ + Loading servers... +
+ ) : availableServers.length === 0 ? ( +
+ No available servers. All Netcup servers are already linked. +
+ ) : ( + + )} +
+
+
+ + setFormData({ ...formData, nickname: e.target.value })} + placeholder="Production Server" + /> +
+
+ + setFormData({ ...formData, purpose: e.target.value })} + placeholder="Web hosting" + /> +
+
+
+

Portainer Credentials (Optional)

+
+
+ + setFormData({ ...formData, portainerUrl: e.target.value })} + placeholder="https://portainer.example.com" + /> +
+
+
+ + setFormData({ ...formData, portainerUsername: e.target.value })} + placeholder="admin" + /> +
+
+ + setFormData({ ...formData, portainerPassword: e.target.value })} + placeholder="••••••••" + /> +
+
+
+
+
+ + + + +
+
+
+ ) +} + +export default function EnterpriseClientDetailPage({ + params +}: { + params: Promise<{ id: string }> +}) { + const { id: clientId } = use(params) + const router = useRouter() + const [showAddRuleDialog, setShowAddRuleDialog] = useState(false) + const [showAddServerDialog, setShowAddServerDialog] = useState(false) + const [showDeleteDialog, setShowDeleteDialog] = useState(false) + + const { data: client, isLoading, isError, error, refetch } = useEnterpriseClient(clientId) + const { data: servers } = useClientServers(clientId) + const { data: errorRules } = useErrorRules(clientId) + const { data: detectedErrors } = useDetectedErrors(clientId, { acknowledged: false, limit: 50 }) + + const deleteClient = useDeleteEnterpriseClient() + const serverAction = useServerAction() + const acknowledgeError = useAcknowledgeError() + + const handlePowerAction = async (serverId: string, command: 'ON' | 'OFF' | 'POWERCYCLE') => { + try { + await serverAction.mutateAsync({ + clientId, + serverId, + action: { action: 'power', command } + }) + } catch { + // Error handled by mutation + } + } + + const handleAcknowledgeError = async (errorId: string) => { + try { + await acknowledgeError.mutateAsync({ clientId, errorId }) + } catch { + // Error handled by mutation + } + } + + const handleDeleteClient = async () => { + try { + await deleteClient.mutateAsync(clientId) + router.push('/admin/enterprise-clients') + } catch { + // Error handled by mutation + } + } + + if (isLoading) { + return ( +
+ +

Loading client details...

+
+ ) + } + + if (isError || !client) { + return ( +
+ +

Failed to load client

+

+ {error instanceof Error ? error.message : 'Client not found'} +

+ +
+ ) + } + + return ( +
+ {/* Header */} +
+
+ + + +
+
+ +
+
+

{client.name}

+ {client.companyName && ( +

{client.companyName}

+ )} +
+
+
+
+ + +
+
+ + {/* Client info cards */} +
+ + +
+ +
+

Contact Email

+

{client.contactEmail}

+
+
+
+
+ {client.contactPhone && ( + + +
+ +
+

Contact Phone

+

{client.contactPhone}

+
+
+
+
+ )} + {client.notes && ( + + +
+ +
+

Notes

+

{client.notes}

+
+
+
+
+ )} +
+ + {/* Overview stats */} +
+ + + + +
+ + {/* Tabs */} + + + + + Servers ({servers?.length || 0}) + + + + Errors ({detectedErrors?.length || 0}) + + + + Rules ({errorRules?.length || 0}) + + + + +
+

+ {servers?.length || 0} server{servers?.length !== 1 ? 's' : ''} linked +

+ +
+ {servers && servers.length > 0 ? ( +
+ {servers.map((server) => ( + + ))} +
+ ) : ( + + + +

No servers linked to this client yet.

+ +
+
+ )} +
+ + + {detectedErrors && detectedErrors.length > 0 ? ( +
+ {detectedErrors.map((err) => ( + handleAcknowledgeError(err.id)} + /> + ))} +
+ ) : ( + + + +

No unacknowledged errors.

+
+
+ )} +
+ + +
+

+ Define regex patterns to detect errors in container logs. +

+ +
+ {errorRules && errorRules.length > 0 ? ( +
+ {errorRules.map((rule) => ( + + ))} +
+ ) : ( + + + +

No error detection rules configured.

+ +
+
+ )} +
+
+ + {/* Add Rule Dialog */} + + + {/* Add Server Dialog */} + s.netcupServerId) || []} + /> + + {/* Delete Confirmation Dialog */} + + + + Delete Enterprise Client + + Are you sure you want to delete {client.name}? This will remove all associated servers, error rules, and detected errors. This action cannot be undone. + + + + + + + + +
+ ) +} diff --git a/src/app/admin/enterprise-clients/[id]/servers/[serverId]/containers/[containerId]/page.tsx b/src/app/admin/enterprise-clients/[id]/servers/[serverId]/containers/[containerId]/page.tsx new file mode 100644 index 0000000..3d21c55 --- /dev/null +++ b/src/app/admin/enterprise-clients/[id]/servers/[serverId]/containers/[containerId]/page.tsx @@ -0,0 +1,403 @@ +'use client' + +import { useState, useEffect, useRef } from 'react' +import { useParams, useRouter } from 'next/navigation' +import Link from 'next/link' +import { + ArrowLeft, + Box, + Play, + Square, + RefreshCw, + Trash2, + Loader2, + Clock, + HardDrive, + Cpu, + MemoryStick, + Network, + Download, + AlertTriangle +} from 'lucide-react' +import { + useEnterpriseClient, + useClientServer, + useContainer, + useContainerLogs, + useContainerAction, + useRemoveContainer +} from '@/hooks/use-enterprise-clients' + +function formatBytes(bytes: number): string { + if (bytes === 0) return '0 B' + const k = 1024 + const sizes = ['B', 'KB', 'MB', 'GB', 'TB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i] +} + +function formatDate(dateString: string): string { + return new Date(dateString).toLocaleString() +} + +export default function ContainerDetailPage() { + const params = useParams() + const router = useRouter() + const clientId = params.id as string + const serverId = params.serverId as string + const containerId = params.containerId as string + + const [tail, setTail] = useState(500) + const [autoScroll, setAutoScroll] = useState(true) + const [actionLoading, setActionLoading] = useState(null) + const logsEndRef = useRef(null) + + const { data: client, isLoading: clientLoading } = useEnterpriseClient(clientId) + const { data: server, isLoading: serverLoading } = useClientServer(clientId, serverId) + const { data: container, isLoading: containerLoading, refetch: refetchContainer } = useContainer(clientId, serverId, containerId) + const { data: logsData, isLoading: logsLoading, refetch: refetchLogs } = useContainerLogs(clientId, serverId, containerId, tail) + const containerAction = useContainerAction() + const removeContainer = useRemoveContainer() + + const isLoading = clientLoading || serverLoading || containerLoading + + // Auto-scroll to bottom when logs update + useEffect(() => { + if (autoScroll && logsEndRef.current) { + logsEndRef.current.scrollIntoView({ behavior: 'smooth' }) + } + }, [logsData, autoScroll]) + + // Auto-refresh logs every 5 seconds + useEffect(() => { + const interval = setInterval(() => { + refetchLogs() + refetchContainer() + }, 5000) + return () => clearInterval(interval) + }, [refetchLogs, refetchContainer]) + + const handleAction = async (action: 'start' | 'stop' | 'restart') => { + setActionLoading(action) + try { + await containerAction.mutateAsync({ + clientId, + serverId, + containerId, + action, + }) + // Refetch container data after action + setTimeout(() => refetchContainer(), 1000) + } catch (err) { + console.error(`Failed to ${action} container:`, err) + } finally { + setActionLoading(null) + } + } + + const handleRemove = async () => { + if (!confirm('Are you sure you want to remove this container? This action cannot be undone.')) return + + setActionLoading('remove') + try { + await removeContainer.mutateAsync({ + clientId, + serverId, + containerId, + force: true, + }) + router.push(`/admin/enterprise-clients/${clientId}/servers/${serverId}`) + } catch (err) { + console.error('Failed to remove container:', err) + } finally { + setActionLoading(null) + } + } + + const handleDownloadLogs = () => { + if (!logsData?.logs || !container) return + + const blob = new Blob([logsData.logs], { type: 'text/plain' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `${container.name || container.id.slice(0, 12)}-logs.txt` + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(url) + } + + if (isLoading) { + return ( +
+ +
+ ) + } + + if (!client || !server || !container) { + return ( +
+

Container not found

+

+ The container you're looking for doesn't exist or has been removed. +

+ + + Back to server + +
+ ) + } + + const isRunning = container.state.Running + const name = container.name || container.id.slice(0, 12) + const stateStatus = container.state.Status + + const statusColor = isRunning + ? 'bg-green-100 text-green-800' + : stateStatus === 'exited' + ? 'bg-red-100 text-red-800' + : 'bg-yellow-100 text-yellow-800' + + return ( +
+ {/* Header */} +
+
+ + + +
+
+ +

{name}

+ + {stateStatus} + +
+

+ {server.nickname || server.netcupServerId} • {container.image} +

+
+
+ + {/* Actions */} +
+ {isRunning ? ( + + ) : ( + + )} + + +
+
+ + {/* Container Info */} +
+

Container Information

+
+
+
+ + Container ID +
+ {container.id.slice(0, 12)} +
+
+
+ + Created +
+ {formatDate(container.created)} +
+ {container.stats && ( + <> +
+
+ + CPU Usage +
+ {container.stats.cpuPercent.toFixed(2)}% +
+
+
+ + Memory +
+ + {formatBytes(container.stats.memoryUsage)} / {formatBytes(container.stats.memoryLimit)} + +
+ + )} +
+ + {/* Ports */} + {container.networkSettings?.ports && Object.keys(container.networkSettings.ports).length > 0 && ( +
+
+ + Ports +
+
+ {Object.entries(container.networkSettings.ports).map(([containerPort, hostBindings]) => ( + + {containerPort} + {hostBindings && hostBindings.length > 0 && ` -> ${hostBindings[0].HostPort}`} + + ))} +
+
+ )} +
+ + {/* Logs */} +
+
+

Logs

+
+ {/* Tail selector */} +
+ Lines: + +
+ + {/* Auto-scroll toggle */} + + + {/* Refresh button */} + + + {/* Download button */} + +
+
+ + {/* Log output */} +
+ {logsLoading && !logsData ? ( +
+ + Loading logs... +
+ ) : logsData?.logs ? ( +
+              {logsData.logs}
+            
+ ) : ( +
+ No logs available +
+ )} +
+
+
+ + {/* Warning for stopped containers */} + {!isRunning && ( +
+
+ +
+

Container is not running

+

+ This container is currently stopped. Start it to see live logs and resource usage. +

+
+
+
+ )} +
+ ) +} diff --git a/src/app/admin/enterprise-clients/[id]/servers/[serverId]/danger/page.tsx b/src/app/admin/enterprise-clients/[id]/servers/[serverId]/danger/page.tsx new file mode 100644 index 0000000..8531e52 --- /dev/null +++ b/src/app/admin/enterprise-clients/[id]/servers/[serverId]/danger/page.tsx @@ -0,0 +1,251 @@ +'use client' + +import { useState } from 'react' +import { useParams } from 'next/navigation' +import Link from 'next/link' +import { + ArrowLeft, + AlertTriangle, + Trash2, + RotateCcw, + Server, + Loader2, + ShieldAlert +} from 'lucide-react' +import { + useEnterpriseClient, + useClientServer +} from '@/hooks/use-enterprise-clients' +import { useNetcupImageFlavours, type ImageFlavour } from '@/hooks/use-netcup' +import { SecurityVerificationDialog } from '@/components/admin/security-verification-dialog' + +type VerificationAction = 'WIPE' | 'REINSTALL' + +export default function DangerZonePage() { + const params = useParams() + const clientId = params.id as string + const serverId = params.serverId as string + + const [verificationOpen, setVerificationOpen] = useState(false) + const [selectedAction, setSelectedAction] = useState('WIPE') + const [selectedImageId, setSelectedImageId] = useState('') + + const { data: client, isLoading: clientLoading } = useEnterpriseClient(clientId) + const { data: server, isLoading: serverLoading } = useClientServer(clientId, serverId) + + // Get image flavours using the Netcup server ID + const { data: flavoursData, isLoading: flavoursLoading } = useNetcupImageFlavours( + server?.netcupServerId || '' + ) + + const isLoading = clientLoading || serverLoading + + if (isLoading) { + return ( +
+ +
+ ) + } + + if (!client || !server) { + return ( +
+

Server not found

+

+ The server you're looking for doesn't exist or has been removed. +

+ + + Back to client + +
+ ) + } + + const handleWipeClick = () => { + setSelectedAction('WIPE') + setVerificationOpen(true) + } + + const handleReinstallClick = () => { + if (!selectedImageId) { + return + } + setSelectedAction('REINSTALL') + setVerificationOpen(true) + } + + const handleSuccess = () => { + // Redirect back to server detail page after successful action + // The server will be in a different state (wiping/reinstalling) + setVerificationOpen(false) + } + + const flavours: ImageFlavour[] = flavoursData?.flavours || [] + + return ( +
+ {/* Header */} +
+ + + +
+
+ +

Danger Zone

+
+

+ + {server.nickname || server.netcupServerId} • {client.name} +

+
+
+ + {/* Warning Banner */} +
+
+ +
+

Warning: Destructive Actions

+

+ The actions on this page will result in permanent data loss. + A verification code will be sent to the client's email address + ({client.contactEmail}) before any action is executed. +

+
+
+
+ + {/* Wipe Server */} +
+
+
+
+ +
+
+

Wipe Server

+

+ Completely erase all data on this server. The server will be wiped + and returned to a clean state. All files, configurations, and + containers will be permanently deleted. +

+
    +
  • + + All data will be permanently lost +
  • +
  • + + Cannot be undone +
  • +
  • + + Server will be offline during wipe +
  • +
+
+
+ +
+
+ + {/* Reinstall Server */} +
+
+
+
+ +
+
+

Reinstall Operating System

+

+ Reinstall the operating system from scratch. Select an image below + and the server will be reinstalled with a fresh OS installation. +

+
    +
  • + + All existing data will be lost +
  • +
  • + + Server will be offline during reinstall +
  • +
  • + + New root password will be generated +
  • +
+
+
+ + {/* Image Selection */} +
+ + {flavoursLoading ? ( +
+ + Loading available images... +
+ ) : flavours.length === 0 ? ( +

No images available for this server.

+ ) : ( + + )} +
+ +
+ +
+
+
+ + {/* Security Verification Dialog */} + +
+ ) +} diff --git a/src/app/admin/enterprise-clients/[id]/servers/[serverId]/page.tsx b/src/app/admin/enterprise-clients/[id]/servers/[serverId]/page.tsx new file mode 100644 index 0000000..72157db --- /dev/null +++ b/src/app/admin/enterprise-clients/[id]/servers/[serverId]/page.tsx @@ -0,0 +1,349 @@ +'use client' + +import { useState, useCallback } from 'react' +import { useParams, useRouter } from 'next/navigation' +import Link from 'next/link' +import { + ArrowLeft, + Server, + Power, + RefreshCw, + AlertTriangle, + Cpu, + HardDrive, + Network, + MemoryStick, + Activity, + Loader2, + Box, + Unlink, + Settings +} from 'lucide-react' +import { + useEnterpriseClient, + useClientServer, + useServerStatsHistory, + useCollectServerStats, + useServerAction, + useRemoveServerFromClient +} from '@/hooks/use-enterprise-clients' +import { + RangeSelector, + StatsCard, + CpuUsageChart, + MemoryUsageChart, + DiskIOChart, + NetworkChart +} from '@/components/admin/enterprise-stats-charts' +import { LiveStatsPanel } from '@/components/admin/live-stats-panel' +import { EnterpriseContainerList } from '@/components/admin/enterprise-container-list' +import type { StatsRange } from '@/lib/api/admin' +import type { StatsDataPoint } from '@/lib/services/stats-collection-service' + +export default function ServerDetailPage() { + const params = useParams() + const router = useRouter() + const clientId = params.id as string + const serverId = params.serverId as string + + const [range, setRange] = useState('24h') + const [actionLoading, setActionLoading] = useState(null) + + const { data: client, isLoading: clientLoading } = useEnterpriseClient(clientId) + const { data: server, isLoading: serverLoading, refetch: refetchServer } = useClientServer(clientId, serverId) + const { data: statsData, isLoading: statsLoading } = useServerStatsHistory(clientId, serverId, range) + const collectStats = useCollectServerStats() + const serverAction = useServerAction() + const removeServer = useRemoveServerFromClient() + + // Stable callback for refreshing stats (used by LiveStatsPanel) + const handleRefreshStats = useCallback(() => { + collectStats.mutate({ clientId, serverId }) + }, [collectStats, clientId, serverId]) + + const isLoading = clientLoading || serverLoading + + if (isLoading) { + return ( +
+ +
+ ) + } + + if (!client || !server) { + return ( +
+

Server not found

+

+ The server you're looking for doesn't exist or has been removed. +

+ + + Back to client + +
+ ) + } + + // Convert API response to StatsDataPoint[] format for charts + const history: StatsDataPoint[] = statsData?.history?.map(h => ({ + ...h, + timestamp: new Date(h.timestamp) + })) || [] + + const latest = statsData?.latest + + const handlePowerAction = async (command: 'ON' | 'OFF' | 'POWERCYCLE' | 'RESET') => { + setActionLoading(command) + try { + await serverAction.mutateAsync({ + clientId, + serverId, + action: { action: 'power', command } + }) + // Refresh server data after action + setTimeout(() => refetchServer(), 2000) + } catch (error) { + console.error('Power action failed:', error) + } finally { + setActionLoading(null) + } + } + + const handleUnlinkServer = async () => { + if (!confirm(`Are you sure you want to unlink "${server?.nickname || server?.netcupServerId}" from this client?\n\nThis will remove the server from this enterprise client. The server itself will not be affected.`)) { + return + } + + try { + await removeServer.mutateAsync({ clientId, serverId }) + router.push(`/admin/enterprise-clients/${clientId}`) + } catch (error) { + console.error('Failed to unlink server:', error) + } + } + + const statusColor = server.netcupStatus === 'RUNNING' || server.netcupStatus === 'running' + ? 'bg-green-100 text-green-800' + : server.netcupStatus === 'SHUTOFF' || server.netcupStatus === 'stopped' + ? 'bg-red-100 text-red-800' + : 'bg-yellow-100 text-yellow-800' + + return ( +
+ {/* Header */} +
+
+ + + +
+
+ +

+ {server.nickname || server.netcupServerId} +

+ + {server.netcupStatus} + +
+

+ {server.purpose && `${server.purpose} • `} + {client.name} • Netcup ID: {server.netcupServerId} +

+
+
+ + {/* Power Controls */} +
+ + + + + + +
+
+ + {/* Server Info */} + {server.netcupIps && server.netcupIps.length > 0 && ( +
+
+
+ IP Address:{' '} + + {server.netcupIps[0]} + +
+ {server.netcupHostname && ( +
+ Hostname:{' '} + {server.netcupHostname} +
+ )} +
+
+ )} + + {/* Live Stats Panel with Auto-Refresh */} + + + {/* Historical Charts */} +
+
+

Historical Metrics

+ +
+ + {statsLoading ? ( +
+ +
+ ) : ( +
+ {/* CPU Chart */} +
+
+ +

CPU Usage

+
+ +
+ + {/* Memory Chart */} +
+
+ +

Memory Usage

+
+ +
+ + {/* Disk I/O Chart */} +
+
+ +

Disk I/O

+
+ +
+ + {/* Network Chart */} +
+
+ +

Network Traffic

+
+ +
+
+ )} +
+ + {/* Quick Stats Cards */} +
+ } + /> + } + /> + } + /> + } + /> +
+ + {/* Containers */} +
+
+ +

Containers

+
+ +
+
+ ) +} diff --git a/src/app/admin/enterprise-clients/[id]/servers/[serverId]/settings/page.tsx b/src/app/admin/enterprise-clients/[id]/servers/[serverId]/settings/page.tsx new file mode 100644 index 0000000..3d7beae --- /dev/null +++ b/src/app/admin/enterprise-clients/[id]/servers/[serverId]/settings/page.tsx @@ -0,0 +1,406 @@ +'use client' + +import { useState, useEffect } from 'react' +import { useParams } from 'next/navigation' +import Link from 'next/link' +import { + ArrowLeft, + Settings, + Server, + Loader2, + Save, + Eye, + EyeOff, + ExternalLink, + Plug, + CheckCircle, + XCircle +} from 'lucide-react' +import { + useEnterpriseClient, + useClientServer, + useUpdateClientServer, + useTestPortainerConnection +} from '@/hooks/use-enterprise-clients' + +export default function ServerSettingsPage() { + const params = useParams() + const clientId = params.id as string + const serverId = params.serverId as string + + const [showPassword, setShowPassword] = useState(false) + const [formData, setFormData] = useState({ + nickname: '', + purpose: '', + portainerUrl: '', + portainerUsername: '', + portainerPassword: '' + }) + const [hasChanges, setHasChanges] = useState(false) + const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null) + + const { data: client, isLoading: clientLoading } = useEnterpriseClient(clientId) + const { data: server, isLoading: serverLoading } = useClientServer(clientId, serverId) + const updateServer = useUpdateClientServer() + const testConnection = useTestPortainerConnection() + + const isLoading = clientLoading || serverLoading + + // Initialize form data when server data loads + useEffect(() => { + if (server) { + setFormData({ + nickname: server.nickname || '', + purpose: server.purpose || '', + portainerUrl: server.portainerUrl || '', + portainerUsername: server.portainerUsername || '', + portainerPassword: '' // Don't load password, let user enter new one + }) + setHasChanges(false) + setTestResult(null) + } + }, [server]) + + const handleInputChange = (field: string, value: string) => { + setFormData(prev => ({ ...prev, [field]: value })) + setHasChanges(true) + // Clear test result when credentials change + if (field.startsWith('portainer')) { + setTestResult(null) + } + } + + const handleTestConnection = async () => { + // Need URL, username, and password to test + const password = formData.portainerPassword || (server?.portainerUsername ? '__EXISTING__' : '') + + if (!formData.portainerUrl || !formData.portainerUsername) { + setTestResult({ + success: false, + message: 'Please enter Portainer URL and username' + }) + return + } + + if (!formData.portainerPassword && !server?.portainerUsername) { + setTestResult({ + success: false, + message: 'Please enter a password' + }) + return + } + + try { + // If we have a new password, use it; otherwise the API will use the saved one + // But we need to send the password for testing, so require it if no saved password + if (!formData.portainerPassword) { + setTestResult({ + success: false, + message: 'Please enter the password to test the connection' + }) + return + } + + const result = await testConnection.mutateAsync({ + clientId, + serverId, + credentials: { + portainerUrl: formData.portainerUrl, + portainerUsername: formData.portainerUsername, + portainerPassword: formData.portainerPassword + } + }) + setTestResult(result) + } catch (error) { + setTestResult({ + success: false, + message: error instanceof Error ? error.message : 'Connection test failed' + }) + } + } + + const handleSave = async () => { + try { + // Only send password if it was changed + const payload: Record = { + nickname: formData.nickname || null, + purpose: formData.purpose || null, + portainerUrl: formData.portainerUrl || null, + portainerUsername: formData.portainerUsername || null, + } + + if (formData.portainerPassword) { + payload.portainerPassword = formData.portainerPassword + } + + await updateServer.mutateAsync({ + clientId, + serverId, + data: payload + }) + + setHasChanges(false) + // Clear password field after save + setFormData(prev => ({ ...prev, portainerPassword: '' })) + } catch (error) { + console.error('Failed to save settings:', error) + } + } + + if (isLoading) { + return ( +
+ +
+ ) + } + + if (!client || !server) { + return ( +
+

Server not found

+

+ The server you're looking for doesn't exist or has been removed. +

+ + + Back to client + +
+ ) + } + + const canTestConnection = formData.portainerUrl && formData.portainerUsername && formData.portainerPassword + + return ( +
+ {/* Header */} +
+
+ + + +
+
+ +

Server Settings

+
+

+ + {server.nickname || server.netcupServerId} • {client.name} +

+
+
+ + +
+ + {/* General Settings */} +
+

General Settings

+ +
+
+ + handleInputChange('nickname', e.target.value)} + placeholder="e.g., Production Server" + className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:ring-blue-500 focus:border-blue-500" + /> +

+ A friendly name to identify this server +

+
+ +
+ + handleInputChange('purpose', e.target.value)} + placeholder="e.g., Web hosting, Database, Staging" + className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:ring-blue-500 focus:border-blue-500" + /> +

+ What this server is used for +

+
+
+
+ + {/* Portainer Settings */} +
+
+
+

Portainer Configuration

+

+ Connect to Portainer for container management +

+
+ {server.portainerUrl && ( + + + Open Portainer + + )} +
+ +
+
+ + handleInputChange('portainerUrl', e.target.value)} + placeholder="https://portainer.example.com:9443" + className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:ring-blue-500 focus:border-blue-500" + /> +

+ The URL to your Portainer instance (e.g., https://IP:9443) +

+
+ +
+
+ + handleInputChange('portainerUsername', e.target.value)} + placeholder="admin" + className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:ring-blue-500 focus:border-blue-500" + /> +
+ +
+ +
+ handleInputChange('portainerPassword', e.target.value)} + placeholder={server.portainerUsername ? '••••••••' : 'Enter password'} + className="w-full border border-gray-300 rounded-md px-3 py-2 pr-10 text-sm focus:ring-blue-500 focus:border-blue-500" + /> + +
+ {server.portainerUsername && ( +

+ Leave blank to keep existing password +

+ )} +
+
+ + {/* Test Connection Button */} +
+ + + {/* Test Result */} + {testResult && ( +
+ {testResult.success ? ( + + ) : ( + + )} + {testResult.message} +
+ )} +
+
+ + {/* Connection Status */} +
+
+
+ + {server.portainerUrl + ? 'Portainer configured' + : 'Portainer not configured'} + +
+
+
+ + {/* Server Info (Read-only) */} +
+

Server Information

+ +
+
+ Netcup Server ID: +

{server.netcupServerId}

+
+
+ IP Address: +

{server.netcupIps?.[0] || 'N/A'}

+
+
+ Hostname: +

{server.netcupHostname || 'N/A'}

+
+
+ Status: +

{server.netcupStatus || 'Unknown'}

+
+
+
+
+ ) +} diff --git a/src/app/admin/enterprise-clients/page.tsx b/src/app/admin/enterprise-clients/page.tsx new file mode 100644 index 0000000..8982a27 --- /dev/null +++ b/src/app/admin/enterprise-clients/page.tsx @@ -0,0 +1,532 @@ +'use client' + +import { useState } from 'react' +import Link from 'next/link' +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog' +import { Label } from '@/components/ui/label' +import { useEnterpriseClients, useCreateEnterpriseClient, useAllClientsErrorSummary } from '@/hooks/use-enterprise-clients' +import { + Search, + Plus, + Building2, + Server, + AlertTriangle, + Mail, + Phone, + ExternalLink, + Loader2, + AlertCircle, + RefreshCw, + Users, + Activity, + CheckCircle2, + Zap, +} from 'lucide-react' +import type { ClientErrorSummary } from '@/lib/api/admin' +import type { EnterpriseClientWithDetails } from '@/types/api' +import { EnterpriseErrorSummaryWidget } from '@/components/admin/enterprise-error-summary-widget' + +// Stats card component +function StatsCard({ + title, + value, + icon: Icon, + iconBg, + iconColor, + subtitle +}: { + title: string + value: number | string + icon: typeof Users + iconBg: string + iconColor: string + subtitle?: string +}) { + return ( + + +
+
+

{title}

+

{value}

+ {subtitle && ( +

{subtitle}

+ )} +
+
+ +
+
+
+
+ ) +} + +// Client card component +function ClientCard({ + client, + errorSummary, +}: { + client: EnterpriseClientWithDetails + errorSummary?: ClientErrorSummary +}) { + const activeServers = client.servers?.filter(s => s.isActive).length || 0 + const errorCount = client.statsOverview?.unacknowledgedErrors || 0 + const hasCrashes = (errorSummary?.crashes24h ?? 0) > 0 + const hasCritical = (errorSummary?.criticalErrors24h ?? 0) > 0 + const hasIssues = hasCrashes || hasCritical + + return ( + + + +
+
+
+ +
+
+
+

+ {client.name} +

+ {/* Pulsing indicator for critical issues */} + {hasIssues && ( + + + + + )} +
+ {client.companyName && ( +

{client.companyName}

+ )} +
+ + {client.contactEmail} +
+
+
+
+
+ {!client.isActive && ( + + Inactive + + )} + +
+ {/* Crash and Critical Error Badges */} + {hasIssues && ( +
+ {hasCrashes && ( + + + {errorSummary?.crashes24h} crashed + + )} + {hasCritical && ( + + + {errorSummary?.criticalErrors24h} critical + + )} +
+ )} +
+
+ +
+
+
+ + Servers +
+

+ {activeServers} + {client._count?.servers !== activeServers && ( + + /{client._count?.servers} + + )} +

+
+
+
+ + CPU Avg +
+

+ {client.statsOverview?.avgCpuPercent != null + ? `${client.statsOverview.avgCpuPercent}%` + : '-'} +

+
+
+
+ + Errors +
+

0 ? 'text-red-600' : ''}`}> + {errorCount} +

+
+
+
+
+ + ) +} + +// Add client dialog +function AddClientDialog({ + open, + onOpenChange +}: { + open: boolean + onOpenChange: (open: boolean) => void +}) { + const [formData, setFormData] = useState({ + name: '', + companyName: '', + contactEmail: '', + contactPhone: '', + notes: '' + }) + + const createClient = useCreateEnterpriseClient() + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + try { + await createClient.mutateAsync({ + name: formData.name, + companyName: formData.companyName || undefined, + contactEmail: formData.contactEmail, + contactPhone: formData.contactPhone || undefined, + notes: formData.notes || undefined + }) + setFormData({ name: '', companyName: '', contactEmail: '', contactPhone: '', notes: '' }) + onOpenChange(false) + } catch { + // Error handled by mutation + } + } + + return ( + + + + Add Enterprise Client + + Add a new enterprise client to manage their infrastructure. + + +
+
+
+ + setFormData({ ...formData, name: e.target.value })} + placeholder="Acme Corp" + required + /> +
+
+ + setFormData({ ...formData, companyName: e.target.value })} + placeholder="Acme Corporation Ltd." + /> +
+
+ + setFormData({ ...formData, contactEmail: e.target.value })} + placeholder="admin@acme.com" + required + /> +
+
+ + setFormData({ ...formData, contactPhone: e.target.value })} + placeholder="+1 555-1234" + /> +
+
+ + setFormData({ ...formData, notes: e.target.value })} + placeholder="Additional notes..." + /> +
+
+ + + + +
+
+
+ ) +} + +// Empty state +function EmptyState({ onAddClient }: { onAddClient: () => void }) { + return ( +
+
+
+
+ +
+
+

No enterprise clients yet

+

+ Get started by adding your first enterprise client to manage their infrastructure. +

+ +
+ ) +} + +export default function EnterpriseClientsPage() { + const [search, setSearch] = useState('') + const [showAddDialog, setShowAddDialog] = useState(false) + + const { data: clients, isLoading, isError, error, refetch, isFetching } = useEnterpriseClients() + const { data: errorSummary } = useAllClientsErrorSummary() + + // Create a map of client error summaries for quick lookup + const errorSummaryMap = new Map( + errorSummary?.clients.map((c) => [c.clientId, c]) ?? [] + ) + + // Filter clients by search + const filteredClients = clients?.filter((client) => { + if (!search) return true + const searchLower = search.toLowerCase() + return ( + client.name.toLowerCase().includes(searchLower) || + client.companyName?.toLowerCase().includes(searchLower) || + client.contactEmail.toLowerCase().includes(searchLower) + ) + }) || [] + + // Calculate stats + const stats = { + total: clients?.length || 0, + active: clients?.filter(c => c.isActive).length || 0, + totalServers: clients?.reduce((acc, c) => acc + (c._count?.servers || 0), 0) || 0, + totalErrors: clients?.reduce((acc, c) => acc + (c.statsOverview?.unacknowledgedErrors || 0), 0) || 0 + } + + // Loading state + if (isLoading) { + return ( +
+
+
+
+ +
+
+

Loading enterprise clients...

+
+ ) + } + + // Error state + if (isError) { + return ( +
+
+
+
+ +
+
+
+

Failed to load enterprise clients

+

+ {error instanceof Error ? error.message : 'An error occurred'} +

+
+ +
+ ) + } + + return ( +
+ {/* Hero Header */} +
+
+
+ +
+
+
+ +
+
+

Enterprise Clients

+

+ Manage enterprise infrastructure and server monitoring +

+
+
+
+ + +
+
+
+ + {/* Stats cards and Error Summary Widget */} +
+ {/* Stats cards - spans 2 columns on large screens */} +
+ + + + 0 ? "bg-red-100 dark:bg-red-900/30" : "bg-slate-100 dark:bg-slate-900/30"} + iconColor={stats.totalErrors > 0 ? "text-red-600 dark:text-red-400" : "text-slate-600 dark:text-slate-400"} + subtitle="Unacknowledged" + /> +
+ + {/* System Health Widget */} +
+ +
+
+ + {/* Client list */} + + +
+
+ All Clients + + {filteredClients.length} client{filteredClients.length !== 1 ? 's' : ''} found + +
+
+ + setSearch(e.target.value)} + className="pl-10 w-full sm:w-72 bg-background" + /> +
+
+
+ + {filteredClients.length === 0 ? ( + search ? ( +
+

No clients match your search.

+
+ ) : ( + setShowAddDialog(true)} /> + ) + ) : ( +
+ {filteredClients.map((client) => ( + + ))} +
+ )} +
+
+ + {/* Add Client Dialog */} + +
+ ) +} diff --git a/src/app/admin/orders/[id]/containers/[containerId]/page.tsx b/src/app/admin/orders/[id]/containers/[containerId]/page.tsx new file mode 100644 index 0000000..43a0e3c --- /dev/null +++ b/src/app/admin/orders/[id]/containers/[containerId]/page.tsx @@ -0,0 +1,772 @@ +'use client' + +import { useState, useEffect, useRef } from 'react' +import { useParams, useRouter } from 'next/navigation' +import Link from 'next/link' +import { + RadialBarChart, + RadialBar, + AreaChart, + Area, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts' +import { + ArrowLeft, + Play, + Square, + RefreshCw, + Trash2, + Cpu, + MemoryStick, + Network, + Clock, + Container, + Loader2, + AlertCircle, + Terminal, + Info, + Activity, + Gauge, + ArrowDownToLine, + ArrowUpFromLine, + Server, + Globe, + Key, + Layers, + RotateCcw, + Copy, + Check, +} from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { + useContainerDetails, + useSingleContainerStats, + useContainerLogs, + useContainerAction, + useRemoveContainer, + type ContainerStats, +} from '@/hooks/use-portainer' + +const MAX_HISTORY_POINTS = 60 + +interface StatsHistory { + timestamp: number + cpu: number + memory: number + memoryPercent: number + networkRx: number + networkTx: number +} + +function formatBytes(bytes: number): string { + if (bytes === 0) return '0 B' + const k = 1024 + const sizes = ['B', 'KB', 'MB', 'GB', 'TB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}` +} + +function formatDate(dateStr: string | number): string { + const date = typeof dateStr === 'number' ? new Date(dateStr * 1000) : new Date(dateStr) + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }) +} + +// Gauge Chart Component using Recharts +function GaugeChart({ + value, + color = '#3b82f6', + bgColor = '#e2e8f0', + size = 100, +}: { + value: number + color?: string + bgColor?: string + size?: number +}) { + const data = [ + { name: 'value', value: Math.min(value, 100), fill: color }, + { name: 'background', value: 100 - Math.min(value, 100), fill: bgColor }, + ] + + return ( +
+ + + + + +
+ ) +} + +// Sparkline Area Chart Component using Recharts +function SparklineChart({ + data, + color = '#3b82f6', + gradientId, +}: { + data: number[] + color?: string + gradientId: string +}) { + if (data.length < 2) { + return ( +
+ Collecting data... +
+ ) + } + + // Convert number array to chart data format + const chartData = data.map((value, index) => ({ + index, + value, + })) + + return ( + + + + + + + + + + + + ) +} + +// Status indicator dot +function StatusDot({ status }: { status: string }) { + const colors: Record = { + running: 'bg-emerald-500', + exited: 'bg-red-500', + paused: 'bg-amber-500', + restarting: 'bg-blue-500', + created: 'bg-slate-400', + } + const pulseColors: Record = { + running: 'bg-emerald-400', + restarting: 'bg-blue-400', + } + const shouldPulse = status === 'running' || status === 'restarting' + + return ( + + {shouldPulse && ( + + )} + + + ) +} + +// Copy button component +function CopyButton({ text }: { text: string }) { + const [copied, setCopied] = useState(false) + + const handleCopy = async () => { + await navigator.clipboard.writeText(text) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + + return ( + + ) +} + +export default function ContainerDetailPage() { + const params = useParams() + const router = useRouter() + const orderId = params.id as string + const containerId = params.containerId as string + + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) + const [actionInProgress, setActionInProgress] = useState(false) + const [activeTab, setActiveTab] = useState<'overview' | 'logs' | 'env'>('overview') + const [logTail, setLogTail] = useState(500) + const [statsHistory, setStatsHistory] = useState([]) + const lastStatsRef = useRef(null) + + const { data: container, isLoading, error } = useContainerDetails(orderId, containerId) + const { data: stats } = useSingleContainerStats(orderId, containerId, container?.state === 'running') + const { data: logs, refetch: refetchLogs } = useContainerLogs(orderId, containerId, logTail) + const containerAction = useContainerAction() + const removeContainer = useRemoveContainer() + + // Track stats history + useEffect(() => { + if (stats && stats !== lastStatsRef.current) { + lastStatsRef.current = stats + setStatsHistory(prev => { + const newHistory = [...prev, { + timestamp: Date.now(), + cpu: stats.cpuPercent, + memory: stats.memoryUsage, + memoryPercent: stats.memoryPercent, + networkRx: stats.networkRx, + networkTx: stats.networkTx, + }] + if (newHistory.length > MAX_HISTORY_POINTS) { + return newHistory.slice(-MAX_HISTORY_POINTS) + } + return newHistory + }) + } + }, [stats]) + + const handleAction = async (action: 'start' | 'stop' | 'restart') => { + setActionInProgress(true) + try { + await containerAction.mutateAsync({ orderId, containerId, action }) + } catch (err) { + console.error(`Failed to ${action} container:`, err) + } finally { + setActionInProgress(false) + } + } + + const handleDelete = async () => { + setActionInProgress(true) + try { + await removeContainer.mutateAsync({ orderId, containerId, force: true }) + router.push(`/admin/orders/${orderId}`) + } catch (err) { + console.error('Failed to remove container:', err) + } finally { + setActionInProgress(false) + setShowDeleteConfirm(false) + } + } + + if (isLoading) { + return ( +
+
+ +

Loading container...

+
+
+ ) + } + + if (error || !container) { + return ( +
+
+
+
+ +
+

Failed to load container

+

{error?.message || 'Container not found'}

+ +
+
+
+ ) + } + + const cpuHistory = statsHistory.map(s => s.cpu) + const memoryHistory = statsHistory.map(s => s.memoryPercent) + const isRunning = container.state === 'running' + + return ( +
+ {/* Header */} +
+
+
+ + + Back to Order + +
+
+
+
+ +
+
+
+

{container.name}

+
+ + {container.state} +
+
+

{container.shortId}

+
+
+
+ {actionInProgress ? ( +
+ + Processing... +
+ ) : ( + <> + {isRunning ? ( + + ) : ( + + )} + + + + )} +
+
+
+
+ +
+ {/* Stats Cards */} + {isRunning && stats && ( +
+ {/* CPU Card */} +
+
+
+
+ +
+
+

CPU Usage

+

+ {stats.cpuPercent < 0.1 && stats.cpuPercent > 0 + ? stats.cpuPercent.toFixed(2) + : stats.cpuPercent.toFixed(1)}% +

+
+
+
+
+
+ +
+
+ +
+
+
+ + {/* Memory Card */} +
+
+
+
+ +
+
+

Memory

+

{stats.memoryPercent.toFixed(1)}%

+
+
+
+
+
+ +
+

+ {formatBytes(stats.memoryUsage)} / {formatBytes(stats.memoryLimit)} +

+
+ +
+
+
+ + {/* Network RX Card */} +
+
+
+ +
+
+

Network In

+

{formatBytes(stats.networkRx)}

+
+
+
+
+
+ +
+
+ +
+
+
+
+ + {/* Network TX Card */} +
+
+
+ +
+
+

Network Out

+

{formatBytes(stats.networkTx)}

+
+
+
+
+
+ +
+
+ +
+
+
+
+
+ )} + + {/* Not Running State */} + {!isRunning && ( +
+
+
+ +
+
+

Container is not running

+

Start the container to view live resource metrics

+
+ +
+
+ )} + + {/* Tabs */} +
+
+ +
+ + {/* Overview Tab */} + {activeTab === 'overview' && ( +
+
+ {/* Container Info */} +
+
+

+ + Container Details +

+
+
+ Image +
+ {container.image} + +
+
+
+ Container ID +
+ {container.id.substring(0, 24)}... + +
+
+
+ Created + {formatDate(container.created)} +
+
+ Hostname + {container.config?.hostname || '-'} +
+
+ Working Dir + {container.config?.workingDir || '/'} +
+
+ Restart Policy + + {container.hostConfig?.restartPolicy?.Name || 'no'} + +
+
+
+
+ + {/* Networking */} +
+ {/* Ports */} +
+

+ + Port Mappings +

+ {container.ports && container.ports.length > 0 ? ( +
+ {container.ports.map((port, idx) => ( +
+
+ +
+
+ {port.public ? ( + <> + {port.public} + : + {port.private} + + ) : ( + {port.private} (not published) + )} + + {port.type} + +
+
+ ))} +
+ ) : ( +
+ No ports exposed +
+ )} +
+ + {/* Networks */} +
+

+ + Networks +

+ {container.networks && Object.keys(container.networks).length > 0 ? ( +
+ {Object.entries(container.networks).map(([name, network]) => ( +
+ {name} +
+ + {network.IPAddress || 'No IP assigned'} + + {network.IPAddress && } +
+
+ ))} +
+ ) : ( +
+ No networks attached +
+ )} +
+
+
+
+ )} + + {/* Logs Tab */} + {activeTab === 'logs' && ( +
+
+

+ + Container Logs +

+
+ + +
+
+
+
+
+
+                    {logs || 'No logs available'}
+                  
+
+
+
+
+ )} + + {/* Environment Tab */} + {activeTab === 'env' && ( +
+

+ + Environment Variables + + {container.config?.env?.length || 0} + +

+ {container.config?.env && container.config.env.length > 0 ? ( +
+
+ + + + + + + + + {container.config.env.map((env, idx) => { + const [key, ...valueParts] = env.split('=') + const value = valueParts.join('=') + const isSecret = key.toLowerCase().includes('password') || + key.toLowerCase().includes('secret') || + key.toLowerCase().includes('key') || + key.toLowerCase().includes('token') + return ( + + + + + ) + })} + +
KeyValue
+ {key} + +
+ + {isSecret ? '••••••••' : value || empty} + + {!isSecret && value && } +
+
+
+
+ ) : ( +
+ No environment variables configured +
+ )} +
+ )} +
+
+ + {/* Delete Confirmation Dialog */} + + + +
+ +
+ Remove Container + + Are you sure you want to remove {container.name}? This action cannot be undone. + +
+ + + + +
+
+
+ ) +} diff --git a/src/app/admin/orders/[id]/page.tsx b/src/app/admin/orders/[id]/page.tsx index 2c0730f..e6f7abe 100644 --- a/src/app/admin/orders/[id]/page.tsx +++ b/src/app/admin/orders/[id]/page.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState, useMemo, useEffect, useCallback } from 'react' +import { useState, useMemo, useEffect, useCallback, useRef } from 'react' import Link from 'next/link' import { useParams, useRouter } from 'next/navigation' import { @@ -13,16 +13,29 @@ import { import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' -import { useOrder, useUpdateOrder, useTriggerProvisioning } from '@/hooks/use-orders' +import { useQueryClient } from '@tanstack/react-query' +import { useOrder, useUpdateOrder, useTriggerProvisioning, useDeleteOrder } from '@/hooks/use-orders' import { useProvisioningLogs, StreamedLog } from '@/hooks/use-provisioning-logs' -import { OrderStatus, SubscriptionTier, LogLevel } from '@/types/api' +import { portainerKeys } from '@/hooks/use-portainer' +import { useDnsVerification, useTriggerDnsVerification, useSkipDnsVerification } from '@/hooks/use-dns' +import { useChangeAutomationMode } from '@/hooks/use-automation' +import { OrderStatus, SubscriptionTier, LogLevel, AutomationMode } from '@/types/api' +import { DnsVerificationPanel } from '@/components/admin/dns-verification-panel' +import { AutomationControls } from '@/components/admin/automation-controls' +import { ServerQuickActions } from '@/components/admin/server-quick-actions' +import { NetcupServerLink } from '@/components/admin/netcup-server-link' +import { ServerMetricsPanel } from '@/components/admin/server-metrics-panel' +import { PortainerCredentialsPanel } from '@/components/admin/portainer-credentials-panel' +import { ContainerList } from '@/components/admin/container-list' import { ArrowLeft, + ArrowDown, Globe, User, Server, Clock, CheckCircle, + XCircle, AlertCircle, Loader2, Eye, @@ -33,7 +46,14 @@ import { Zap, Wifi, WifiOff, + Trash2, } from 'lucide-react' +import { SliderConfirmDialog } from '@/components/ui/slider-confirm-dialog' +import { RefreshButton } from '@/components/ui/refresh-button' +import { ToolsEditor } from '@/components/admin/ToolsEditor' +import { OrderTimeline } from '@/components/admin/order-timeline' +import { ProvisioningConfigForm, ProvisioningConfig } from '@/components/admin/provisioning-config-form' +import { SSH_PORT_BEFORE_PROVISION, SSH_PORT_AFTER_PROVISION } from '@/lib/ssh/constants' // Status badge component function StatusBadge({ status }: { status: OrderStatus }) { @@ -58,14 +78,132 @@ function StatusBadge({ status }: { status: OrderStatus }) { ) } +// Server card component with SSH test +function ServerCard({ + orderId, + serverIp, + sshPort, + showTestButton, +}: { + orderId: string + serverIp: string | null + sshPort: number | null + showTestButton: boolean +}) { + const [testing, setTesting] = useState(false) + const [testResult, setTestResult] = useState<{ success: boolean; message: string; latency?: number } | null>(null) + + const handleTestSSH = async () => { + if (!serverIp) return + setTesting(true) + setTestResult(null) + try { + const response = await fetch(`/api/v1/admin/orders/${orderId}/test-ssh`, { + method: 'POST', + }) + const data = await response.json() + if (data.success) { + setTestResult({ + success: true, + message: `SSH connection successful!`, + latency: data.latency, + }) + } else { + setTestResult({ + success: false, + message: data.error || 'Connection failed', + }) + } + } catch (err) { + setTestResult({ + success: false, + message: err instanceof Error ? err.message : 'Connection test failed', + }) + } finally { + setTesting(false) + } + } + + return ( + + + + + Server + + + + {serverIp ? ( + <> +
+

{serverIp}

+

SSH Port: {sshPort || 22}

+
+ + {showTestButton && ( +
+ + + {testResult && ( +
+ {testResult.success ? ( + + ) : ( + + )} +
+

{testResult.message}

+ {testResult.latency && ( +

Latency: {testResult.latency}ms

+ )} +
+
+ )} +
+ )} + + ) : ( +

Not configured

+ )} +
+
+ ) +} + // Server credentials form component function ServerCredentialsForm({ + orderId, initialIp, initialPort, hasCredentials, onSubmit, isLoading, }: { + orderId: string initialIp?: string initialPort?: number hasCredentials: boolean @@ -74,7 +212,7 @@ function ServerCredentialsForm({ }) { const [ip, setIp] = useState(initialIp || '') const [password, setPassword] = useState('') - const [port, setPort] = useState(String(initialPort || 22)) + const [port, setPort] = useState(String(initialPort || SSH_PORT_BEFORE_PROVISION)) const [showPassword, setShowPassword] = useState(false) const [testing, setTesting] = useState(false) const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null) @@ -82,10 +220,36 @@ function ServerCredentialsForm({ const handleTestConnection = async () => { setTesting(true) setTestResult(null) - // TODO: Call API to test SSH connection - await new Promise(resolve => setTimeout(resolve, 2000)) - setTestResult({ success: true, message: 'Connection successful! SSH version: OpenSSH_8.4' }) - setTesting(false) + try { + const response = await fetch(`/api/v1/admin/orders/${orderId}/test-ssh`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + serverIp: ip, + password: password, + sshPort: parseInt(port), + }), + }) + const data = await response.json() + if (data.success) { + setTestResult({ + success: true, + message: `Connection successful! Latency: ${data.latency}ms`, + }) + } else { + setTestResult({ + success: false, + message: data.error || 'Connection failed', + }) + } + } catch (err) { + setTestResult({ + success: false, + message: err instanceof Error ? err.message : 'Connection test failed', + }) + } finally { + setTesting(false) + } } const handleSubmit = (e: React.FormEvent) => { @@ -125,11 +289,14 @@ function ServerCredentialsForm({ setPort(e.target.value)} required /> +

+ Default: {SSH_PORT_BEFORE_PROVISION} (changes to {SSH_PORT_AFTER_PROVISION} after provisioning) +

@@ -216,7 +383,7 @@ function ServerCredentialsForm({ } // Provisioning logs component -function ProvisioningLogs({ logs, isLive, isConnected, onReconnect }: { +function ProvisioningLogs({ logs, isLive, isConnected, isComplete, finalStatus, onReconnect }: { logs: Array<{ id: string timestamp: Date @@ -226,8 +393,15 @@ function ProvisioningLogs({ logs, isLive, isConnected, onReconnect }: { }> isLive: boolean isConnected?: boolean + isComplete?: boolean + finalStatus?: 'FULFILLED' | 'FAILED' | null onReconnect?: () => void }) { + const logContainerRef = useRef(null) + const [autoScroll, setAutoScroll] = useState(true) + const userScrolledRef = useRef(false) + const lastScrollTopRef = useRef(0) + const levelColors: Record = { INFO: 'text-blue-400', WARN: 'text-yellow-400', @@ -240,8 +414,40 @@ function ProvisioningLogs({ logs, isLive, isConnected, onReconnect }: { return d.toLocaleTimeString('en-US', { hour12: false }) } + // Handle scroll to detect if user manually scrolled up + const handleScroll = () => { + if (!logContainerRef.current) return + const { scrollTop, scrollHeight, clientHeight } = logContainerRef.current + const isAtBottom = scrollHeight - scrollTop - clientHeight < 100 + + // Detect if user scrolled UP manually (not programmatic scroll down) + if (scrollTop < lastScrollTopRef.current && !isAtBottom) { + userScrolledRef.current = true + setAutoScroll(false) + } else if (isAtBottom) { + // User scrolled back to bottom + userScrolledRef.current = false + setAutoScroll(true) + } + + lastScrollTopRef.current = scrollTop + } + + // Auto-scroll to bottom when new logs arrive + useEffect(() => { + if (autoScroll && logContainerRef.current) { + // Use requestAnimationFrame for smooth scrolling + requestAnimationFrame(() => { + if (logContainerRef.current) { + logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight + lastScrollTopRef.current = logContainerRef.current.scrollTop + } + }) + } + }, [logs.length, autoScroll]) + return ( - +
@@ -252,7 +458,28 @@ function ProvisioningLogs({ logs, isLive, isConnected, onReconnect }: { Real-time output from the provisioning process
- {isLive && ( + {/* Completion status */} + {isComplete && ( +
+ {finalStatus === 'FULFILLED' ? ( + <> + + Completed Successfully + + ) : ( + <> + + Provisioning Failed + + )} +
+ )} + {/* Live status */} + {isLive && !isComplete && ( <> {isConnected ? (
@@ -268,24 +495,49 @@ function ProvisioningLogs({ logs, isLive, isConnected, onReconnect }: { Disconnected {onReconnect && ( - + )}
)} )} - {!isLive && logs.length > 0 && ( + {!isLive && !isComplete && logs.length > 0 && ( {logs.length} log{logs.length !== 1 ? 's' : ''} )} + {/* Auto-scroll indicator */} + {!autoScroll && isLive && !isComplete && ( + + )}
-
+
{logs.length === 0 ? (

{isLive && isConnected ? 'Waiting for logs...' : 'No logs available yet.'} @@ -306,119 +558,10 @@ function ProvisioningLogs({ logs, isLive, isConnected, onReconnect }: { ) } -// Order timeline component -function OrderTimeline({ - status, - timestamps, -}: { - status: OrderStatus - timestamps: { - createdAt?: Date | null - serverReadyAt?: Date | null - provisioningStartedAt?: Date | null - completedAt?: Date | null - } -}) { - const stages = [ - { key: 'payment_confirmed', label: 'Payment Confirmed', status: 'PAYMENT_CONFIRMED' }, - { key: 'awaiting_server', label: 'Server Ordered', status: 'AWAITING_SERVER' }, - { key: 'server_ready', label: 'Server Ready', status: 'SERVER_READY' }, - { key: 'dns_ready', label: 'DNS Configured', status: 'DNS_READY' }, - { key: 'provisioning', label: 'Provisioning', status: 'PROVISIONING' }, - { key: 'fulfilled', label: 'Fulfilled', status: 'FULFILLED' }, - ] - - const statusOrder = stages.map((s) => s.status) - const currentIndex = statusOrder.indexOf(status) - - const formatDate = (date: Date | null | undefined) => { - if (!date) return null - return new Date(date).toLocaleString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric', - hour: 'numeric', - minute: '2-digit', - hour12: true, - }) - } - - const getTimestamp = (key: string): string | null => { - switch (key) { - case 'payment_confirmed': - return formatDate(timestamps.createdAt) - case 'server_ready': - return formatDate(timestamps.serverReadyAt) - case 'provisioning': - return formatDate(timestamps.provisioningStartedAt) - case 'fulfilled': - return formatDate(timestamps.completedAt) - default: - return null - } - } - - return ( - - - - - Order Timeline - - - -

- {stages.map((stage, index) => { - const isComplete = index < currentIndex || (index === currentIndex && status !== 'FAILED') - const isCurrent = index === currentIndex - const timestamp = getTimestamp(stage.key) - - return ( -
-
-
- {isComplete ? ( - - ) : ( - {index + 1} - )} -
- {index < stages.length - 1 && ( -
- )} -
-
-

- {stage.label} -

- {timestamp && ( -

{timestamp}

- )} -
-
- ) - })} -
- - - ) -} - export default function OrderDetailPage() { const params = useParams() const router = useRouter() + const queryClient = useQueryClient() const orderId = params.id as string // Fetch order data @@ -434,6 +577,16 @@ export default function OrderDetailPage() { // Mutations const updateOrder = useUpdateOrder() const triggerProvision = useTriggerProvisioning() + const changeAutomationMode = useChangeAutomationMode() + + // DNS hooks + const { data: dnsData } = useDnsVerification(orderId) + const triggerDnsVerification = useTriggerDnsVerification() + const skipDnsVerification = useSkipDnsVerification() + + // Delete hook + const deleteOrder = useDeleteOrder() + const [showDeleteDialog, setShowDeleteDialog] = useState(false) // Check if we should enable SSE streaming const isProvisioning = order?.status === OrderStatus.PROVISIONING @@ -454,7 +607,10 @@ export default function OrderDetailPage() { onComplete: useCallback((success: boolean) => { // Refetch order data when provisioning completes refetch() - }, [refetch]), + // Also invalidate Portainer credentials to pick up any synced credentials + queryClient.invalidateQueries({ queryKey: portainerKeys.credentials(orderId) }) + queryClient.invalidateQueries({ queryKey: portainerKeys.containers(orderId) }) + }, [refetch, queryClient, orderId]), }) // Computed values @@ -488,15 +644,7 @@ export default function OrderDetailPage() { ) }, [order?.provisioningLogs, streamedLogs]) - // Auto-scroll logs to bottom when new logs come in - useEffect(() => { - if (isProvisioning && allLogs.length > 0) { - const container = document.getElementById('log-container') - if (container) { - container.scrollTop = container.scrollHeight - } - } - }, [allLogs.length, isProvisioning]) + // Note: Auto-scroll is handled inside ProvisioningLogs component via logContainerRef const handleCredentialsSubmit = async (ip: string, password: string, port: number) => { await updateOrder.mutateAsync({ @@ -505,6 +653,18 @@ export default function OrderDetailPage() { serverIp: ip, serverPassword: password, sshPort: port, + status: OrderStatus.SERVER_READY, // Mark as ready after saving credentials + }, + }) + } + + const handleProvisioningConfigSave = async (config: ProvisioningConfig) => { + await updateOrder.mutateAsync({ + id: orderId, + data: { + customer: config.customer, + companyName: config.companyName, + licenseKey: config.licenseKey, }, }) } @@ -514,9 +674,28 @@ export default function OrderDetailPage() { await triggerProvision.mutateAsync(orderId) } catch (err) { console.error('Failed to trigger provisioning:', err) + const message = err instanceof Error ? err.message : 'Failed to start provisioning' + alert(message) } } + const handleAutomationModeChange = async (action: 'auto' | 'manual' | 'pause' | 'resume') => { + await changeAutomationMode.mutateAsync({ orderId, action }) + } + + const handleDnsVerify = async () => { + await triggerDnsVerification.mutateAsync(orderId) + } + + const handleDnsSkip = async () => { + await skipDnsVerification.mutateAsync(orderId) + } + + const handleDeleteOrder = async () => { + await deleteOrder.mutateAsync(orderId) + router.push('/admin/orders') + } + // Loading state if (isLoading) { return ( @@ -546,18 +725,23 @@ export default function OrderDetailPage() { Go Back - + refetch()} + isRefreshing={isFetching} + label="Retry" + />
) } - const showCredentialsForm = order.status === OrderStatus.AWAITING_SERVER || order.status === OrderStatus.SERVER_READY - const showProvisionButton = order.status === OrderStatus.DNS_READY || order.status === OrderStatus.FAILED + // Always show credentials form - allows entering/updating server details at any stage + const showCredentialsForm = order.status !== OrderStatus.FULFILLED && order.status !== OrderStatus.EMAIL_CONFIGURED + const showProvisionButton = order.status === OrderStatus.SERVER_READY || order.status === OrderStatus.DNS_READY || order.status === OrderStatus.FAILED + // Provisioning config is editable before provisioning starts + const isProvisioningConfigEditable = !([OrderStatus.PROVISIONING, OrderStatus.FULFILLED, OrderStatus.EMAIL_CONFIGURED] as OrderStatus[]).includes(order.status) const showLogs = order.status === OrderStatus.PROVISIONING || order.status === OrderStatus.FULFILLED || order.status === OrderStatus.EMAIL_CONFIGURED || @@ -580,15 +764,10 @@ export default function OrderDetailPage() {

Order #{orderId.slice(0, 8)}...

- + isRefreshing={isFetching} + /> {showProvisionButton && ( )} +
+ {/* Delete confirmation dialog */} + + + {/* Order Timeline - Horizontal progress bar at top */} +
+ +
+ {/* Failure reason banner */} {order.status === OrderStatus.FAILED && order.failureReason && (
@@ -637,6 +850,29 @@ export default function OrderDetailPage() {
)} + {/* Provisioning Logs - Prominent display at top when active */} + {showLogs && ( + + )} + + {/* Automation Controls */} + + {/* Order info cards */}
@@ -675,49 +911,36 @@ export default function OrderDetailPage() { - - - - - Server - - - - {order.serverIp ? ( - <> -

{order.serverIp}

-

SSH Port: {order.sshPort || 22}

- - ) : ( -

Not configured

- )} -
-
+
- {/* Tools list */} - - - Selected Tools - Tools to be deployed on this server - - -
- {order.tools.map((tool) => ( - - {tool} - - ))} -
-
-
+ {/* Tools list - editable before provisioning */} + { + await updateOrder.mutateAsync({ id: orderId, data: { tools } }) + }} + isEditable={!([OrderStatus.PROVISIONING, OrderStatus.FULFILLED, OrderStatus.EMAIL_CONFIGURED] as OrderStatus[]).includes(order.status)} + isSaving={updateOrder.isPending} + /> + + {/* Provisioning config form - customer/license info */} + {/* Server credentials form (show for AWAITING_SERVER or SERVER_READY status) */} {showCredentialsForm && ( )} - {/* Two column layout for timeline and logs */} -
- + {/* Netcup Server Linking & Quick Actions */} + {order.serverIp && ( + + +
+
+ +
+
+ Netcup Server + Link this order to a Netcup server for management +
+
+
+ + refetch()} + /> + {order.netcupServerId && ( + + )} + +
+ )} - {/* Show logs for provisioning/completed/failed status */} - {showLogs && ( - - )} -
+ {/* Server Metrics (show when Netcup server is linked) */} + {order.netcupServerId && ( + + )} + + {/* DNS Verification Panel (show when server is configured) */} + {order.serverIp && ( + + )} + + {/* Portainer & Container Management (show for provisioned/completed orders) */} + {(order.status === OrderStatus.FULFILLED || + order.status === OrderStatus.EMAIL_CONFIGURED || + order.status === OrderStatus.PROVISIONING) && ( +
+ + +
+ )} {/* Jobs history */} {order.jobs && order.jobs.length > 0 && ( diff --git a/src/app/admin/orders/page.tsx b/src/app/admin/orders/page.tsx index 13c2ddc..c982e0d 100644 --- a/src/app/admin/orders/page.tsx +++ b/src/app/admin/orders/page.tsx @@ -4,6 +4,7 @@ import { useState, useMemo } from 'react' import { useRouter } from 'next/navigation' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' +import { Badge } from '@/components/ui/badge' import { OrderKanban, OrderPipelineCompact, @@ -22,7 +23,11 @@ import { Plus, Loader2, AlertCircle, + Package, + X, + Sparkles, } from 'lucide-react' +import { exportOrdersToCsv } from '@/lib/csv-export' // View modes type ViewMode = 'kanban' | 'list' @@ -76,6 +81,27 @@ function mapApiOrderToCardOrder(apiOrder: { } } +// Tier display config +const tierConfig: Record = { + 'all': { label: 'All Tiers', color: '' }, + 'hub-dashboard': { label: 'Hub Dashboard', color: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' }, + 'control-panel': { label: 'Control Panel', color: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400' }, +} + +// Status display config +const statusConfig: Record = { + 'all': { label: 'All Statuses', color: '' }, + 'PAYMENT_CONFIRMED': { label: 'Payment Confirmed', color: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400' }, + 'AWAITING_SERVER': { label: 'Awaiting Server', color: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400' }, + 'SERVER_READY': { label: 'Server Ready', color: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' }, + 'DNS_PENDING': { label: 'DNS Pending', color: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400' }, + 'DNS_READY': { label: 'DNS Ready', color: 'bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-400' }, + 'PROVISIONING': { label: 'Provisioning', color: 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-400' }, + 'FULFILLED': { label: 'Fulfilled', color: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' }, + 'EMAIL_CONFIGURED': { label: 'Complete', color: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400' }, + 'FAILED': { label: 'Failed', color: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400' }, +} + export default function OrdersPage() { const router = useRouter() const [viewMode, setViewMode] = useState('kanban') @@ -107,6 +133,9 @@ export default function OrdersPage() { return data.orders.map(mapApiOrderToCardOrder) }, [data?.orders]) + // Check if any filters are active + const hasActiveFilters = filters.search || filters.tier !== 'all' || filters.status !== 'all' + // Handle order action const handleOrderAction = (order: OrderCardType, action: string) => { console.log(`Action "${action}" triggered for order:`, order) @@ -126,18 +155,46 @@ export default function OrdersPage() { // Handle export const handleExport = () => { - // TODO: Implement CSV export - console.log('Exporting orders...') - alert('Export functionality coming soon!') + if (!data?.orders || data.orders.length === 0) { + alert('No orders to export') + return + } + exportOrdersToCsv(data.orders) + } + + // Clear all filters + const clearFilters = () => { + setFilters({ search: '', tier: 'all', status: 'all' }) } // Loading state if (isLoading) { return ( -
-
- -

Loading orders...

+
+ {/* Hero header skeleton */} +
+
+
+
+
+
+
+
+ + {/* Loading content */} +
+
+
+
+
+ +
+
+
+

Loading orders...

+

Fetching your order pipeline

+
+
) @@ -146,19 +203,31 @@ export default function OrdersPage() { // Error state if (isError) { return ( -
-
- -
-

Failed to load orders

-

- {error instanceof Error ? error.message : 'An error occurred'} -

+
+ {/* Hero header */} +
+
+
+

Order Pipeline

+

Manage and track customer provisioning orders

+
+
+ + {/* Error content */} +
+
+
+ +
+

Failed to load orders

+

+ {error instanceof Error ? error.message : 'An unexpected error occurred while fetching orders.'} +

+
-
) @@ -166,133 +235,221 @@ export default function OrdersPage() { return (
- {/* Page header */} -
-
-

Order Pipeline

-

- Manage and track customer provisioning orders -

-
-
- - + {/* Hero Header */} +
+ {/* Background decoration */} +
+
+ +
+ {/* Title section */} +
+
+ +
+
+

Order Pipeline

+

Manage and track customer provisioning orders

+
+
+ + {/* Action buttons */} +
+ + +
{/* Toolbar */} -
- {/* Search and filters */} -
-
- - - setFilters((prev) => ({ ...prev, search: e.target.value })) - } - className="pl-9" - /> +
+
+ {/* Search and filters */} +
+ {/* Search input */} +
+ + + setFilters((prev) => ({ ...prev, search: e.target.value })) + } + className="pl-9 bg-background border-muted-foreground/20 focus:border-primary" + /> +
+ + {/* Filter dropdowns */} +
+ {/* Tier filter */} +
+ + +
+ + {/* Status filter */} +
+ + +
+
- {/* Tier filter */} - - - {/* Status filter */} - -
- - {/* View controls */} -
- - -
+ {/* View controls */} +
+ {/* Refresh button */} - + + {/* View toggle */} +
+ + +
{/* Active filters indicator */} - {(filters.search || filters.tier !== 'all' || filters.status !== 'all') && ( -
- - - Showing {orders.length} orders - {data?.pagination && ` of ${data.pagination.total}`} - + {hasActiveFilters && ( +
+
+ + + Showing {orders.length} orders + {data?.pagination && ( + of {data.pagination.total} + )} + +
+ + {/* Filter badges */} + {filters.search && ( + + Search: {filters.search} + + + )} + {filters.tier !== 'all' && ( + + {tierConfig[filters.tier].label} + + + )} + {filters.status !== 'all' && ( + + {statusConfig[filters.status].label} + + + )} + + {/* Clear all button */}
)} @@ -300,14 +457,33 @@ export default function OrdersPage() { {/* Empty state */} {orders.length === 0 && (
-
-

No orders found

- {(filters.search || filters.tier !== 'all' || filters.status !== 'all') && ( +
+
+ +
+

No orders found

+

+ {hasActiveFilters + ? "No orders match your current filters. Try adjusting your search criteria." + : "Get started by creating your first order." + } +

+ {hasActiveFilters ? ( + ) : ( + )}
diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 882652a..5861ef7 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -21,75 +21,142 @@ import { ArrowRight, Loader2, RefreshCw, + LayoutDashboard, + Activity, } from 'lucide-react' -// Stats card component +// Enhanced stats card with icon backgrounds and hover effects function StatsCard({ title, value, description, icon: Icon, isLoading, + iconBgColor = 'bg-blue-100 dark:bg-blue-900/30', + iconColor = 'text-blue-600 dark:text-blue-400', }: { title: string value: string | number description: string icon: React.ElementType isLoading?: boolean + iconBgColor?: string + iconColor?: string }) { return ( - + {title} - +
+ +
{isLoading ? ( -
+
) : ( -
{value}
+
{value}
)} -

{description}

+

{description}

) } -// Order status badge +// Enhanced order status badge with dot indicator function OrderStatusBadge({ status }: { status: string }) { - const statusStyles: Record = { - PAYMENT_CONFIRMED: 'bg-blue-100 text-blue-800', - AWAITING_SERVER: 'bg-yellow-100 text-yellow-800', - SERVER_READY: 'bg-purple-100 text-purple-800', - DNS_PENDING: 'bg-orange-100 text-orange-800', - DNS_READY: 'bg-cyan-100 text-cyan-800', - PROVISIONING: 'bg-indigo-100 text-indigo-800', - FULFILLED: 'bg-green-100 text-green-800', - EMAIL_CONFIGURED: 'bg-emerald-100 text-emerald-800', - FAILED: 'bg-red-100 text-red-800', + const statusConfig: Record = { + PAYMENT_CONFIRMED: { + label: 'Payment Confirmed', + bgColor: 'bg-blue-50 dark:bg-blue-950/30', + textColor: 'text-blue-700 dark:text-blue-400', + borderColor: 'border-blue-200 dark:border-blue-800', + dotColor: 'bg-blue-500' + }, + AWAITING_SERVER: { + label: 'Awaiting Server', + bgColor: 'bg-amber-50 dark:bg-amber-950/30', + textColor: 'text-amber-700 dark:text-amber-400', + borderColor: 'border-amber-200 dark:border-amber-800', + dotColor: 'bg-amber-500', + pulse: true + }, + SERVER_READY: { + label: 'Server Ready', + bgColor: 'bg-purple-50 dark:bg-purple-950/30', + textColor: 'text-purple-700 dark:text-purple-400', + borderColor: 'border-purple-200 dark:border-purple-800', + dotColor: 'bg-purple-500' + }, + DNS_PENDING: { + label: 'DNS Pending', + bgColor: 'bg-orange-50 dark:bg-orange-950/30', + textColor: 'text-orange-700 dark:text-orange-400', + borderColor: 'border-orange-200 dark:border-orange-800', + dotColor: 'bg-orange-500', + pulse: true + }, + DNS_READY: { + label: 'DNS Ready', + bgColor: 'bg-cyan-50 dark:bg-cyan-950/30', + textColor: 'text-cyan-700 dark:text-cyan-400', + borderColor: 'border-cyan-200 dark:border-cyan-800', + dotColor: 'bg-cyan-500' + }, + PROVISIONING: { + label: 'Provisioning', + bgColor: 'bg-indigo-50 dark:bg-indigo-950/30', + textColor: 'text-indigo-700 dark:text-indigo-400', + borderColor: 'border-indigo-200 dark:border-indigo-800', + dotColor: 'bg-indigo-500', + pulse: true + }, + FULFILLED: { + label: 'Fulfilled', + bgColor: 'bg-emerald-50 dark:bg-emerald-950/30', + textColor: 'text-emerald-700 dark:text-emerald-400', + borderColor: 'border-emerald-200 dark:border-emerald-800', + dotColor: 'bg-emerald-500' + }, + EMAIL_CONFIGURED: { + label: 'Complete', + bgColor: 'bg-emerald-50 dark:bg-emerald-950/30', + textColor: 'text-emerald-700 dark:text-emerald-400', + borderColor: 'border-emerald-200 dark:border-emerald-800', + dotColor: 'bg-emerald-500' + }, + FAILED: { + label: 'Failed', + bgColor: 'bg-red-50 dark:bg-red-950/30', + textColor: 'text-red-700 dark:text-red-400', + borderColor: 'border-red-200 dark:border-red-800', + dotColor: 'bg-red-500' + }, } - const statusLabels: Record = { - PAYMENT_CONFIRMED: 'Payment Confirmed', - AWAITING_SERVER: 'Awaiting Server', - SERVER_READY: 'Server Ready', - DNS_PENDING: 'DNS Pending', - DNS_READY: 'DNS Ready', - PROVISIONING: 'Provisioning', - FULFILLED: 'Fulfilled', - EMAIL_CONFIGURED: 'Complete', - FAILED: 'Failed', + const config = statusConfig[status] || { + label: status, + bgColor: 'bg-slate-50 dark:bg-slate-950/30', + textColor: 'text-slate-700 dark:text-slate-400', + borderColor: 'border-slate-200 dark:border-slate-800', + dotColor: 'bg-slate-500' } return ( - {statusLabels[status] || status} + + {config.label} ) } @@ -110,7 +177,7 @@ function formatTimeSince(date: Date | string): string { return then.toLocaleDateString() } -// Recent orders component +// Recent orders component with enhanced styling function RecentOrders({ orders, isLoading }: { orders: Array<{ id: string @@ -122,51 +189,60 @@ function RecentOrders({ orders, isLoading }: { isLoading: boolean }) { return ( - - -
- Recent Orders - Latest customer provisioning orders + + +
+
+ +
+
+ Recent Orders + Latest customer provisioning orders +
-
{isLoading ? ( -
+
{[1, 2, 3, 4].map((i) => ( -
+
-
+
-
+
))}
) : orders.length === 0 ? ( -
- No orders yet +
+ +

No orders yet

+

+ Orders will appear here when customers place them +

) : ( -
+
{orders.map((order) => (
-

{order.domain}

+

{order.domain}

{order.user.name || order.user.company || order.user.email}

- + {formatTimeSince(order.createdAt)} @@ -180,7 +256,7 @@ function RecentOrders({ orders, isLoading }: { ) } -// Pipeline overview component +// Enhanced pipeline overview component function PipelineOverview({ stats, isLoading }: { stats: { pending: number @@ -191,37 +267,90 @@ function PipelineOverview({ stats, isLoading }: { isLoading: boolean }) { const stages = [ - { name: 'Payment & Server', count: stats?.pending || 0, icon: Clock, color: 'text-yellow-500' }, - { name: 'Provisioning', count: stats?.inProgress || 0, icon: TrendingUp, color: 'text-indigo-500' }, - { name: 'Completed', count: stats?.completed || 0, icon: CheckCircle, color: 'text-green-500' }, - { name: 'Failed', count: stats?.failed || 0, icon: AlertCircle, color: 'text-red-500' }, + { + name: 'Payment & Server', + count: stats?.pending || 0, + icon: Clock, + iconBgColor: 'bg-amber-100 dark:bg-amber-900/30', + iconColor: 'text-amber-600 dark:text-amber-400', + barColor: 'bg-amber-500' + }, + { + name: 'Provisioning', + count: stats?.inProgress || 0, + icon: TrendingUp, + iconBgColor: 'bg-indigo-100 dark:bg-indigo-900/30', + iconColor: 'text-indigo-600 dark:text-indigo-400', + barColor: 'bg-indigo-500' + }, + { + name: 'Completed', + count: stats?.completed || 0, + icon: CheckCircle, + iconBgColor: 'bg-emerald-100 dark:bg-emerald-900/30', + iconColor: 'text-emerald-600 dark:text-emerald-400', + barColor: 'bg-emerald-500' + }, + { + name: 'Failed', + count: stats?.failed || 0, + icon: AlertCircle, + iconBgColor: 'bg-red-100 dark:bg-red-900/30', + iconColor: 'text-red-600 dark:text-red-400', + barColor: 'bg-red-500' + }, ] + const total = stages.reduce((acc, stage) => acc + stage.count, 0) + return ( - - - Order Pipeline - Orders by current stage + + +
+
+ +
+
+ Order Pipeline + Orders by current stage +
+
- {stages.map((stage) => ( -
-
- - {stage.name} + {stages.map((stage) => { + const percentage = total > 0 ? (stage.count / total) * 100 : 0 + return ( +
+
+
+
+ +
+ {stage.name} +
+ {isLoading ? ( +
+ ) : ( + {stage.count} + )} +
+ {!isLoading && total > 0 && ( +
+
+
+ )}
- {isLoading ? ( -
- ) : ( - {stage.count} - )} -
- ))} + ) + })}
-
+
- @@ -238,15 +367,17 @@ export default function AdminDashboard() { if (isError) { return (
-
- -
-

Failed to load dashboard

-

Could not fetch statistics

+
+
+
-
@@ -254,27 +385,39 @@ export default function AdminDashboard() { } return ( -
- {/* Page header */} -
-
-

Dashboard

-

- Overview of your LetsBe Hub platform -

+
+ {/* Hero Header Section */} +
+ {/* Background decorative elements */} +
+
+ +
+
+
+ +
+
+

Dashboard

+

+ Overview of your LetsBe Hub platform +

+
+
+
-
- {/* Stats grid */} + {/* Stats grid with enhanced cards */}
diff --git a/src/app/admin/profile/page.tsx b/src/app/admin/profile/page.tsx new file mode 100644 index 0000000..be7956d --- /dev/null +++ b/src/app/admin/profile/page.tsx @@ -0,0 +1,256 @@ +'use client' + +import { useState } from 'react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { Separator } from '@/components/ui/separator' +import { useProfile, useUpdateProfile } from '@/hooks/use-profile' +import { ProfilePhotoUpload } from '@/components/profile/profile-photo-upload' +import { PasswordChangeDialog } from '@/components/profile/password-change-dialog' +import { TwoFactorSettings } from '@/components/settings/two-factor-settings' +import { + User, + Mail, + Shield, + Key, + Loader2, + Save, + AlertCircle, + CheckCircle, + ShieldCheck, +} from 'lucide-react' + +export default function ProfilePage() { + const { data: profile, isLoading, error } = useProfile() + const updateProfileMutation = useUpdateProfile() + + const [name, setName] = useState('') + const [isNameDirty, setIsNameDirty] = useState(false) + const [showPasswordDialog, setShowPasswordDialog] = useState(false) + const [saveSuccess, setSaveSuccess] = useState(false) + const [saveError, setSaveError] = useState(null) + + // Initialize name when profile loads + if (profile && !isNameDirty && name !== (profile.name || '')) { + setName(profile.name || '') + } + + const handleNameChange = (value: string) => { + setName(value) + setIsNameDirty(true) + setSaveSuccess(false) + setSaveError(null) + } + + const handleSaveName = async () => { + setSaveError(null) + setSaveSuccess(false) + try { + await updateProfileMutation.mutateAsync({ name: name.trim() }) + setIsNameDirty(false) + setSaveSuccess(true) + setTimeout(() => setSaveSuccess(false), 3000) + } catch (err) { + if (err && typeof err === 'object' && 'data' in err) { + const apiError = err as { data?: { error?: string } } + setSaveError(apiError.data?.error || 'Failed to save profile') + } else { + setSaveError(err instanceof Error ? err.message : 'Failed to save profile') + } + } + } + + if (isLoading) { + return ( +
+ +
+ ) + } + + if (error || !profile) { + return ( +
+ +

Failed to load profile

+
+ ) + } + + return ( +
+ {/* Header */} +
+

My Profile

+

+ Manage your account settings and security preferences +

+
+ +
+ {/* Profile Information */} + + + + + Profile Information + + + Your basic profile details and photo + + + + {/* Photo Upload */} + + + + + {/* Name Field */} +
+ +
+ handleNameChange(e.target.value)} + disabled={updateProfileMutation.isPending} + /> + +
+ {saveSuccess && ( +

+ + Name saved successfully +

+ )} + {saveError && ( +

+ + {saveError} +

+ )} +
+ + + + {/* Email (Read-only) */} +
+ + +

+ Email cannot be changed +

+
+ + {/* Role (Read-only) */} +
+ +
+ + {profile.role} + +
+
+
+
+ + {/* Security */} + + + + + Security + + + Manage your password and two-factor authentication + + + + {/* Password */} +
+ +

+ Keep your account secure with a strong password +

+ +
+ + + + {/* Two-Factor Authentication */} +
+
+ + {profile.twoFactorEnabled ? ( + + Enabled + + ) : ( + + Disabled + + )} +
+

+ Add an extra layer of security to your account using an authenticator app +

+ +
+
+
+
+ + {/* Password Change Dialog */} + +
+ ) +} diff --git a/src/app/admin/servers/[id]/containers/[containerId]/page.tsx b/src/app/admin/servers/[id]/containers/[containerId]/page.tsx new file mode 100644 index 0000000..c37fc28 --- /dev/null +++ b/src/app/admin/servers/[id]/containers/[containerId]/page.tsx @@ -0,0 +1,772 @@ +'use client' + +import { useState, useEffect, useRef } from 'react' +import { useParams, useRouter } from 'next/navigation' +import Link from 'next/link' +import { + RadialBarChart, + RadialBar, + AreaChart, + Area, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts' +import { + ArrowLeft, + Play, + Square, + RefreshCw, + Trash2, + Cpu, + MemoryStick, + Network, + Clock, + Container, + Loader2, + AlertCircle, + Terminal, + Info, + Activity, + Gauge, + ArrowDownToLine, + ArrowUpFromLine, + Server, + Globe, + Key, + Layers, + RotateCcw, + Copy, + Check, +} from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { + useContainerDetails, + useSingleContainerStats, + useContainerLogs, + useContainerAction, + useRemoveContainer, + type ContainerStats, +} from '@/hooks/use-portainer' + +const MAX_HISTORY_POINTS = 60 + +interface StatsHistory { + timestamp: number + cpu: number + memory: number + memoryPercent: number + networkRx: number + networkTx: number +} + +function formatBytes(bytes: number): string { + if (bytes === 0) return '0 B' + const k = 1024 + const sizes = ['B', 'KB', 'MB', 'GB', 'TB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}` +} + +function formatDate(dateStr: string | number): string { + const date = typeof dateStr === 'number' ? new Date(dateStr * 1000) : new Date(dateStr) + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }) +} + +// Gauge Chart Component using Recharts +function GaugeChart({ + value, + color = '#3b82f6', + bgColor = '#e2e8f0', + size = 100, +}: { + value: number + color?: string + bgColor?: string + size?: number +}) { + const data = [ + { name: 'value', value: Math.min(value, 100), fill: color }, + { name: 'background', value: 100 - Math.min(value, 100), fill: bgColor }, + ] + + return ( +
+ + + + + +
+ ) +} + +// Sparkline Area Chart Component using Recharts +function SparklineChart({ + data, + color = '#3b82f6', + gradientId, +}: { + data: number[] + color?: string + gradientId: string +}) { + if (data.length < 2) { + return ( +
+ Collecting data... +
+ ) + } + + // Convert number array to chart data format + const chartData = data.map((value, index) => ({ + index, + value, + })) + + return ( + + + + + + + + + + + + ) +} + +// Status indicator dot +function StatusDot({ status }: { status: string }) { + const colors: Record = { + running: 'bg-emerald-500', + exited: 'bg-red-500', + paused: 'bg-amber-500', + restarting: 'bg-blue-500', + created: 'bg-slate-400', + } + const pulseColors: Record = { + running: 'bg-emerald-400', + restarting: 'bg-blue-400', + } + const shouldPulse = status === 'running' || status === 'restarting' + + return ( + + {shouldPulse && ( + + )} + + + ) +} + +// Copy button component +function CopyButton({ text }: { text: string }) { + const [copied, setCopied] = useState(false) + + const handleCopy = async () => { + await navigator.clipboard.writeText(text) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + + return ( + + ) +} + +export default function ContainerDetailPage() { + const params = useParams() + const router = useRouter() + const serverId = params.id as string + const containerId = params.containerId as string + + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) + const [actionInProgress, setActionInProgress] = useState(false) + const [activeTab, setActiveTab] = useState<'overview' | 'logs' | 'env'>('overview') + const [logTail, setLogTail] = useState(500) + const [statsHistory, setStatsHistory] = useState([]) + const lastStatsRef = useRef(null) + + const { data: container, isLoading, error } = useContainerDetails(serverId, containerId) + const { data: stats } = useSingleContainerStats(serverId, containerId, container?.state === 'running') + const { data: logs, refetch: refetchLogs } = useContainerLogs(serverId, containerId, logTail) + const containerAction = useContainerAction() + const removeContainer = useRemoveContainer() + + // Track stats history + useEffect(() => { + if (stats && stats !== lastStatsRef.current) { + lastStatsRef.current = stats + setStatsHistory(prev => { + const newHistory = [...prev, { + timestamp: Date.now(), + cpu: stats.cpuPercent, + memory: stats.memoryUsage, + memoryPercent: stats.memoryPercent, + networkRx: stats.networkRx, + networkTx: stats.networkTx, + }] + if (newHistory.length > MAX_HISTORY_POINTS) { + return newHistory.slice(-MAX_HISTORY_POINTS) + } + return newHistory + }) + } + }, [stats]) + + const handleAction = async (action: 'start' | 'stop' | 'restart') => { + setActionInProgress(true) + try { + await containerAction.mutateAsync({ orderId: serverId, containerId, action }) + } catch (err) { + console.error(`Failed to ${action} container:`, err) + } finally { + setActionInProgress(false) + } + } + + const handleDelete = async () => { + setActionInProgress(true) + try { + await removeContainer.mutateAsync({ orderId: serverId, containerId, force: true }) + router.push(`/admin/servers/${serverId}`) + } catch (err) { + console.error('Failed to remove container:', err) + } finally { + setActionInProgress(false) + setShowDeleteConfirm(false) + } + } + + if (isLoading) { + return ( +
+
+ +

Loading container...

+
+
+ ) + } + + if (error || !container) { + return ( +
+
+
+
+ +
+

Failed to load container

+

{error?.message || 'Container not found'}

+ +
+
+
+ ) + } + + const cpuHistory = statsHistory.map(s => s.cpu) + const memoryHistory = statsHistory.map(s => s.memoryPercent) + const isRunning = container.state === 'running' + + return ( +
+ {/* Header */} +
+
+
+ + + Back to Server + +
+
+
+
+ +
+
+
+

{container.name}

+
+ + {container.state} +
+
+

{container.shortId}

+
+
+
+ {actionInProgress ? ( +
+ + Processing... +
+ ) : ( + <> + {isRunning ? ( + + ) : ( + + )} + + + + )} +
+
+
+
+ +
+ {/* Stats Cards */} + {isRunning && stats && ( +
+ {/* CPU Card */} +
+
+
+
+ +
+
+

CPU Usage

+

+ {stats.cpuPercent < 0.1 && stats.cpuPercent > 0 + ? stats.cpuPercent.toFixed(2) + : stats.cpuPercent.toFixed(1)}% +

+
+
+
+
+
+ +
+
+ +
+
+
+ + {/* Memory Card */} +
+
+
+
+ +
+
+

Memory

+

{stats.memoryPercent.toFixed(1)}%

+
+
+
+
+
+ +
+

+ {formatBytes(stats.memoryUsage)} / {formatBytes(stats.memoryLimit)} +

+
+ +
+
+
+ + {/* Network RX Card */} +
+
+
+ +
+
+

Network In

+

{formatBytes(stats.networkRx)}

+
+
+
+
+
+ +
+
+ +
+
+
+
+ + {/* Network TX Card */} +
+
+
+ +
+
+

Network Out

+

{formatBytes(stats.networkTx)}

+
+
+
+
+
+ +
+
+ +
+
+
+
+
+ )} + + {/* Not Running State */} + {!isRunning && ( +
+
+
+ +
+
+

Container is not running

+

Start the container to view live resource metrics

+
+ +
+
+ )} + + {/* Tabs */} +
+
+ +
+ + {/* Overview Tab */} + {activeTab === 'overview' && ( +
+
+ {/* Container Info */} +
+
+

+ + Container Details +

+
+
+ Image +
+ {container.image} + +
+
+
+ Container ID +
+ {container.id.substring(0, 24)}... + +
+
+
+ Created + {formatDate(container.created)} +
+
+ Hostname + {container.config?.hostname || '-'} +
+
+ Working Dir + {container.config?.workingDir || '/'} +
+
+ Restart Policy + + {container.hostConfig?.restartPolicy?.Name || 'no'} + +
+
+
+
+ + {/* Networking */} +
+ {/* Ports */} +
+

+ + Port Mappings +

+ {container.ports && container.ports.length > 0 ? ( +
+ {container.ports.map((port, idx) => ( +
+
+ +
+
+ {port.public ? ( + <> + {port.public} + : + {port.private} + + ) : ( + {port.private} (not published) + )} + + {port.type} + +
+
+ ))} +
+ ) : ( +
+ No ports exposed +
+ )} +
+ + {/* Networks */} +
+

+ + Networks +

+ {container.networks && Object.keys(container.networks).length > 0 ? ( +
+ {Object.entries(container.networks).map(([name, network]) => ( +
+ {name} +
+ + {network.IPAddress || 'No IP assigned'} + + {network.IPAddress && } +
+
+ ))} +
+ ) : ( +
+ No networks attached +
+ )} +
+
+
+
+ )} + + {/* Logs Tab */} + {activeTab === 'logs' && ( +
+
+

+ + Container Logs +

+
+ + +
+
+
+
+
+
+                    {logs || 'No logs available'}
+                  
+
+
+
+
+ )} + + {/* Environment Tab */} + {activeTab === 'env' && ( +
+

+ + Environment Variables + + {container.config?.env?.length || 0} + +

+ {container.config?.env && container.config.env.length > 0 ? ( +
+
+ + + + + + + + + {container.config.env.map((env, idx) => { + const [key, ...valueParts] = env.split('=') + const value = valueParts.join('=') + const isSecret = key.toLowerCase().includes('password') || + key.toLowerCase().includes('secret') || + key.toLowerCase().includes('key') || + key.toLowerCase().includes('token') + return ( + + + + + ) + })} + +
KeyValue
+ {key} + +
+ + {isSecret ? '••••••••' : value || empty} + + {!isSecret && value && } +
+
+
+
+ ) : ( +
+ No environment variables configured +
+ )} +
+ )} +
+
+ + {/* Delete Confirmation Dialog */} + + + +
+ +
+ Remove Container + + Are you sure you want to remove {container.name}? This action cannot be undone. + +
+ + + + +
+
+
+ ) +} diff --git a/src/app/admin/servers/[id]/page.tsx b/src/app/admin/servers/[id]/page.tsx new file mode 100644 index 0000000..2e4a681 --- /dev/null +++ b/src/app/admin/servers/[id]/page.tsx @@ -0,0 +1,370 @@ +'use client' + +import { useMemo } from 'react' +import Link from 'next/link' +import { useParams, useRouter } from 'next/navigation' +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { useOrder } from '@/hooks/use-orders' +import { PortainerCredentialsPanel } from '@/components/admin/portainer-credentials-panel' +import { ContainerList } from '@/components/admin/container-list' +import { ServerQuickActions } from '@/components/admin/server-quick-actions' +import { NetcupServerLink } from '@/components/admin/netcup-server-link' +import { OrderStatus, SubscriptionTier } from '@/types/api' +import { + ArrowLeft, + Server, + Globe, + User, + Calendar, + ExternalLink, + Loader2, + AlertCircle, + RefreshCw, + FileText, + Package, + CheckCircle, + XCircle, + Clock, +} from 'lucide-react' + +// Status badge component +function StatusBadge({ status }: { status: string }) { + const statusConfig: Record = { + online: { + label: 'Online', + className: 'bg-emerald-100 text-emerald-800 border-emerald-200', + icon: CheckCircle, + }, + provisioning: { + label: 'Provisioning', + className: 'bg-blue-100 text-blue-800 border-blue-200', + icon: Clock, + }, + offline: { + label: 'Offline', + className: 'bg-red-100 text-red-800 border-red-200', + icon: XCircle, + }, + pending: { + label: 'Pending', + className: 'bg-amber-100 text-amber-800 border-amber-200', + icon: Clock, + }, + } + + const config = statusConfig[status] || statusConfig.pending + const Icon = config.icon + + return ( + + + {config.label} + + ) +} + +// Derive server status from order status +function getServerStatus(orderStatus: OrderStatus): string { + switch (orderStatus) { + case OrderStatus.FULFILLED: + case OrderStatus.EMAIL_CONFIGURED: + return 'online' + case OrderStatus.PROVISIONING: + return 'provisioning' + case OrderStatus.FAILED: + return 'offline' + default: + return 'pending' + } +} + +// Tool chip component +function ToolChip({ tool }: { tool: string }) { + const getToolColor = (toolName: string) => { + const name = toolName.toLowerCase() + if (name.includes('nextcloud')) return 'bg-blue-100 text-blue-700 border-blue-200' + if (name.includes('keycloak')) return 'bg-purple-100 text-purple-700 border-purple-200' + if (name.includes('minio')) return 'bg-rose-100 text-rose-700 border-rose-200' + if (name.includes('poste')) return 'bg-emerald-100 text-emerald-700 border-emerald-200' + if (name.includes('portainer')) return 'bg-cyan-100 text-cyan-700 border-cyan-200' + return 'bg-slate-100 text-slate-700 border-slate-200' + } + + return ( + + {tool} + + ) +} + +export default function ServerDetailPage() { + const params = useParams() + const router = useRouter() + const serverId = params.id as string + + // Server data comes from order (servers are orders with serverIp) + const { + data: order, + isLoading, + isError, + error, + refetch, + isFetching, + } = useOrder(serverId) + + const tierLabel = useMemo(() => { + if (!order) return '' + return order.tier === SubscriptionTier.HUB_DASHBOARD ? 'Hub Dashboard' : 'Control Panel' + }, [order?.tier]) + + const serverStatus = order ? getServerStatus(order.status) : 'pending' + + // Loading state + if (isLoading) { + return ( +
+
+ +

Loading server details...

+
+
+ ) + } + + // Error state + if (isError || !order) { + return ( +
+
+ +
+

Failed to load server

+

+ {error instanceof Error ? error.message : 'Server not found'} +

+
+
+ + +
+
+
+ ) + } + + // Only show for servers (orders with serverIp and provisioned status) + const provisionedStatuses: OrderStatus[] = [ + OrderStatus.PROVISIONING, + OrderStatus.FULFILLED, + OrderStatus.EMAIL_CONFIGURED, + OrderStatus.FAILED, + ] + const isServer = order.serverIp && provisionedStatuses.includes(order.status) + + if (!isServer) { + return ( +
+
+ +
+

Not a provisioned server

+

+ This order has not been provisioned yet. View the order details to continue setup. +

+
+ + + +
+
+ ) + } + + return ( +
+ {/* Header */} +
+ + + +
+
+

{order.domain}

+ +
+

{order.serverIp}

+
+
+ + + + + {order.portainerUrl && ( + + )} + {order.dashboardUrl && ( + + )} +
+
+ + {/* Server info cards */} +
+ + + + + Server Info + + + +
+ IP Address + {order.serverIp} +
+
+ SSH Port + {order.sshPort || 22} +
+
+ Created + {new Date(order.createdAt).toLocaleDateString()} +
+
+
+ + + + + + Customer + + + +

{order.user.name || order.user.company || 'N/A'}

+

{order.user.email}

+ {order.user.company && order.user.name && ( +

{order.user.company}

+ )} + + + +
+
+ + + + + + Domain & Tier + + + +
+

{order.domain}

+ + {tierLabel} + +
+
+
+
+ + {/* Tools */} + + + + + Installed Tools + + + {order.tools.length} tools deployed on this server + + + +
+ {order.tools.map((tool) => ( + + ))} +
+
+
+ + {/* Netcup Server Linking & Quick Actions */} + + +
+
+ +
+
+ Netcup Server + Link to Netcup server for power management and rescue mode +
+
+
+ + refetch()} + /> + {order.netcupServerId && ( + + )} + +
+ + {/* Portainer Credentials Panel */} + + + {/* Container List */} + +
+ ) +} diff --git a/src/app/admin/servers/netcup/[id]/page.tsx b/src/app/admin/servers/netcup/[id]/page.tsx new file mode 100644 index 0000000..c5a2acf --- /dev/null +++ b/src/app/admin/servers/netcup/[id]/page.tsx @@ -0,0 +1,1245 @@ +'use client' + +import { useState, useEffect } from 'react' +import { useParams, useRouter } from 'next/navigation' +import Link from 'next/link' +import { Card } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Badge } from '@/components/ui/badge' +import { + Loader2, + Server, + Power, + PowerOff, + RefreshCw, + CheckCircle, + Wrench, + HardDrive, + Cpu, + MemoryStick, + Activity, + Camera, + Trash2, + RotateCcw, + Plus, + Network, + Download, + Upload, + ArrowLeft, + Copy, + Check, + Globe, + Clock, + Pencil, +} from 'lucide-react' +import { SliderConfirmDialog } from '@/components/ui/slider-confirm-dialog' +import { EditHostnameDialog } from '@/components/admin/edit-hostname-dialog' +import { EditNicknameDialog } from '@/components/admin/edit-nickname-dialog' +import { ReinstallDialog } from '@/components/admin/reinstall-dialog' +import { + useNetcupServer, + useNetcupPowerAction, + useNetcupRescue, + useFetchLiveStatus, + useServerMetrics, + useServerSnapshots, + useCreateSnapshot, + useDeleteSnapshot, + useRevertSnapshot, + usePortainerStatus, + useTaskStatus, + isTransitionalState, + isServerReinstalling, + PowerAction, + NetcupServer, +} from '@/hooks/use-netcup' + +const stateConfig: Record< + string, + { label: string; bgColor: string; textColor: string; borderColor: string; dotColor: string; icon: typeof Power; animate?: boolean } +> = { + ON: { label: 'Online', bgColor: 'bg-emerald-50', textColor: 'text-emerald-700', borderColor: 'border-emerald-200', dotColor: 'bg-emerald-500', icon: CheckCircle }, + OFF: { label: 'Offline', bgColor: 'bg-slate-50', textColor: 'text-slate-600', borderColor: 'border-slate-200', dotColor: 'bg-slate-400', icon: PowerOff }, + POWERCYCLE: { label: 'Restarting', bgColor: 'bg-amber-50', textColor: 'text-amber-700', borderColor: 'border-amber-200', dotColor: 'bg-amber-500', icon: RefreshCw, animate: true }, + RESET: { label: 'Hard Resetting', bgColor: 'bg-orange-50', textColor: 'text-orange-700', borderColor: 'border-orange-200', dotColor: 'bg-orange-500', icon: RefreshCw, animate: true }, + POWEROFF: { label: 'Shutting Down', bgColor: 'bg-orange-50', textColor: 'text-orange-700', borderColor: 'border-orange-200', dotColor: 'bg-orange-500', icon: PowerOff, animate: true }, + POWERON: { label: 'Powering On', bgColor: 'bg-blue-50', textColor: 'text-blue-700', borderColor: 'border-blue-200', dotColor: 'bg-blue-500', icon: Power, animate: true }, + REINSTALLING: { label: 'Reinstalling OS', bgColor: 'bg-purple-50', textColor: 'text-purple-700', borderColor: 'border-purple-200', dotColor: 'bg-purple-500', icon: HardDrive, animate: true }, + UNKNOWN: { label: 'Unknown', bgColor: 'bg-slate-50', textColor: 'text-slate-500', borderColor: 'border-slate-200', dotColor: 'bg-slate-300', icon: Server }, +} + +function formatBytes(bytes: number): string { + if (bytes === 0) return '0 B' + if (!Number.isFinite(bytes) || bytes < 0) return '0 B' + const k = 1024 + const sizes = ['B', 'KB', 'MB', 'GB', 'TB'] + const i = Math.max(0, Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1)) + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i] +} + +function formatBytesPerSec(bps: number): string { + return formatBytes(bps) + '/s' +} + +function CopyButton({ text }: { text: string }) { + const [copied, setCopied] = useState(false) + + const handleCopy = async () => { + await navigator.clipboard.writeText(text) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + + return ( + + ) +} + +function MetricsBar({ value, max = 100, label, color = 'bg-blue-500', gradient }: { + value: number + max?: number + label: string + color?: string + gradient?: string +}) { + const percentage = Math.min((value / max) * 100, 100) + const barColor = gradient || color + + // Determine color based on value thresholds + const getThresholdColor = () => { + if (percentage > 90) return 'bg-gradient-to-r from-red-500 to-red-600' + if (percentage > 75) return 'bg-gradient-to-r from-amber-500 to-orange-500' + return barColor + } + + return ( +
+
+ {label} + {value.toFixed(1)}% +
+
+
+
+
+ ) +} + +function MetricsSection({ serverId }: { serverId: string }) { + const [hours, setHours] = useState(24) + const { data: metrics, isLoading, isFetching, error, refetch } = useServerMetrics(serverId, hours) + + // Only show full loading on initial load (no data yet) + const isInitialLoading = isLoading && !metrics + + if (error && !metrics) { + return ( +
+

Failed to load metrics

+ +
+ ) + } + + if (isInitialLoading) { + return ( +
+ {/* Period selector skeleton */} +
+ {[1, 6, 24, 168, 720].map((h) => ( + + ))} +
+ {/* Skeleton cards */} +
+ {[ + { icon: Cpu, label: 'CPU Usage', color: 'text-blue-500' }, + { icon: HardDrive, label: 'Disk I/O', color: 'text-emerald-500' }, + { icon: Network, label: 'Network', color: 'text-violet-500' }, + ].map(({ icon: Icon, label, color }) => ( +
+
+
+ +
+ {label} +
+
+ +
+
+ ))} +
+
+ ) + } + + if (!metrics) { + return ( +
+ +

No metrics data available

+

+ Metrics will appear once the server is running +

+
+ ) + } + + return ( +
+ {/* Period selector - pill style toggle */} +
+
+ {[1, 6, 24, 168, 720].map((h) => ( + + ))} +
+ +
+ + {/* Metrics grid */} +
+ {/* CPU metrics */} +
+
+
+
+ +
+
+

CPU Usage

+

Average load

+
+
+ + {metrics.cpu.average}% + +
+ +
+ Peak: {metrics.cpu.max}% + {metrics.cpu.dataPoints.length} samples +
+
+ + {/* Disk I/O metrics */} + {metrics.disk && (metrics.disk.readBps.length > 0 || metrics.disk.writeBps.length > 0) && ( +
+
+
+ +
+
+

Disk I/O

+

Read / Write

+
+
+
+
+
+ + Read +
+

+ {metrics.disk.readBps.length > 0 + ? formatBytesPerSec(metrics.disk.readBps[metrics.disk.readBps.length - 1]?.value || 0) + : 'N/A'} +

+

+ {metrics.disk.readIops.length > 0 + ? `${metrics.disk.readIops[metrics.disk.readIops.length - 1]?.value.toFixed(0) || 0} IOPS` + : '-'} +

+
+
+
+ + Write +
+

+ {metrics.disk.writeBps.length > 0 + ? formatBytesPerSec(metrics.disk.writeBps[metrics.disk.writeBps.length - 1]?.value || 0) + : 'N/A'} +

+

+ {metrics.disk.writeIops.length > 0 + ? `${metrics.disk.writeIops[metrics.disk.writeIops.length - 1]?.value.toFixed(0) || 0} IOPS` + : '-'} +

+
+
+
+ )} + + {/* Network metrics */} + {metrics.network && (metrics.network.rxBps.length > 0 || metrics.network.txBps.length > 0) && ( +
+
+
+ +
+
+

Network

+

RX / TX

+
+
+
+
+
+ + Receive +
+

+ {metrics.network.rxBps.length > 0 + ? formatBytesPerSec(metrics.network.rxBps[metrics.network.rxBps.length - 1]?.value || 0) + : 'N/A'} +

+
+
+
+ + Transmit +
+

+ {metrics.network.txBps.length > 0 + ? formatBytesPerSec(metrics.network.txBps[metrics.network.txBps.length - 1]?.value || 0) + : 'N/A'} +

+
+
+
+ )} +
+
+ ) +} + +interface SnapshotConfirmState { + type: 'create' | 'delete' | 'revert' | null + snapshotName: string +} + +function SnapshotsSection({ serverId }: { serverId: string }) { + const [newSnapshotName, setNewSnapshotName] = useState('') + const [confirmDialog, setConfirmDialog] = useState({ + type: null, + snapshotName: '', + }) + + const { data: snapshotsData, isLoading, error, refetch } = useServerSnapshots(serverId) + const createMutation = useCreateSnapshot() + const deleteMutation = useDeleteSnapshot() + const revertMutation = useRevertSnapshot() + + const isPending = createMutation.isPending || deleteMutation.isPending || revertMutation.isPending + + const handleConfirm = async () => { + if (!confirmDialog.type) return + + try { + switch (confirmDialog.type) { + case 'create': + await createMutation.mutateAsync({ + serverId, + name: newSnapshotName || undefined, + }) + setNewSnapshotName('') + break + case 'delete': + await deleteMutation.mutateAsync({ serverId, name: confirmDialog.snapshotName }) + break + case 'revert': + await revertMutation.mutateAsync({ serverId, name: confirmDialog.snapshotName }) + break + } + setConfirmDialog({ type: null, snapshotName: '' }) + } catch (error) { + console.error('Snapshot action failed:', error) + } + } + + const getDialogConfig = () => { + switch (confirmDialog.type) { + case 'create': + return { + title: 'Create Snapshot', + description: `Create a new snapshot${newSnapshotName ? ` named "${newSnapshotName}"` : ''}. This will capture the current state of your server for backup and recovery purposes.`, + confirmText: 'Create Snapshot', + variant: 'default' as const, + } + case 'delete': + return { + title: 'Delete Snapshot', + description: `Permanently delete the snapshot "${confirmDialog.snapshotName}". This action cannot be undone and you will lose this recovery point.`, + confirmText: 'Delete', + variant: 'destructive' as const, + } + case 'revert': + return { + title: 'Revert to Snapshot', + description: `Revert the server to snapshot "${confirmDialog.snapshotName}". All changes made after this snapshot was created will be lost. The server may need to restart.`, + confirmText: 'Revert', + variant: 'destructive' as const, + } + default: + return { title: '', description: '', confirmText: '', variant: 'default' as const } + } + } + + const dialogConfig = getDialogConfig() + + if (isLoading) { + return ( +
+ +

Loading snapshots...

+
+ ) + } + + if (error) { + return ( +
+

Failed to load snapshots

+ +
+ ) + } + + const snapshots = snapshotsData?.snapshots || [] + + return ( + <> + { + if (!open) setConfirmDialog({ type: null, snapshotName: '' }) + }} + title={dialogConfig.title} + description={dialogConfig.description} + confirmText={dialogConfig.confirmText} + variant={dialogConfig.variant} + onConfirm={handleConfirm} + isLoading={isPending} + /> + +
+ {/* Create snapshot - elevated card */} +
+
+
+

Create New Snapshot

+

+ Create a point-in-time backup of your server state +

+ setNewSnapshotName(e.target.value)} + className="max-w-sm bg-background" + /> +
+
+ +
+
+
+ + {/* Snapshots list */} +
+
+

+ {snapshots.length} {snapshots.length === 1 ? 'Snapshot' : 'Snapshots'} +

+ +
+ + {snapshots.length === 0 ? ( +
+ +

No snapshots created yet

+

+ Create your first snapshot to backup your server state +

+
+ ) : ( +
+ {snapshots.map((snapshot, index) => ( +
+
+
+ +
+
+

{snapshot.name}

+
+ + + {new Date(snapshot.createdAt).toLocaleString()} + + {snapshot.size && ( + + + {formatBytes(snapshot.size)} + + )} +
+
+
+ +
+ + +
+
+ ))} +
+ )} +
+
+ + ) +} + +interface ConfirmDialogState { + action: PowerAction | 'rescue' | null + title: string + description: string + confirmText: string + variant: 'destructive' | 'warning' | 'default' +} + +function PowerControlsSection({ server }: { server: NetcupServer }) { + const [confirmDialog, setConfirmDialog] = useState({ + action: null, + title: '', + description: '', + confirmText: '', + variant: 'warning', + }) + const powerMutation = useNetcupPowerAction() + const rescueMutation = useNetcupRescue() + + const isPending = powerMutation.isPending || rescueMutation.isPending + const isOn = server.state === 'ON' + + const openConfirmDialog = (action: PowerAction | 'rescue') => { + const configs: Record> = { + ON: { + title: 'Power On Server', + description: 'The server will be powered on. This may take a few moments.', + confirmText: 'Power On', + variant: 'default', + }, + POWERCYCLE: { + title: 'Restart Server', + description: 'The server will be power cycled (turned off and back on). All running processes will be terminated.', + confirmText: 'Restart', + variant: 'warning', + }, + POWEROFF: { + title: 'Power Off Server', + description: 'The server will be forcefully powered off. Any unsaved data may be lost. All services will stop immediately.', + confirmText: 'Power Off', + variant: 'destructive', + }, + RESET: { + title: 'Hard Reset Server', + description: 'This is equivalent to pressing the physical reset button. The server will be immediately reset without graceful shutdown. Data loss may occur.', + confirmText: 'Hard Reset', + variant: 'destructive', + }, + rescue: { + title: 'Enable Rescue Mode', + description: 'The server will boot into rescue mode on the next restart. You will receive temporary SSH credentials. Use this to recover from system issues.', + confirmText: 'Enable Rescue', + variant: 'warning', + }, + } + + const config = configs[action] + setConfirmDialog({ action, ...config }) + } + + const handleConfirm = async () => { + if (!confirmDialog.action) return + + try { + if (confirmDialog.action === 'rescue') { + await rescueMutation.mutateAsync({ serverId: server.id, activate: true }) + } else { + await powerMutation.mutateAsync({ serverId: server.id, action: confirmDialog.action }) + } + setConfirmDialog({ action: null, title: '', description: '', confirmText: '', variant: 'warning' }) + } catch (error) { + console.error('Action failed:', error) + } + } + + return ( + <> + { + if (!open) setConfirmDialog({ action: null, title: '', description: '', confirmText: '', variant: 'warning' }) + }} + title={confirmDialog.title} + description={confirmDialog.description} + confirmText={confirmDialog.confirmText} + variant={confirmDialog.variant} + onConfirm={handleConfirm} + isLoading={isPending} + /> + + {/* Server is OFF - show only Power On button */} + {server.state === 'OFF' ? ( +
+ +
+ ) : ( + /* Server is ON or in transitional state - show all control buttons */ +
+ {/* Restart */} + + + {/* Power Off */} + + + {/* Hard Reset */} + + + {/* Rescue Mode */} + +
+ )} + + ) +} + +export default function ServerDetailPage() { + const params = useParams() + const router = useRouter() + const serverId = params.id as string + const [hostnameDialogOpen, setHostnameDialogOpen] = useState(false) + const [nicknameDialogOpen, setNicknameDialogOpen] = useState(false) + const [reinstallDialogOpen, setReinstallDialogOpen] = useState(false) + const [reinstallTaskId, setReinstallTaskId] = useState(null) + const [reinstallStartedAt, setReinstallStartedAt] = useState(null) + + // Minimum time to show "Reinstalling" state (5 minutes - typical OS reinstall time) + const REINSTALL_MIN_DURATION_MS = 5 * 60 * 1000 + + const { data: server, isLoading, error } = useNetcupServer(serverId, true) + const fetchStatusMutation = useFetchLiveStatus() + const { data: portainerStatus, isLoading: isPortainerLoading } = usePortainerStatus(server?.primaryIpv4) + + // Track reinstall task status + // Note: Netcup task completes quickly (just "kick off reinstall"), but actual reinstall takes 10-15 min + const { data: reinstallTaskStatus } = useTaskStatus(reinstallTaskId, { + onCompleted: () => { + // Task "completed" but actual reinstall is still in progress + // Don't clear task ID yet - let the minimum duration timer handle it + fetchStatusMutation.mutate(serverId) + }, + onFailed: () => { + // Task failed - clear immediately + setReinstallTaskId(null) + setReinstallStartedAt(null) + }, + }) + + // Calculate if we should still show reinstalling state + // Show REINSTALLING if: task is active OR minimum duration hasn't passed + const now = Date.now() + const timeSinceReinstall = reinstallStartedAt ? now - reinstallStartedAt : 0 + const isWithinReinstallWindow = reinstallStartedAt && timeSinceReinstall < REINSTALL_MIN_DURATION_MS + const taskNotFailed = !reinstallTaskStatus || reinstallTaskStatus.status !== 'FAILED' + + // Show reinstalling if: + // 1. Local state tracking (task ID + time window) says we're reinstalling, OR + // 2. Centralized hook tracking says we're reinstalling + const isReinstallingLocal = reinstallTaskId && taskNotFailed && ( + (reinstallTaskStatus?.status !== 'COMPLETED') || isWithinReinstallWindow + ) + const isReinstallingCentral = isServerReinstalling(serverId) + const isReinstalling = isReinstallingLocal || isReinstallingCentral + const effectiveState = isReinstalling ? 'REINSTALLING' : (server?.state || 'UNKNOWN') + + // Handle reinstall duration tracking + // This effect manages the minimum duration and triggers re-renders for UI updates + const [, forceUpdate] = useState(0) + + useEffect(() => { + if (!reinstallStartedAt || !reinstallTaskId) return + + // Calculate remaining time + const remaining = REINSTALL_MIN_DURATION_MS - (Date.now() - reinstallStartedAt) + if (remaining <= 0) { + // Duration passed, clear the state + setReinstallTaskId(null) + setReinstallStartedAt(null) + fetchStatusMutation.mutate(serverId) + return + } + + // Set a timer to clear after remaining time + const clearTimer = setTimeout(() => { + setReinstallTaskId(null) + setReinstallStartedAt(null) + fetchStatusMutation.mutate(serverId) + }, remaining) + + // Also set up periodic re-renders (every 30s) to update elapsed time display + const updateTimer = setInterval(() => { + forceUpdate((n) => n + 1) + }, 30000) + + return () => { + clearTimeout(clearTimer) + clearInterval(updateTimer) + } + }, [reinstallStartedAt, reinstallTaskId, serverId]) + + // Auto-fetch live status on mount + useEffect(() => { + if (serverId) { + fetchStatusMutation.mutate(serverId) + } + }, [serverId]) + + // Poll for state changes when in transitional state + useEffect(() => { + if (!server || !isTransitionalState(server.state)) { + return + } + + // Poll every 5 seconds while in transitional state + const pollInterval = setInterval(() => { + fetchStatusMutation.mutate(serverId) + }, 5000) + + return () => clearInterval(pollInterval) + }, [server?.state, serverId]) + + if (isLoading) { + return ( +
+
+
+
+ +
+
+

Loading server details...

+
+ ) + } + + if (error || !server) { + return ( +
+ + + +
+
+ +
+

Server not found

+

+ The server you are looking for does not exist or you do not have access to it. +

+ + + +
+
+ ) + } + + const config = stateConfig[effectiveState] || stateConfig.UNKNOWN + const StateIcon = config.icon + + return ( +
+ {/* Edit Hostname Dialog */} + + + {/* Edit Nickname Dialog */} + + + {/* Reinstall Dialog */} + { + setReinstallTaskId(taskId) + setReinstallStartedAt(Date.now()) + }} + /> + + {/* Header - Hero section */} +
+ {/* Background decoration */} +
+
+ +
+ {/* Back link */} + + + Back to Servers + + +
+ {/* Server identity */} +
+
+ +
+
+
+

+ {server.nickname || server.name} +

+ {/* Status badge */} +
+ {config.animate ? ( + + ) : ( + + )} + {config.label} +
+ +
+ {server.nickname && ( +

+ {server.name} +

+ )} +
+
+ + {/* Refresh button */} + +
+
+
+ + {/* Server Specifications */} +
+
+
+ +
+
+

Server Specifications

+

Hardware configuration

+
+
+
+ {/* CPU Cores */} +
+
+
+ +
+
+

+ {server.cpuCores ?? --} +

+

CPU Cores

+
+
+
+ {/* Memory */} +
+
+
+ +
+
+

+ {server.ramGb ? ( + <>{server.ramGb} GB + ) : ( + -- + )} +

+

Memory

+
+
+
+ {/* Storage */} +
+
+
+ +
+
+

+ {server.diskGb ? ( + <>{server.diskGb} GB + ) : ( + -- + )} +

+

Storage

+
+
+
+
+
+ + {/* Network Information */} +
+
+
+ +
+
+

Network Information

+

Connection details and quick links

+
+
+
+
+ {/* Hostname */} +
+

Hostname

+
+

+ {server.hostname || Not set} +

+ {server.hostname && } + +
+
+ {/* Server IP */} +
+

Server IP

+
+ {server.primaryIpv4 ? ( + <> + {server.primaryIpv4} + + + ) : ( + Not available + )} +
+
+ {/* Portainer Link */} +
+

Portainer

+ {!server.primaryIpv4 ? ( + No IP address + ) : isPortainerLoading ? ( + + + Checking availability... + + ) : portainerStatus?.available ? ( + + + + Open Portainer + {portainerStatus.version && ( + v{portainerStatus.version} + )} + + ) : portainerStatus?.needsSetup ? ( + + + Needs setup + {portainerStatus.version && ( + v{portainerStatus.version} + )} + + ) : ( + + + Not available + + )} +
+
+ {/* IPv6 if available */} + {server.primaryIpv6 && ( +
+
+

IPv6 Address

+
+ {server.primaryIpv6} + +
+
+
+ )} +
+
+ + {/* Power Controls Section */} +
+
+
+ +
+
+

Power Controls

+

Manage server power state

+
+
+ +
+ + {/* System Management Section */} +
+
+
+ +
+
+

System Management

+

Reinstall operating system and manage server configuration

+
+
+ +
+
+

Reinstall Operating System

+

+ Erase all data and install a fresh operating system. Choose from Debian, Ubuntu, and more. +

+
+ +
+
+
+ + {/* Metrics Section */} +
+
+
+ +
+
+

Performance Metrics

+

Monitor CPU, disk I/O, and network performance

+
+
+ +
+ + {/* Snapshots Section */} +
+
+
+ +
+
+

Snapshots

+

Create and manage server snapshots for backup and recovery

+
+
+ +
+
+ ) +} diff --git a/src/app/admin/servers/netcup/page.tsx b/src/app/admin/servers/netcup/page.tsx new file mode 100644 index 0000000..847294a --- /dev/null +++ b/src/app/admin/servers/netcup/page.tsx @@ -0,0 +1,916 @@ +'use client' + +import { useState } from 'react' +import Link from 'next/link' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { + Loader2, + Server, + Power, + PowerOff, + RefreshCw, + CheckCircle, + Wrench, + HardDrive, + Cpu, + MemoryStick, + ChevronDown, + ChevronUp, + Activity, + Camera, + Trash2, + RotateCcw, + Plus, + Network, + Download, + Upload, + Eye, + Globe, + Copy, + Check, + Clock, + ServerCrash, +} from 'lucide-react' +import { + useNetcupServers, + useNetcupAuth, + useNetcupPowerAction, + useNetcupRescue, + useServerMetrics, + useServerSnapshots, + useCreateSnapshot, + useDeleteSnapshot, + useRevertSnapshot, + isServerReinstalling, + PowerAction, + NetcupServer, +} from '@/hooks/use-netcup' +import { NetcupAuthSetup } from '@/components/admin/netcup-auth-setup' + +const stateConfig: Record< + string, + { label: string; bgColor: string; textColor: string; borderColor: string; dotColor: string; icon: typeof Power; animate?: boolean } +> = { + ON: { label: 'Online', bgColor: 'bg-emerald-50', textColor: 'text-emerald-700', borderColor: 'border-emerald-200', dotColor: 'bg-emerald-500', icon: CheckCircle }, + OFF: { label: 'Offline', bgColor: 'bg-slate-50', textColor: 'text-slate-600', borderColor: 'border-slate-200', dotColor: 'bg-slate-400', icon: PowerOff }, + POWERCYCLE: { label: 'Restarting', bgColor: 'bg-amber-50', textColor: 'text-amber-700', borderColor: 'border-amber-200', dotColor: 'bg-amber-500', icon: RefreshCw, animate: true }, + RESET: { label: 'Hard Resetting', bgColor: 'bg-orange-50', textColor: 'text-orange-700', borderColor: 'border-orange-200', dotColor: 'bg-orange-500', icon: RefreshCw, animate: true }, + POWEROFF: { label: 'Shutting Down', bgColor: 'bg-orange-50', textColor: 'text-orange-700', borderColor: 'border-orange-200', dotColor: 'bg-orange-500', icon: PowerOff, animate: true }, + REINSTALLING: { label: 'Reinstalling', bgColor: 'bg-violet-50', textColor: 'text-violet-700', borderColor: 'border-violet-200', dotColor: 'bg-violet-500', icon: RefreshCw, animate: true }, + UNKNOWN: { label: 'Unknown', bgColor: 'bg-slate-50', textColor: 'text-slate-500', borderColor: 'border-slate-200', dotColor: 'bg-slate-300', icon: Server }, +} + +function formatBytes(bytes: number): string { + if (bytes === 0) return '0 B' + const k = 1024 + const sizes = ['B', 'KB', 'MB', 'GB', 'TB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i] +} + +function formatBytesPerSec(bps: number): string { + return formatBytes(bps) + '/s' +} + +function CopyButton({ text }: { text: string }) { + const [copied, setCopied] = useState(false) + + const handleCopy = async (e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() + await navigator.clipboard.writeText(text) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + + return ( + + ) +} + +function MetricsBar({ value, max = 100, label, gradient }: { + value: number + max?: number + label: string + gradient?: string +}) { + const percentage = Math.min((value / max) * 100, 100) + + const getThresholdColor = () => { + if (percentage > 90) return 'bg-gradient-to-r from-red-500 to-red-600' + if (percentage > 75) return 'bg-gradient-to-r from-amber-500 to-orange-500' + return gradient || 'bg-gradient-to-r from-blue-500 to-blue-600' + } + + return ( +
+
+ {label} + {value.toFixed(1)}% +
+
+
+
+
+ ) +} + +function MetricsPanel({ serverId }: { serverId: string }) { + const [hours, setHours] = useState(24) + const { data: metrics, isLoading, isFetching, error, refetch } = useServerMetrics(serverId, hours) + + if (isLoading) { + return ( +
+ +
+ ) + } + + if (error) { + return ( +
+

Failed to load metrics

+ +
+ ) + } + + if (!metrics) { + return ( +
+ +

No metrics available

+
+ ) + } + + return ( +
+ {/* Period selector */} +
+
+ {[1, 6, 24, 168].map((h) => ( + + ))} +
+ +
+ +
+ {/* CPU */} +
+
+
+
+ +
+ CPU +
+ + {metrics.cpu.average}% + +
+ +
+ + {/* Disk I/O */} + {metrics.disk && (metrics.disk.readBps.length > 0 || metrics.disk.writeBps.length > 0) && ( +
+
+
+ +
+ Disk I/O +
+
+
+ + + {metrics.disk.readBps.length > 0 + ? formatBytesPerSec(metrics.disk.readBps[metrics.disk.readBps.length - 1]?.value || 0) + : 'N/A'} + +
+
+ + + {metrics.disk.writeBps.length > 0 + ? formatBytesPerSec(metrics.disk.writeBps[metrics.disk.writeBps.length - 1]?.value || 0) + : 'N/A'} + +
+
+
+ )} + + {/* Network */} + {metrics.network && (metrics.network.rxBps.length > 0 || metrics.network.txBps.length > 0) && ( +
+
+
+ +
+ Network +
+
+
+ + + {metrics.network.rxBps.length > 0 + ? formatBytesPerSec(metrics.network.rxBps[metrics.network.rxBps.length - 1]?.value || 0) + : 'N/A'} + +
+
+ + + {metrics.network.txBps.length > 0 + ? formatBytesPerSec(metrics.network.txBps[metrics.network.txBps.length - 1]?.value || 0) + : 'N/A'} + +
+
+
+ )} +
+
+ ) +} + +function SnapshotsPanel({ serverId }: { serverId: string }) { + const [newSnapshotName, setNewSnapshotName] = useState('') + const [confirmDelete, setConfirmDelete] = useState(null) + const [confirmRevert, setConfirmRevert] = useState(null) + + const { data: snapshotsData, isLoading, isFetching, error, refetch } = useServerSnapshots(serverId) + const createMutation = useCreateSnapshot() + const deleteMutation = useDeleteSnapshot() + const revertMutation = useRevertSnapshot() + + const handleCreateSnapshot = async () => { + try { + await createMutation.mutateAsync({ + serverId, + name: newSnapshotName || undefined + }) + setNewSnapshotName('') + } catch (error) { + console.error('Failed to create snapshot:', error) + } + } + + const handleDeleteSnapshot = async (name: string) => { + try { + await deleteMutation.mutateAsync({ serverId, name }) + setConfirmDelete(null) + } catch (error) { + console.error('Failed to delete snapshot:', error) + } + } + + const handleRevertSnapshot = async (name: string) => { + try { + await revertMutation.mutateAsync({ serverId, name }) + setConfirmRevert(null) + } catch (error) { + console.error('Failed to revert snapshot:', error) + } + } + + if (isLoading) { + return ( +
+ +
+ ) + } + + if (error) { + return ( +
+

Failed to load snapshots

+ +
+ ) + } + + const snapshots = snapshotsData?.snapshots || [] + + return ( +
+ {/* Create snapshot */} +
+ setNewSnapshotName(e.target.value)} + className="h-8 text-xs bg-background" + /> + +
+ + {/* Snapshots list */} + {snapshots.length === 0 ? ( +
+ +

No snapshots

+
+ ) : ( +
+ {snapshots.map((snapshot) => ( +
+
+

{snapshot.name}

+
+ + + {new Date(snapshot.createdAt).toLocaleDateString()} + + {snapshot.size && ( + {formatBytes(snapshot.size)} + )} +
+
+ + {confirmDelete === snapshot.name ? ( +
+ + +
+ ) : confirmRevert === snapshot.name ? ( +
+ + +
+ ) : ( +
+ + +
+ )} +
+ ))} +
+ )} +
+ ) +} + +function ServerCard({ server }: { server: NetcupServer }) { + const [confirmAction, setConfirmAction] = useState(null) + const [expandedSection, setExpandedSection] = useState<'metrics' | 'snapshots' | null>(null) + + const powerMutation = useNetcupPowerAction() + const rescueMutation = useNetcupRescue() + + // Check if this server is being reinstalled (centralized tracking) + const isReinstalling = isServerReinstalling(server.id) + const effectiveState = isReinstalling ? 'REINSTALLING' : server.state + + const config = stateConfig[effectiveState] || stateConfig.UNKNOWN + const StateIcon = config.icon + const isOn = effectiveState === 'ON' + const isPending = powerMutation.isPending || rescueMutation.isPending + + const handlePowerAction = async (action: PowerAction) => { + try { + await powerMutation.mutateAsync({ serverId: server.id, action }) + setConfirmAction(null) + } catch (error) { + console.error('Power action failed:', error) + } + } + + const handleRescue = async (activate: boolean) => { + try { + await rescueMutation.mutateAsync({ serverId: server.id, activate }) + setConfirmAction(null) + } catch (error) { + console.error('Rescue action failed:', error) + } + } + + const toggleSection = (section: 'metrics' | 'snapshots') => { + setExpandedSection(expandedSection === section ? null : section) + } + + return ( + + {/* Card gradient background */} +
+
+ + {/* Decorative gradient blob */} +
+ + + +
+
+
+ +
+
+ + {server.nickname || server.name} + +

+ {server.nickname ? server.name : null} +

+
+
+ + {/* Status badge with dot indicator */} +
+ + {config.label} +
+
+
+ + + + {/* Server specs - card style with colored icons */} +
+ {server.cpuCores && ( +
+
+ +
+
+

{server.cpuCores}

+

Cores

+
+
+ )} + {server.ramGb && ( +
+
+ +
+
+

{server.ramGb}

+

GB RAM

+
+
+ )} + {server.diskGb && ( +
+
+ +
+
+

{server.diskGb}

+

GB SSD

+
+
+ )} +
+ + {/* Network info - improved styling */} +
+ {server.hostname && ( +
+
+ + Hostname +
+
+ {server.hostname} + +
+
+ )} +
+
+ + IPv4 +
+
+ {server.primaryIpv4 ? ( + <> + {server.primaryIpv4} + + + ) : ( + Not available + )} +
+
+
+ + {/* Confirm dialog - improved styling */} + {confirmAction && ( +
+

+ {confirmAction === 'rescue' + ? 'Enable rescue mode?' + : `Confirm ${confirmAction.toLowerCase()} action?`} +

+

+ {confirmAction === 'POWEROFF' && 'Server will be forcefully powered off.'} + {confirmAction === 'RESET' && 'Server will be hard reset (may cause data loss).'} + {confirmAction === 'POWERCYCLE' && 'Server will be power cycled.'} + {confirmAction === 'rescue' && 'Server will boot into rescue mode on next restart.'} +

+
+ + +
+
+ )} + + {/* Power control buttons - card style with hover effects */} + {!confirmAction && ( +
+ {effectiveState === 'UNKNOWN' || effectiveState === 'REINSTALLING' ? ( + <> + + + + ) : !isOn ? ( + + ) : ( + <> + + + + )} + + + + +
+ )} + + {/* Expandable sections - improved styling */} +
+ {/* Metrics toggle */} + + {expandedSection === 'metrics' && ( +
+ +
+ )} + + {/* Snapshots toggle */} + + {expandedSection === 'snapshots' && ( +
+ +
+ )} +
+
+
+ + ) +} + +function LoadingSkeleton() { + return ( +
+ {[1, 2, 3].map((i) => ( + + +
+
+
+
+
+
+
+
+
+
+ + +
+ {[1, 2, 3].map((j) => ( +
+ ))} +
+
+
+ {[1, 2, 3, 4].map((j) => ( +
+ ))} +
+ + + ))} +
+ ) +} + +function EmptyState() { + return ( +
+
+ +
+

No servers found

+

+ Your Netcup account does not have any servers yet. Servers will appear here once they are provisioned. +

+
+ ) +} + +export default function NetcupServersPage() { + const { data: authStatus, isLoading: isLoadingAuth } = useNetcupAuth() + const { data: serversData, isLoading: isLoadingServers, refetch } = useNetcupServers() + + const isAuthenticated = authStatus?.authenticated + + return ( +
+ {/* Hero Header */} +
+ {/* Background decorations */} +
+
+ +
+
+
+ +
+
+

Netcup Servers

+

+ Manage your Netcup servers via SCP API +

+
+
+ {isAuthenticated && ( + + )} +
+
+ + {/* Auth setup card */} + + + {/* Loading state - initial auth check */} + {isLoadingAuth && ( +
+
+
+
+ +
+
+

Checking authentication...

+
+ )} + + {/* Servers grid - only show if authenticated */} + {isAuthenticated && ( + <> + {isLoadingServers ? ( + + ) : serversData?.servers && serversData.servers.length > 0 ? ( +
+ {serversData.servers.map((server) => ( + + ))} +
+ ) : ( + + )} + + )} +
+ ) +} diff --git a/src/app/admin/servers/page.tsx b/src/app/admin/servers/page.tsx index a9ab506..834d37a 100644 --- a/src/app/admin/servers/page.tsx +++ b/src/app/admin/servers/page.tsx @@ -29,44 +29,103 @@ import { Clock, Cpu, Package, + Filter, + ServerCrash, + Zap, + Activity, } from 'lucide-react' -// Status badge component -function ServerStatusBadge({ status }: { status: ServerStatus }) { - const config: Record = { - online: { - label: 'Online', - className: 'bg-green-100 text-green-800', - icon: , - }, - provisioning: { - label: 'Provisioning', - className: 'bg-blue-100 text-blue-800', - icon: , - }, - offline: { - label: 'Offline', - className: 'bg-red-100 text-red-800', - icon: , - }, - pending: { - label: 'Pending', - className: 'bg-yellow-100 text-yellow-800', - icon: , - }, - } +// Status configuration with enhanced styling +const statusConfig: Record = { + online: { + label: 'Online', + bgColor: 'bg-emerald-50 dark:bg-emerald-950/30', + textColor: 'text-emerald-700 dark:text-emerald-400', + borderColor: 'border-emerald-200 dark:border-emerald-800', + dotColor: 'bg-emerald-500', + iconBg: 'bg-emerald-100 dark:bg-emerald-900/50', + iconColor: 'text-emerald-600 dark:text-emerald-400', + cardGradient: 'from-emerald-50/50 via-card to-card dark:from-emerald-950/20', + icon: CheckCircle, + }, + provisioning: { + label: 'Provisioning', + bgColor: 'bg-blue-50 dark:bg-blue-950/30', + textColor: 'text-blue-700 dark:text-blue-400', + borderColor: 'border-blue-200 dark:border-blue-800', + dotColor: 'bg-blue-500', + iconBg: 'bg-blue-100 dark:bg-blue-900/50', + iconColor: 'text-blue-600 dark:text-blue-400', + cardGradient: 'from-blue-50/50 via-card to-card dark:from-blue-950/20', + icon: Clock, + }, + offline: { + label: 'Offline', + bgColor: 'bg-red-50 dark:bg-red-950/30', + textColor: 'text-red-700 dark:text-red-400', + borderColor: 'border-red-200 dark:border-red-800', + dotColor: 'bg-red-500', + iconBg: 'bg-red-100 dark:bg-red-900/50', + iconColor: 'text-red-600 dark:text-red-400', + cardGradient: 'from-red-50/50 via-card to-card dark:from-red-950/20', + icon: XCircle, + }, + pending: { + label: 'Pending', + bgColor: 'bg-amber-50 dark:bg-amber-950/30', + textColor: 'text-amber-700 dark:text-amber-400', + borderColor: 'border-amber-200 dark:border-amber-800', + dotColor: 'bg-amber-500', + iconBg: 'bg-amber-100 dark:bg-amber-900/50', + iconColor: 'text-amber-600 dark:text-amber-400', + cardGradient: 'from-amber-50/50 via-card to-card dark:from-amber-950/20', + icon: Clock, + }, +} - const statusConfig = config[status] +// Enhanced status badge component with dot indicator and pulse animation +function ServerStatusBadge({ status }: { status: ServerStatus }) { + const config = statusConfig[status] return ( - - {statusConfig.icon} - {statusConfig.label} + + + {config.label} ) } -// Server card component +// Tool chip component with improved styling +function ToolChip({ tool }: { tool: string }) { + // Tool-specific colors + const getToolColor = (toolName: string) => { + const name = toolName.toLowerCase() + if (name.includes('nextcloud')) return 'bg-blue-100 text-blue-700 border-blue-200 dark:bg-blue-900/30 dark:text-blue-300 dark:border-blue-800' + if (name.includes('keycloak')) return 'bg-purple-100 text-purple-700 border-purple-200 dark:bg-purple-900/30 dark:text-purple-300 dark:border-purple-800' + if (name.includes('minio')) return 'bg-rose-100 text-rose-700 border-rose-200 dark:bg-rose-900/30 dark:text-rose-300 dark:border-rose-800' + if (name.includes('poste')) return 'bg-emerald-100 text-emerald-700 border-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-300 dark:border-emerald-800' + if (name.includes('portainer')) return 'bg-cyan-100 text-cyan-700 border-cyan-200 dark:bg-cyan-900/30 dark:text-cyan-300 dark:border-cyan-800' + return 'bg-slate-100 text-slate-700 border-slate-200 dark:bg-slate-800 dark:text-slate-300 dark:border-slate-700' + } + + return ( + + {tool} + + ) +} + +// Enhanced Server card component function ServerCard({ server }: { server: { id: string domain: string @@ -83,75 +142,88 @@ function ServerCard({ server }: { server: { company: string | null } }}) { + const config = statusConfig[server.serverStatus] + const StatusIcon = config.icon + return ( - - -
+ + {/* Subtle gradient overlay on hover */} +
+ + + {/* Header with icon and status */} +
-
- +
+
-
-
-

{server.domain}

- +
+
+ +

+ {server.domain} +

+
-

{server.serverIp}

+

{server.serverIp}

- -
-
-
- -
-

{server.customer.name || server.customer.email}

+ {/* Status badge */} +
+ +
+ + {/* Server details grid */} +
+
+ +
+

{server.customer.name || server.customer.email}

{server.customer.company && ( -

{server.customer.company}

+

{server.customer.company}

)}
-
- - {server.tier.replace('_', ' ').toLowerCase()} +
+ + {server.tier.replace('_', ' ').toLowerCase()}
-
- - SSH Port: {server.sshPort} +
+ + SSH: {server.sshPort}
-
- - {new Date(server.createdAt).toLocaleDateString()} +
+ + {new Date(server.createdAt).toLocaleDateString()}
-
+ {/* Tools section */} +
Tools ({server.tools.length})
-
- {server.tools.map((tool) => ( - - {tool} - +
+ {server.tools.slice(0, 4).map((tool) => ( + ))} + {server.tools.length > 4 && ( + + +{server.tools.length - 4} more + + )}
@@ -159,6 +231,173 @@ function ServerCard({ server }: { server: { ) } +// Stats card component +function StatsCard({ + icon: Icon, + value, + label, + iconBg, + iconColor, + trend +}: { + icon: typeof Server + value: number + label: string + iconBg: string + iconColor: string + trend?: 'up' | 'down' | 'neutral' +}) { + return ( + +
+ +
+
+ +
+
+
{value}
+

{label}

+
+
+
+ + ) +} + +// Empty state component with illustration +function EmptyState({ + hasFilters, + onClearFilters +}: { + hasFilters: boolean + onClearFilters: () => void +}) { + return ( +
+ {/* Illustration */} +
+
+
+ +
+
+ +

No servers found

+

+ {hasFilters + ? 'No servers match your current filters. Try adjusting your search criteria or clear the filters to see all servers.' + : 'Servers will appear here once orders are provisioned. Create a new order to get started.'} +

+ + {hasFilters && ( + + )} +
+ ) +} + +// Pagination component +function Pagination({ + currentPage, + totalPages, + onPageChange +}: { + currentPage: number + totalPages: number + onPageChange: (page: number) => void +}) { + const pages = useMemo(() => { + const items: (number | 'ellipsis')[] = [] + const showEllipsisStart = currentPage > 3 + const showEllipsisEnd = currentPage < totalPages - 2 + + if (totalPages <= 7) { + // Show all pages + for (let i = 1; i <= totalPages; i++) { + items.push(i) + } + } else { + // Always show first page + items.push(1) + + if (showEllipsisStart) { + items.push('ellipsis') + } + + // Show pages around current + const start = Math.max(2, currentPage - 1) + const end = Math.min(totalPages - 1, currentPage + 1) + + for (let i = start; i <= end; i++) { + if (!items.includes(i)) items.push(i) + } + + if (showEllipsisEnd) { + items.push('ellipsis') + } + + // Always show last page + if (!items.includes(totalPages)) items.push(totalPages) + } + + return items + }, [currentPage, totalPages]) + + return ( +
+

+ Page {currentPage} of {totalPages} +

+ +
+ + +
+ {pages.map((page, idx) => ( + page === 'ellipsis' ? ( + ... + ) : ( + + ) + ))} +
+ + +
+
+ ) +} + export default function ServersPage() { const [search, setSearch] = useState('') const [statusFilter, setStatusFilter] = useState('all') @@ -192,15 +431,25 @@ export default function ServersPage() { }, [data]) const totalPages = data?.pagination?.totalPages || 1 + const hasFilters = Boolean(search || statusFilter !== 'all') + + const clearFilters = () => { + setSearch('') + setStatusFilter('all') + setCurrentPage(1) + } // Loading state if (isLoading) { return ( -
-
- -

Loading servers...

+
+
+
+
+ +
+

Loading servers...

) } @@ -208,104 +457,106 @@ export default function ServersPage() { // Error state if (isError) { return ( -
-
- -
-

Failed to load servers

-

- {error instanceof Error ? error.message : 'An error occurred'} -

+
+
+
+
+
-
+
+

Failed to load servers

+

+ {error instanceof Error ? error.message : 'An unexpected error occurred'} +

+
+
) } return ( -
- {/* Page header */} -
-
-

Servers

-

- Manage deployed infrastructure servers -

-
- -
+
+ {/* Hero Header Section */} +
+ {/* Background decorations */} +
+
+
- {/* Stats cards */} -
- - -
+
+
+
-
-
{stats.total}
-

Total Servers

-
- - - - -
- -
-
{stats.online}
-

Online

-
+
+

Servers

+

+ Manage and monitor your deployed infrastructure +

- - - - -
- -
-
{stats.provisioning}
-

Provisioning

-
-
-
-
- - -
- -
-
{stats.offline}
-

Offline

-
-
-
-
+
+ +
- {/* Filters */} - - + {/* Stats Cards Grid */} +
+ + + + +
+ + {/* Filters Section */} + +
- All Servers - + All Servers + {data?.pagination?.total || 0} server{(data?.pagination?.total || 0) !== 1 ? 's' : ''} found + {hasFilters && ' with current filters'}
-
+
+ {/* Search Input */}
- + + {/* Status Filter */} +
+ + + +
+ {data?.servers && data.servers.length > 0 ? ( <> - {/* Server grid */} -
+ {/* Server Cards Grid */} +
{data.servers.map((server) => ( ))} @@ -347,55 +606,15 @@ export default function ServersPage() { {/* Pagination */} {totalPages > 1 && ( -
-

- Page {currentPage} of {totalPages} -

-
- - -
-
+ )} ) : ( -
- -

No servers found

-

- {search || statusFilter !== 'all' - ? 'Try adjusting your search or filters' - : 'Servers will appear here once orders are provisioned'} -

- {(search || statusFilter !== 'all') && ( - - )} -
+ )} diff --git a/src/app/admin/settings/page.tsx b/src/app/admin/settings/page.tsx new file mode 100644 index 0000000..3ff1e88 --- /dev/null +++ b/src/app/admin/settings/page.tsx @@ -0,0 +1,1075 @@ +'use client' + +import { useState, useEffect } from 'react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { useSettings, useUpdateSettings } from '@/hooks/use-settings' +import { + Settings, + Container, + Key, + Server, + Wrench, + Eye, + EyeOff, + Save, + RotateCcw, + Loader2, + AlertCircle, + CheckCircle, + RefreshCw, + Shield, + AlertTriangle, + X, + Mail, + Bell, + Send, + Database, + HardDrive, +} from 'lucide-react' +import { Switch } from '@/components/ui/switch' + +interface SettingFieldProps { + settingKey: string + label: string + description?: string + placeholder?: string + type?: 'text' | 'password' | 'number' + value: string + encrypted?: boolean + maskedValue?: string + onChange: (key: string, value: string) => void +} + +function SettingField({ + settingKey, + label, + description, + placeholder, + type = 'text', + value, + encrypted, + maskedValue, + onChange, +}: SettingFieldProps) { + const [showPassword, setShowPassword] = useState(false) + const [localValue, setLocalValue] = useState(value) + const [isDirty, setIsDirty] = useState(false) + + useEffect(() => { + if (!isDirty) { + setLocalValue(value) + } + }, [value, isDirty]) + + const handleChange = (newValue: string) => { + setLocalValue(newValue) + setIsDirty(true) + onChange(settingKey, newValue) + } + + const isPassword = type === 'password' || encrypted + + return ( +
+ +
+ handleChange(e.target.value)} + className={`h-10 bg-background/50 border-muted-foreground/20 focus:border-primary/50 focus:ring-2 focus:ring-primary/20 transition-all ${ + isPassword ? 'pr-12' : '' + } ${isDirty ? 'border-blue-300 dark:border-blue-700' : ''}`} + /> + {isPassword && ( + + )} +
+ {description && ( +

{description}

+ )} +
+ ) +} + +interface SettingsSectionProps { + title: string + description: string + icon: React.ReactNode + iconBgColor: string + iconColor: string + gradientFrom?: string + children: React.ReactNode +} + +function SettingsSection({ + title, + description, + icon, + iconBgColor, + iconColor, + gradientFrom = 'from-card', + children +}: SettingsSectionProps) { + return ( +
+
+
+
+
{icon}
+
+
+

{title}

+

{description}

+
+
+
+
{children}
+
+ ) +} + +export default function SettingsPage() { + const { data, isLoading, error, refetch, isFetching } = useSettings() + const updateMutation = useUpdateSettings() + + const [formValues, setFormValues] = useState>({}) + const [saveMessage, setSaveMessage] = useState<{ type: 'success' | 'error'; message: string } | null>(null) + const [testEmail, setTestEmail] = useState('') + const [testingEmail, setTestingEmail] = useState(false) + const [testEmailResult, setTestEmailResult] = useState<{ success: boolean; message: string } | null>(null) + const [testingStorage, setTestingStorage] = useState(false) + const [testStorageResult, setTestStorageResult] = useState<{ success: boolean; message: string } | null>(null) + + // Initialize form values from fetched settings + useEffect(() => { + if (data?.settings) { + const initial: Record = {} + for (const setting of data.settings) { + // For encrypted values, don't pre-fill (user must re-enter) + initial[setting.key] = setting.encrypted ? '' : setting.value + } + setFormValues(initial) + } + }, [data]) + + const handleChange = (key: string, value: string) => { + setFormValues((prev) => ({ ...prev, [key]: value })) + } + + const handleSave = async () => { + setSaveMessage(null) + + // Convert form values to settings array + const settingsToUpdate = Object.entries(formValues) + .filter(([, value]) => value !== '') // Only include non-empty values + .map(([key, value]) => ({ key, value })) + + try { + await updateMutation.mutateAsync({ settings: settingsToUpdate }) + setSaveMessage({ type: 'success', message: 'Settings saved successfully!' }) + // Reset form values after successful save + setFormValues({}) + setTimeout(() => setSaveMessage(null), 5000) + } catch (err) { + setSaveMessage({ + type: 'error', + message: err instanceof Error ? err.message : 'Failed to save settings', + }) + } + } + + const handleReset = () => { + setFormValues({}) + if (data?.settings) { + const initial: Record = {} + for (const setting of data.settings) { + initial[setting.key] = setting.encrypted ? '' : setting.value + } + setFormValues(initial) + } + setSaveMessage(null) + } + + const handleTestEmail = async () => { + setTestingEmail(true) + setTestEmailResult(null) + + try { + const response = await fetch('/api/v1/admin/settings/email/test', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ testEmail: testEmail || undefined }), + }) + + const result = await response.json() + + if (result.success) { + setTestEmailResult({ + success: true, + message: result.emailSent + ? `Connection successful! Test email sent to ${testEmail}` + : 'SMTP connection successful!', + }) + } else { + setTestEmailResult({ + success: false, + message: result.error || 'Connection test failed', + }) + } + } catch { + setTestEmailResult({ + success: false, + message: 'Failed to test email connection', + }) + } finally { + setTestingEmail(false) + } + } + + const handleTestStorage = async () => { + setTestingStorage(true) + setTestStorageResult(null) + + try { + const response = await fetch('/api/v1/admin/settings/storage/test', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }) + + const result = await response.json() + + setTestStorageResult({ + success: result.success, + message: result.message || (result.success ? 'Connection successful!' : 'Connection failed'), + }) + } catch { + setTestStorageResult({ + success: false, + message: 'Failed to test storage connection', + }) + } finally { + setTestingStorage(false) + } + } + + const getSettingValue = (key: string) => formValues[key] ?? '' + const getSettingMasked = (key: string) => { + const setting = data?.settings.find((s) => s.key === key) + return setting?.maskedValue + } + const isEncrypted = (key: string) => { + const setting = data?.settings.find((s) => s.key === key) + return setting?.encrypted ?? false + } + + if (isLoading) { + return ( +
+
+
+
+ +
+
+

Loading settings...

+
+ ) + } + + if (error) { + return ( +
+
+ +
+

Failed to load settings

+

+ There was an error loading the system settings. Please try again. +

+ +
+ ) + } + + const hasChanges = Object.values(formValues).some((v) => v !== '') + + return ( +
+ {/* Hero Header */} +
+ {/* Background decoration */} +
+
+ +
+
+ {/* Title and description */} +
+
+ +
+
+

System Settings

+

+ Configure system-wide settings for provisioning and integrations +

+
+
+ + {/* Action buttons */} +
+ + +
+
+
+
+ + {/* Save Message Toast */} + {saveMessage && ( +
+
+ {saveMessage.type === 'success' ? ( + + ) : ( + + )} +
+ {saveMessage.message} + +
+ )} + + {/* Docker Runner Section */} + } + iconBgColor="bg-blue-100 dark:bg-blue-900/30" + iconColor="text-blue-600 dark:text-blue-400" + gradientFrom="from-card" + > +
+ + + + + +
+
+ + {/* Docker Hub Section */} + } + iconBgColor="bg-sky-100 dark:bg-sky-900/30" + iconColor="text-sky-600 dark:text-sky-400" + gradientFrom="from-card" + > +
+ + + +
+
+ + {/* Gitea Registry Section */} + } + iconBgColor="bg-amber-100 dark:bg-amber-900/30" + iconColor="text-amber-600 dark:text-amber-400" + gradientFrom="from-card" + > +
+ + + +
+
+ + {/* Hub Configuration Section */} + } + iconBgColor="bg-purple-100 dark:bg-purple-900/30" + iconColor="text-purple-600 dark:text-purple-400" + gradientFrom="from-card" + > +
+ + +
+ + {/* Warning Box */} +
+
+
+
+ +
+
+
+

Important Warning

+

+ Changing the encryption key will prevent decryption of previously encrypted values. + Only change this if you understand the implications and have backed up your data. +

+
+
+
+
+ + {/* Provisioning Defaults Section */} + } + iconBgColor="bg-emerald-100 dark:bg-emerald-900/30" + iconColor="text-emerald-600 dark:text-emerald-400" + gradientFrom="from-card" + > +
+ + +
+
+ + {/* Email Configuration Section */} + } + iconBgColor="bg-pink-100 dark:bg-pink-900/30" + iconColor="text-pink-600 dark:text-pink-400" + gradientFrom="from-card" + > +
+ + + + + + +
+ + {/* TLS Toggle */} +
+
+ +

Enable secure connection (recommended)

+
+ handleChange('email.smtp.secure', checked ? 'true' : 'false')} + /> +
+ + {/* Test Connection */} +
+

+ + Test Email Connection +

+
+ setTestEmail(e.target.value)} + className="flex-1" + /> + +
+

+ Save your changes first, then test. Leave email empty to just test connection, or enter an email to send a test message. +

+ {testEmailResult && ( +
+ {testEmailResult.success ? ( + + ) : ( + + )} + {testEmailResult.message} +
+ )} +
+
+ + {/* Notifications Section */} + } + iconBgColor="bg-orange-100 dark:bg-orange-900/30" + iconColor="text-orange-600 dark:text-orange-400" + gradientFrom="from-card" + > + {/* Master Toggle */} +
+
+ +

Master switch for all email notifications

+
+ handleChange('notifications.enabled', checked ? 'true' : 'false')} + /> +
+ + {/* Recipients */} +
+ + handleChange('notifications.recipients', e.target.value)} + /> +

+ JSON array of email addresses to receive notifications +

+
+ + {/* Cooldown */} +
+ +
+ + {/* Container Alerts */} +
+

+ + Container Alerts +

+
+
+
+ +

Alert when containers crash unexpectedly

+
+ handleChange('notifications.container.crashes.enabled', checked ? 'true' : 'false')} + /> +
+
+
+ +

Alert when containers are killed due to memory limits

+
+ handleChange('notifications.container.oom.enabled', checked ? 'true' : 'false')} + /> +
+
+
+ +

Alert when containers restart unexpectedly

+
+ handleChange('notifications.container.restarts.enabled', checked ? 'true' : 'false')} + /> +
+
+
+ + {/* Error Alerts */} +
+

+ + Error Alerts +

+
+
+
+ +

Alert on critical-level errors detected in logs

+
+ handleChange('notifications.errors.critical.enabled', checked ? 'true' : 'false')} + /> +
+
+
+ +

Alert on error-level messages (higher volume)

+
+ handleChange('notifications.errors.error.enabled', checked ? 'true' : 'false')} + /> +
+
+
+ + {/* Server Stats Alerts */} +
+

+ + Server Stats Alerts +

+
+ {/* CPU */} +
+
+
+ +

Alert when CPU exceeds threshold

+
+ handleChange('notifications.stats.cpu.enabled', checked ? 'true' : 'false')} + /> +
+
+ + handleChange('notifications.stats.cpu.threshold', e.target.value)} + className="w-20 h-8 text-sm" + /> + % +
+
+ + {/* Memory */} +
+
+
+ +

Alert when memory exceeds threshold

+
+ handleChange('notifications.stats.memory.enabled', checked ? 'true' : 'false')} + /> +
+
+ + handleChange('notifications.stats.memory.threshold', e.target.value)} + className="w-20 h-8 text-sm" + /> + % +
+
+ + {/* Disk */} +
+
+
+ +

Alert when disk usage exceeds threshold

+
+ handleChange('notifications.stats.disk.enabled', checked ? 'true' : 'false')} + /> +
+
+ + handleChange('notifications.stats.disk.threshold', e.target.value)} + className="w-20 h-8 text-sm" + /> + % +
+
+
+
+
+ + {/* Object Storage Section */} + } + iconBgColor="bg-cyan-100 dark:bg-cyan-900/30" + iconColor="text-cyan-600 dark:text-cyan-400" + gradientFrom="from-card" + > +
+ + + + + + +
+ + {/* SSL Toggle */} +
+
+ +

Enable secure connection to storage server

+
+ handleChange('storage.use_ssl', checked ? 'true' : 'false')} + /> +
+ + {/* Test Connection */} +
+

+ + Test Storage Connection +

+
+ +
+

+ Save your changes first, then test. This will verify connection to the bucket. +

+ {testStorageResult && ( +
+ {testStorageResult.success ? ( + + ) : ( + + )} + {testStorageResult.message} +
+ )} +
+
+
+ ) +} diff --git a/src/app/admin/staff/page.tsx b/src/app/admin/staff/page.tsx new file mode 100644 index 0000000..5f87bd8 --- /dev/null +++ b/src/app/admin/staff/page.tsx @@ -0,0 +1,464 @@ +'use client' + +import { useState } from 'react' +import { useSession } from 'next-auth/react' +import { StaffRole, StaffStatus } from '@prisma/client' +import { + useStaffList, + useInvitations, + useUpdateStaff, + useDeleteStaff, + useCancelInvitation, +} from '@/hooks/use-staff' +import { hasPermission } from '@/lib/services/permission-service' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog' +import { Badge } from '@/components/ui/badge' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { + MoreHorizontal, + Search, + UserPlus, + Shield, + ShieldCheck, + ShieldAlert, + Clock, + Trash2, + UserX, + UserCheck, +} from 'lucide-react' +import { InviteStaffDialog } from '@/components/admin/invite-staff-dialog' + +const roleColors: Record = { + OWNER: 'bg-purple-100 text-purple-800', + ADMIN: 'bg-blue-100 text-blue-800', + MANAGER: 'bg-green-100 text-green-800', + SUPPORT: 'bg-gray-100 text-gray-800', +} + +const roleIcons: Record = { + OWNER: ShieldAlert, + ADMIN: ShieldCheck, + MANAGER: Shield, + SUPPORT: Shield, +} + +const statusColors: Record = { + ACTIVE: 'bg-green-100 text-green-800', + SUSPENDED: 'bg-red-100 text-red-800', +} + +export default function StaffPage() { + const { data: session } = useSession() + const userRole = (session?.user?.role as StaffRole) || 'SUPPORT' + + const [search, setSearch] = useState('') + const [showInviteDialog, setShowInviteDialog] = useState(false) + const [staffToDelete, setStaffToDelete] = useState(null) + const [inviteToCancel, setInviteToCancel] = useState(null) + + const { data: staffData, isLoading: staffLoading } = useStaffList({ search }) + const { data: invitesData, isLoading: invitesLoading } = useInvitations() + const updateStaff = useUpdateStaff() + const deleteStaff = useDeleteStaff() + const cancelInvitation = useCancelInvitation() + + const canManage = hasPermission(userRole, 'staff:manage') + const canInvite = hasPermission(userRole, 'staff:invite') + const canDelete = hasPermission(userRole, 'staff:delete') + + const handleStatusChange = async (id: string, status: StaffStatus) => { + try { + await updateStaff.mutateAsync({ id, data: { status } }) + } catch { + // Error handled by mutation + } + } + + const handleRoleChange = async (id: string, role: StaffRole) => { + try { + await updateStaff.mutateAsync({ id, data: { role } }) + } catch { + // Error handled by mutation + } + } + + const handleDeleteStaff = async () => { + if (!staffToDelete) return + try { + await deleteStaff.mutateAsync(staffToDelete) + setStaffToDelete(null) + } catch { + // Error handled by mutation + } + } + + const handleCancelInvite = async () => { + if (!inviteToCancel) return + try { + await cancelInvitation.mutateAsync(inviteToCancel) + setInviteToCancel(null) + } catch { + // Error handled by mutation + } + } + + return ( +
+
+
+

Staff Management

+

+ Manage team members and their permissions +

+
+ {canInvite && ( + + )} +
+ + + + + Staff Members ({staffData?.pagination.total || 0}) + + + Pending Invites ({invitesData?.total || 0}) + + + + + + +
+
+ + setSearch(e.target.value)} + className="pl-9" + /> +
+
+
+ + {staffLoading ? ( +
+ Loading... +
+ ) : !staffData?.staff.length ? ( +
+ No staff members found +
+ ) : ( + + + + Name + Email + Role + Status + 2FA + Joined + + + + + {staffData.staff.map((staff) => { + const RoleIcon = roleIcons[staff.role] + return ( + + + {staff.name || 'No name'} + {staff.isCurrentUser && ( + + You + + )} + + {staff.email} + + {canManage && !staff.isCurrentUser && staff.role !== 'OWNER' ? ( + + ) : ( + + + {staff.role} + + )} + + + + {staff.status} + + + + {staff.twoFactorEnabled ? ( + + Enabled + + ) : ( + + Disabled + + )} + + + {new Date(staff.createdAt).toLocaleDateString()} + + + {!staff.isCurrentUser && canManage && ( + + + + + + {staff.status === 'ACTIVE' ? ( + + handleStatusChange(staff.id, 'SUSPENDED') + } + > + + Suspend + + ) : ( + + handleStatusChange(staff.id, 'ACTIVE') + } + > + + Activate + + )} + {canDelete && staff.role !== 'OWNER' && ( + <> + + setStaffToDelete(staff.id)} + > + + Delete + + + )} + + + )} + + + ) + })} + +
+ )} +
+
+
+ + + + + Pending Invitations + + Staff members who have been invited but haven't created their account yet + + + + {invitesLoading ? ( +
+ Loading... +
+ ) : !invitesData?.invitations.length ? ( +
+ No pending invitations +
+ ) : ( + + + + Email + Role + Invited By + Expires + Status + + + + + {invitesData.invitations.map((invite) => ( + + + {invite.email} + + + + {invite.role} + + + + {invite.invitedByStaff?.name || invite.invitedByStaff?.email || 'Unknown'} + + +
+ + {new Date(invite.expiresAt).toLocaleDateString()} +
+
+ + {invite.isExpired ? ( + Expired + ) : ( + Pending + )} + + + {canInvite && ( + + )} + +
+ ))} +
+
+ )} +
+
+
+
+ + {/* Invite Dialog */} + + + {/* Delete Staff Confirmation */} + setStaffToDelete(null)} + > + + + Delete Staff Member + + Are you sure you want to delete this staff member? This action + cannot be undone. + + + + Cancel + + Delete + + + + + + {/* Cancel Invite Confirmation */} + setInviteToCancel(null)} + > + + + Cancel Invitation + + Are you sure you want to cancel this invitation? The invite link + will no longer work. + + + + Keep Invite + + Cancel Invite + + + + +
+ ) +} diff --git a/src/app/api/cron/cleanup-stats/route.ts b/src/app/api/cron/cleanup-stats/route.ts new file mode 100644 index 0000000..eb78e3e --- /dev/null +++ b/src/app/api/cron/cleanup-stats/route.ts @@ -0,0 +1,56 @@ +import { NextRequest, NextResponse } from 'next/server' +import { statsCollectionService } from '@/lib/services/stats-collection-service' + +/** + * GET /api/cron/cleanup-stats + * Delete stats snapshots older than 90 days + * + * This endpoint is designed to be called by a daily cron job. + * It should be protected by a secret token in production. + * + * Example cron schedule: Once per day at 3am + * Vercel cron config in vercel.json: + * { + * "crons": [ + * { "path": "/api/cron/cleanup-stats", "schedule": "0 3 * * *" } + * ] + * } + */ +export async function GET(request: NextRequest) { + // Verify cron secret (for security in production) + const cronSecret = process.env.CRON_SECRET + const authHeader = request.headers.get('authorization') + + if (cronSecret && authHeader !== `Bearer ${cronSecret}`) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + try { + const startTime = Date.now() + const deletedCount = await statsCollectionService.cleanupOldSnapshots() + const duration = Date.now() - startTime + + return NextResponse.json({ + success: true, + message: `Cleaned up ${deletedCount} old stats snapshots`, + deleted: deletedCount, + duration: `${duration}ms`, + retentionDays: 90, + timestamp: new Date().toISOString() + }) + } catch (error) { + console.error('Cron job failed - cleanup-stats:', error) + return NextResponse.json( + { + error: 'Stats cleanup failed', + message: error instanceof Error ? error.message : 'Unknown error' + }, + { status: 500 } + ) + } +} + +// Also support POST for flexibility +export async function POST(request: NextRequest) { + return GET(request) +} diff --git a/src/app/api/cron/collect-stats/route.ts b/src/app/api/cron/collect-stats/route.ts new file mode 100644 index 0000000..d4b0b80 --- /dev/null +++ b/src/app/api/cron/collect-stats/route.ts @@ -0,0 +1,110 @@ +import { NextRequest, NextResponse } from 'next/server' +import { statsCollectionService } from '@/lib/services/stats-collection-service' +import { logScanningService } from '@/lib/services/log-scanning-service' +import { containerHealthService } from '@/lib/services/container-health-service' + +/** + * GET /api/cron/collect-stats + * Collect stats, scan logs for errors, and check container health for all enterprise servers + * + * This endpoint is designed to be called by a cron job or scheduled task. + * It should be protected by a secret token in production. + * + * Example cron schedule: Every 5 minutes + * Vercel cron config in vercel.json: + * { + * "crons": [ + * { "path": "/api/cron/collect-stats", "schedule": "*\/5 * * * *" } + * ] + * } + * + * What this cron does: + * 1. Collects performance stats from Netcup + Portainer for all active servers + * 2. Scans container logs for errors matching client-defined rules + * 3. Checks container health and detects crashes/OOM kills + */ +export async function GET(request: NextRequest) { + // Verify cron secret (for security in production) + const cronSecret = process.env.CRON_SECRET + const authHeader = request.headers.get('authorization') + + if (cronSecret && authHeader !== `Bearer ${cronSecret}`) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const startTime = Date.now() + const results = { + stats: { collected: 0, failed: 0 }, + logScan: { totalServers: 0, scannedServers: 0, failedServers: 0, totalErrorsFound: 0, duration: 0 }, + healthCheck: { totalServers: 0, checkedServers: 0, failedServers: 0, eventsDetected: 0, crashes: 0, oomKills: 0, duration: 0 }, + errors: [] as string[], + } + + // 1. Collect performance stats (existing functionality) + try { + results.stats = await statsCollectionService.collectAllStats() + console.log(`[Cron] Stats collection: ${results.stats.collected} servers, ${results.stats.failed} failed`) + } catch (error) { + console.error('[Cron] Stats collection failed:', error) + results.errors.push(`Stats collection: ${error instanceof Error ? error.message : 'Unknown error'}`) + } + + // 2. Scan container logs for errors + try { + results.logScan = await logScanningService.scanAllServers() + console.log(`[Cron] Log scan: ${results.logScan.scannedServers}/${results.logScan.totalServers} servers, ${results.logScan.totalErrorsFound} errors found`) + } catch (error) { + console.error('[Cron] Log scanning failed:', error) + results.errors.push(`Log scanning: ${error instanceof Error ? error.message : 'Unknown error'}`) + } + + // 3. Check container health (crash detection) + try { + results.healthCheck = await containerHealthService.checkAllServers() + console.log(`[Cron] Health check: ${results.healthCheck.checkedServers}/${results.healthCheck.totalServers} servers, ${results.healthCheck.eventsDetected} events detected`) + } catch (error) { + console.error('[Cron] Health check failed:', error) + results.errors.push(`Health check: ${error instanceof Error ? error.message : 'Unknown error'}`) + } + + const totalDuration = Date.now() - startTime + const hasErrors = results.errors.length > 0 + + return NextResponse.json({ + success: !hasErrors, + timestamp: new Date().toISOString(), + duration: `${totalDuration}ms`, + + // Stats collection results + stats: { + serversCollected: results.stats.collected, + serversFailed: results.stats.failed, + }, + + // Log scanning results + logScan: { + serversScanned: results.logScan.scannedServers, + serversFailed: results.logScan.failedServers, + errorsFound: results.logScan.totalErrorsFound, + duration: `${results.logScan.duration}ms`, + }, + + // Health check results + healthCheck: { + serversChecked: results.healthCheck.checkedServers, + serversFailed: results.healthCheck.failedServers, + eventsDetected: results.healthCheck.eventsDetected, + crashes: results.healthCheck.crashes, + oomKills: results.healthCheck.oomKills, + duration: `${results.healthCheck.duration}ms`, + }, + + // Any errors that occurred + ...(hasErrors && { errors: results.errors }), + }, { status: hasErrors ? 207 : 200 }) // 207 Multi-Status if partial failure +} + +// Also support POST for flexibility +export async function POST(request: NextRequest) { + return GET(request) +} diff --git a/src/app/api/v1/admin/analytics/route.ts b/src/app/api/v1/admin/analytics/route.ts new file mode 100644 index 0000000..128459a --- /dev/null +++ b/src/app/api/v1/admin/analytics/route.ts @@ -0,0 +1,313 @@ +import { NextRequest, NextResponse } from 'next/server' +import { requireStaffPermission } from '@/lib/auth-helpers' +import { prisma } from '@/lib/prisma' +import { OrderStatus, SubscriptionPlan, SubscriptionTier, UserStatus, SubscriptionStatus } from '@prisma/client' + +type TimeRange = '7d' | '30d' | '90d' + +function getDateRange(range: TimeRange): Date { + const now = new Date() + switch (range) { + case '7d': + return new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000) + case '30d': + return new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000) + case '90d': + return new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000) + default: + return new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000) + } +} + +function getPreviousRange(range: TimeRange): { start: Date; end: Date } { + const now = new Date() + const currentStart = getDateRange(range) + const duration = now.getTime() - currentStart.getTime() + return { + start: new Date(currentStart.getTime() - duration), + end: currentStart, + } +} + +/** + * GET /api/v1/admin/analytics + * Get comprehensive analytics data for the dashboard + * Query params: range=7d|30d|90d (default: 30d) + */ +export async function GET(request: NextRequest) { + try { + await requireStaffPermission('dashboard:view') + + const searchParams = request.nextUrl.searchParams + const range = (searchParams.get('range') || '30d') as TimeRange + const startDate = getDateRange(range) + const previousRange = getPreviousRange(range) + + // === OVERVIEW METRICS === + + // Total orders (all time) + const totalOrders = await prisma.order.count() + + // Orders in current period + const currentPeriodOrders = await prisma.order.count({ + where: { createdAt: { gte: startDate } }, + }) + + // Orders in previous period + const previousPeriodOrders = await prisma.order.count({ + where: { + createdAt: { + gte: previousRange.start, + lt: previousRange.end, + }, + }, + }) + + // Active customers + const activeCustomers = await prisma.user.count({ + where: { status: UserStatus.ACTIVE }, + }) + + // Current period new customers + const currentPeriodCustomers = await prisma.user.count({ + where: { createdAt: { gte: startDate } }, + }) + + // Previous period new customers + const previousPeriodCustomers = await prisma.user.count({ + where: { + createdAt: { + gte: previousRange.start, + lt: previousRange.end, + }, + }, + }) + + // Active subscriptions + const activeSubscriptions = await prisma.subscription.count({ + where: { status: { in: [SubscriptionStatus.ACTIVE, SubscriptionStatus.TRIAL] } }, + }) + + // Success rate (fulfilled / (fulfilled + failed)) + const fulfilledOrders = await prisma.order.count({ + where: { status: { in: [OrderStatus.FULFILLED, OrderStatus.EMAIL_CONFIGURED] } }, + }) + const failedOrders = await prisma.order.count({ + where: { status: OrderStatus.FAILED }, + }) + const successRate = fulfilledOrders + failedOrders > 0 + ? (fulfilledOrders / (fulfilledOrders + failedOrders)) * 100 + : 100 + + // === ORDERS BY DAY === + const ordersByDay = await prisma.$queryRaw<{ date: Date; count: bigint }[]>` + SELECT DATE(created_at) as date, COUNT(*) as count + FROM orders + WHERE created_at >= ${startDate} + GROUP BY DATE(created_at) + ORDER BY date ASC + ` + + // === ORDERS BY STATUS === + const ordersByStatus = await prisma.order.groupBy({ + by: ['status'], + _count: { status: true }, + }) + + const statusCounts: Record = {} + Object.values(OrderStatus).forEach((status) => { + statusCounts[status] = 0 + }) + ordersByStatus.forEach((item) => { + statusCounts[item.status] = item._count.status + }) + + // === CUSTOMER GROWTH BY DAY === + const customerGrowth = await prisma.$queryRaw<{ date: Date; count: bigint }[]>` + SELECT DATE(created_at) as date, COUNT(*) as count + FROM users + WHERE created_at >= ${startDate} + GROUP BY DATE(created_at) + ORDER BY date ASC + ` + + // === SUBSCRIPTIONS BY PLAN === + const subscriptionsByPlan = await prisma.subscription.groupBy({ + by: ['plan'], + _count: { plan: true }, + }) + + const planCounts: Record = {} + Object.values(SubscriptionPlan).forEach((plan) => { + planCounts[plan] = 0 + }) + subscriptionsByPlan.forEach((item) => { + planCounts[item.plan] = item._count.plan + }) + + // === SUBSCRIPTIONS BY TIER === + const subscriptionsByTier = await prisma.subscription.groupBy({ + by: ['tier'], + _count: { tier: true }, + }) + + const tierCounts: Record = {} + Object.values(SubscriptionTier).forEach((tier) => { + tierCounts[tier] = 0 + }) + subscriptionsByTier.forEach((item) => { + tierCounts[item.tier] = item._count.tier + }) + + // === TOKEN USAGE BY DAY === + const tokenUsageByDay = await prisma.$queryRaw<{ date: Date; tokens: bigint }[]>` + SELECT DATE(created_at) as date, + SUM(tokens_input + tokens_output) as tokens + FROM token_usage + WHERE created_at >= ${startDate} + GROUP BY DATE(created_at) + ORDER BY date ASC + ` + + // === TOKEN USAGE BY OPERATION === + const tokensByOperation = await prisma.$queryRaw<{ operation: string; tokens: bigint }[]>` + SELECT operation, SUM(tokens_input + tokens_output) as tokens + FROM token_usage + WHERE created_at >= ${startDate} + GROUP BY operation + ORDER BY tokens DESC + ` + + // === TOP TOKEN CONSUMERS === + const topConsumers = await prisma.$queryRaw<{ userId: string; tokens: bigint }[]>` + SELECT user_id as "userId", SUM(tokens_input + tokens_output) as tokens + FROM token_usage + WHERE created_at >= ${startDate} + GROUP BY user_id + ORDER BY tokens DESC + LIMIT 10 + ` + + // Get customer names for top consumers + const consumerIds = topConsumers.map((c) => c.userId) + const consumers = await prisma.user.findMany({ + where: { id: { in: consumerIds } }, + select: { id: true, name: true, email: true, company: true }, + }) + + const consumerMap = new Map(consumers.map((c) => [c.id, c])) + const topConsumersWithNames = topConsumers.map((c) => { + const user = consumerMap.get(c.userId) + return { + userId: c.userId, + name: user?.name || user?.company || user?.email || 'Unknown', + tokens: Number(c.tokens), + } + }) + + // === PROVISIONING METRICS === + + // Recent failures + const recentFailures = await prisma.order.findMany({ + where: { + status: OrderStatus.FAILED, + updatedAt: { gte: startDate }, + }, + select: { + id: true, + domain: true, + updatedAt: true, + provisioningLogs: { + where: { level: 'ERROR' }, + orderBy: { timestamp: 'desc' }, + take: 1, + select: { message: true }, + }, + }, + orderBy: { updatedAt: 'desc' }, + take: 10, + }) + + // Orders by automation mode + const ordersByAutomation = await prisma.order.groupBy({ + by: ['automationMode'], + _count: { automationMode: true }, + }) + + const automationCounts: Record = { + AUTO: 0, + MANUAL: 0, + PAUSED: 0, + } + ordersByAutomation.forEach((item) => { + automationCounts[item.automationMode] = item._count.automationMode + }) + + // Calculate trends + const ordersTrend = previousPeriodOrders > 0 + ? ((currentPeriodOrders - previousPeriodOrders) / previousPeriodOrders) * 100 + : currentPeriodOrders > 0 ? 100 : 0 + + const customersTrend = previousPeriodCustomers > 0 + ? ((currentPeriodCustomers - previousPeriodCustomers) / previousPeriodCustomers) * 100 + : currentPeriodCustomers > 0 ? 100 : 0 + + return NextResponse.json({ + range, + overview: { + totalOrders, + ordersTrend: Math.round(ordersTrend * 10) / 10, + activeCustomers, + customersTrend: Math.round(customersTrend * 10) / 10, + activeSubscriptions, + successRate: Math.round(successRate * 10) / 10, + }, + orders: { + byDay: ordersByDay.map((row) => ({ + date: row.date.toISOString().split('T')[0], + count: Number(row.count), + })), + byStatus: statusCounts, + }, + customers: { + growthByDay: customerGrowth.map((row) => ({ + date: row.date.toISOString().split('T')[0], + count: Number(row.count), + })), + byPlan: planCounts, + byTier: tierCounts, + }, + tokens: { + usageByDay: tokenUsageByDay.map((row) => ({ + date: row.date.toISOString().split('T')[0], + tokens: Number(row.tokens), + })), + byOperation: tokensByOperation.map((row) => ({ + operation: row.operation, + tokens: Number(row.tokens), + })), + topConsumers: topConsumersWithNames, + }, + provisioning: { + successRate: Math.round(successRate * 10) / 10, + byAutomation: automationCounts, + recentFailures: recentFailures.map((order) => ({ + orderId: order.id, + domain: order.domain, + date: order.updatedAt.toISOString(), + reason: order.provisioningLogs[0]?.message || 'Unknown error', + })), + }, + }) + } catch (error) { + if (typeof error === 'object' && error !== null && 'status' in error) { + const err = error as { status: number; message: string } + return NextResponse.json({ error: err.message }, { status: err.status }) + } + console.error('Error fetching analytics:', error) + return NextResponse.json( + { error: 'Failed to fetch analytics' }, + { status: 500 } + ) + } +} diff --git a/src/app/api/v1/admin/customers/[id]/route.ts b/src/app/api/v1/admin/customers/[id]/route.ts index e84dafa..7d7c118 100644 --- a/src/app/api/v1/admin/customers/[id]/route.ts +++ b/src/app/api/v1/admin/customers/[id]/route.ts @@ -148,3 +148,97 @@ export async function PATCH( ) } } + +/** + * DELETE /api/v1/admin/customers/[id] + * Delete a customer and all related records (orders, subscriptions, token usage) + * Does NOT touch any actual servers - just removes from Hub database + */ +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await auth() + + if (!session || session.user.userType !== 'staff') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: customerId } = await params + + // Find existing customer with their orders + const existingCustomer = await prisma.user.findUnique({ + where: { id: customerId }, + include: { + orders: { + include: { + dnsVerification: true, + }, + }, + }, + }) + + if (!existingCustomer) { + return NextResponse.json({ error: 'Customer not found' }, { status: 404 }) + } + + // Note: Staff users are in a separate table, so this endpoint only handles customers + + // Delete in correct order to respect foreign key constraints + // 1. For each order, delete related records + for (const order of existingCustomer.orders) { + // Delete DNS records and verification + if (order.dnsVerification) { + await prisma.dnsRecord.deleteMany({ + where: { dnsVerificationId: order.dnsVerification.id }, + }) + await prisma.dnsVerification.delete({ + where: { id: order.dnsVerification.id }, + }) + } + + // Delete provisioning logs + await prisma.provisioningLog.deleteMany({ + where: { orderId: order.id }, + }) + + // Delete jobs + await prisma.provisioningJob.deleteMany({ + where: { orderId: order.id }, + }) + } + + // 2. Delete all orders + await prisma.order.deleteMany({ + where: { userId: customerId }, + }) + + // 3. Delete subscriptions + await prisma.subscription.deleteMany({ + where: { userId: customerId }, + }) + + // 4. Delete token usage records + await prisma.tokenUsage.deleteMany({ + where: { userId: customerId }, + }) + + // 5. Delete the customer/user + await prisma.user.delete({ + where: { id: customerId }, + }) + + return NextResponse.json({ + success: true, + message: `Customer ${existingCustomer.email} and all related records deleted`, + deletedOrders: existingCustomer.orders.length, + }) + } catch (error) { + console.error('Error deleting customer:', error) + return NextResponse.json( + { error: 'Failed to delete customer' }, + { status: 500 } + ) + } +} diff --git a/src/app/api/v1/admin/customers/route.ts b/src/app/api/v1/admin/customers/route.ts index cf6e354..3556cbe 100644 --- a/src/app/api/v1/admin/customers/route.ts +++ b/src/app/api/v1/admin/customers/route.ts @@ -2,6 +2,14 @@ import { NextRequest, NextResponse } from 'next/server' import { auth } from '@/lib/auth' import { prisma } from '@/lib/prisma' import { UserStatus, Prisma } from '@prisma/client' +import bcrypt from 'bcryptjs' + +interface CreateCustomerRequest { + email: string + name?: string + company?: string + status?: UserStatus +} /** * GET /api/v1/admin/customers @@ -86,3 +94,84 @@ export async function GET(request: NextRequest) { ) } } + +/** + * POST /api/v1/admin/customers + * Create a new customer + */ +export async function POST(request: NextRequest) { + try { + const session = await auth() + + if (!session || session.user.userType !== 'staff') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const body: CreateCustomerRequest = await request.json() + + // Validate required fields + if (!body.email) { + return NextResponse.json( + { error: 'Email is required' }, + { status: 400 } + ) + } + + // Check if email already exists + const existingUser = await prisma.user.findUnique({ + where: { email: body.email }, + }) + + if (existingUser) { + return NextResponse.json( + { error: 'A customer with this email already exists' }, + { status: 409 } + ) + } + + // Generate a random password (customer will need to reset it) + const tempPassword = Math.random().toString(36).slice(-12) + const passwordHash = await bcrypt.hash(tempPassword, 10) + + // Create the customer + const customer = await prisma.user.create({ + data: { + email: body.email, + name: body.name || null, + company: body.company || null, + status: body.status || 'PENDING_VERIFICATION', + passwordHash, + }, + select: { + id: true, + email: true, + name: true, + company: true, + status: true, + createdAt: true, + subscriptions: { + select: { + id: true, + plan: true, + tier: true, + status: true, + }, + }, + _count: { + select: { + orders: true, + subscriptions: true, + }, + }, + }, + }) + + return NextResponse.json(customer, { status: 201 }) + } catch (error) { + console.error('Error creating customer:', error) + return NextResponse.json( + { error: 'Failed to create customer' }, + { status: 500 } + ) + } +} diff --git a/src/app/api/v1/admin/enterprise-clients/[id]/container-events/route.ts b/src/app/api/v1/admin/enterprise-clients/[id]/container-events/route.ts new file mode 100644 index 0000000..0088cd1 --- /dev/null +++ b/src/app/api/v1/admin/enterprise-clients/[id]/container-events/route.ts @@ -0,0 +1,124 @@ +import { NextRequest, NextResponse } from 'next/server' +import { auth } from '@/lib/auth' +import { prisma } from '@/lib/prisma' +import { containerHealthService } from '@/lib/services/container-health-service' +import type { ContainerEventType } from '@prisma/client' + +// GET /api/v1/admin/enterprise-clients/[id]/container-events +// List container events (crashes, restarts, etc.) for a client +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const session = await auth() + if (!session || session.user.userType !== 'staff') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: clientId } = await params + const { searchParams } = new URL(request.url) + + // Verify client exists + const client = await prisma.enterpriseClient.findUnique({ + where: { id: clientId }, + }) + + if (!client) { + return NextResponse.json({ error: 'Client not found' }, { status: 404 }) + } + + // Parse filters + const eventType = searchParams.get('type') as ContainerEventType | undefined + const serverId = searchParams.get('serverId') || undefined + const limit = parseInt(searchParams.get('limit') || '50', 10) + const offset = parseInt(searchParams.get('offset') || '0', 10) + + try { + const result = await containerHealthService.getUnacknowledgedEvents(clientId, { + eventType, + serverId, + limit: Math.min(limit, 200), + offset, + }) + + return NextResponse.json({ + events: result.events, + total: result.total, + pagination: { + limit, + offset, + hasMore: offset + result.events.length < result.total, + }, + }) + } catch (error) { + console.error('Failed to get container events:', error) + return NextResponse.json( + { error: 'Failed to get container events' }, + { status: 500 } + ) + } +} + +// POST /api/v1/admin/enterprise-clients/[id]/container-events +// Acknowledge container events +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const session = await auth() + if (!session || session.user.userType !== 'staff') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: clientId } = await params + + // Verify client exists + const client = await prisma.enterpriseClient.findUnique({ + where: { id: clientId }, + }) + + if (!client) { + return NextResponse.json({ error: 'Client not found' }, { status: 404 }) + } + + try { + const body = await request.json() + const { eventIds } = body as { eventIds: string[] } + + if (!eventIds || !Array.isArray(eventIds) || eventIds.length === 0) { + return NextResponse.json( + { error: 'eventIds array is required' }, + { status: 400 } + ) + } + + // Verify events belong to this client + const events = await prisma.containerEvent.findMany({ + where: { + id: { in: eventIds }, + server: { clientId }, + }, + }) + + if (events.length !== eventIds.length) { + return NextResponse.json( + { error: 'Some events not found or do not belong to this client' }, + { status: 400 } + ) + } + + const userId = session.user.email || 'unknown' + const acknowledgedCount = await containerHealthService.acknowledgeEvents(eventIds, userId) + + return NextResponse.json({ + success: true, + acknowledged: acknowledgedCount, + }) + } catch (error) { + console.error('Failed to acknowledge container events:', error) + return NextResponse.json( + { error: 'Failed to acknowledge container events' }, + { status: 500 } + ) + } +} diff --git a/src/app/api/v1/admin/enterprise-clients/[id]/error-dashboard/route.ts b/src/app/api/v1/admin/enterprise-clients/[id]/error-dashboard/route.ts new file mode 100644 index 0000000..d8d9a2b --- /dev/null +++ b/src/app/api/v1/admin/enterprise-clients/[id]/error-dashboard/route.ts @@ -0,0 +1,38 @@ +import { NextRequest, NextResponse } from 'next/server' +import { auth } from '@/lib/auth' +import { prisma } from '@/lib/prisma' +import { errorDashboardService } from '@/lib/services/error-dashboard-service' + +// GET /api/v1/admin/enterprise-clients/[id]/error-dashboard +// Get aggregated error dashboard data for a client +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const session = await auth() + if (!session || session.user.userType !== 'staff') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: clientId } = await params + + // Verify client exists + const client = await prisma.enterpriseClient.findUnique({ + where: { id: clientId }, + }) + + if (!client) { + return NextResponse.json({ error: 'Client not found' }, { status: 404 }) + } + + try { + const dashboard = await errorDashboardService.getClientDashboard(clientId) + return NextResponse.json(dashboard) + } catch (error) { + console.error('Failed to get error dashboard:', error) + return NextResponse.json( + { error: 'Failed to get error dashboard' }, + { status: 500 } + ) + } +} diff --git a/src/app/api/v1/admin/enterprise-clients/[id]/error-rules/[ruleId]/route.ts b/src/app/api/v1/admin/enterprise-clients/[id]/error-rules/[ruleId]/route.ts new file mode 100644 index 0000000..e7ca0d6 --- /dev/null +++ b/src/app/api/v1/admin/enterprise-clients/[id]/error-rules/[ruleId]/route.ts @@ -0,0 +1,137 @@ +import { NextRequest, NextResponse } from 'next/server' +import { auth } from '@/lib/auth' +import { prisma } from '@/lib/prisma' +import { + getErrorRule, + updateErrorRule, + deleteErrorRule, +} from '@/lib/services/error-detection-service' +import type { ErrorSeverity } from '@prisma/client' + +// GET /api/v1/admin/enterprise-clients/[id]/error-rules/[ruleId] +// Get a specific error detection rule +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string; ruleId: string }> } +) { + const session = await auth() + if (!session || session.user.userType !== 'staff') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: clientId, ruleId } = await params + + // Verify rule belongs to client + const rule = await prisma.errorDetectionRule.findFirst({ + where: { + id: ruleId, + clientId, + }, + }) + + if (!rule) { + return NextResponse.json({ error: 'Rule not found' }, { status: 404 }) + } + + try { + const ruleWithCount = await getErrorRule(ruleId) + return NextResponse.json(ruleWithCount) + } catch (error) { + console.error('Failed to get error rule:', error) + return NextResponse.json( + { error: 'Failed to get error rule' }, + { status: 500 } + ) + } +} + +// PATCH /api/v1/admin/enterprise-clients/[id]/error-rules/[ruleId] +// Update an error detection rule +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ id: string; ruleId: string }> } +) { + const session = await auth() + if (!session || session.user.userType !== 'staff') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: clientId, ruleId } = await params + const body = await request.json() + + // Verify rule belongs to client + const existingRule = await prisma.errorDetectionRule.findFirst({ + where: { + id: ruleId, + clientId, + }, + }) + + if (!existingRule) { + return NextResponse.json({ error: 'Rule not found' }, { status: 404 }) + } + + // Validate severity if provided + const validSeverities: ErrorSeverity[] = ['INFO', 'WARNING', 'ERROR', 'CRITICAL'] + if (body.severity && !validSeverities.includes(body.severity)) { + return NextResponse.json( + { error: 'Invalid severity. Must be one of: INFO, WARNING, ERROR, CRITICAL' }, + { status: 400 } + ) + } + + try { + const rule = await updateErrorRule(ruleId, { + name: body.name, + pattern: body.pattern, + severity: body.severity, + description: body.description, + isActive: body.isActive, + }) + + return NextResponse.json(rule) + } catch (error) { + console.error('Failed to update error rule:', error) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to update error rule' }, + { status: 400 } + ) + } +} + +// DELETE /api/v1/admin/enterprise-clients/[id]/error-rules/[ruleId] +// Delete an error detection rule +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string; ruleId: string }> } +) { + const session = await auth() + if (!session || session.user.userType !== 'staff') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: clientId, ruleId } = await params + + // Verify rule belongs to client + const rule = await prisma.errorDetectionRule.findFirst({ + where: { + id: ruleId, + clientId, + }, + }) + + if (!rule) { + return NextResponse.json({ error: 'Rule not found' }, { status: 404 }) + } + + try { + await deleteErrorRule(ruleId) + return NextResponse.json({ success: true }) + } catch (error) { + console.error('Failed to delete error rule:', error) + return NextResponse.json( + { error: 'Failed to delete error rule' }, + { status: 500 } + ) + } +} diff --git a/src/app/api/v1/admin/enterprise-clients/[id]/error-rules/route.ts b/src/app/api/v1/admin/enterprise-clients/[id]/error-rules/route.ts new file mode 100644 index 0000000..66d445f --- /dev/null +++ b/src/app/api/v1/admin/enterprise-clients/[id]/error-rules/route.ts @@ -0,0 +1,120 @@ +import { NextRequest, NextResponse } from 'next/server' +import { auth } from '@/lib/auth' +import { prisma } from '@/lib/prisma' +import { + getErrorRules, + createErrorRule, + seedDefaultRules, +} from '@/lib/services/error-detection-service' +import type { ErrorSeverity } from '@prisma/client' + +// GET /api/v1/admin/enterprise-clients/[id]/error-rules +// List all error detection rules for a client +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const session = await auth() + if (!session || session.user.userType !== 'staff') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: clientId } = await params + + // Verify client exists + const client = await prisma.enterpriseClient.findUnique({ + where: { id: clientId }, + }) + + if (!client) { + return NextResponse.json({ error: 'Client not found' }, { status: 404 }) + } + + try { + const rules = await getErrorRules(clientId) + return NextResponse.json(rules) + } catch (error) { + console.error('Failed to get error rules:', error) + return NextResponse.json( + { error: 'Failed to get error rules' }, + { status: 500 } + ) + } +} + +// POST /api/v1/admin/enterprise-clients/[id]/error-rules +// Create a new error detection rule +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const session = await auth() + if (!session || session.user.userType !== 'staff') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: clientId } = await params + const body = await request.json() + + // Verify client exists + const client = await prisma.enterpriseClient.findUnique({ + where: { id: clientId }, + }) + + if (!client) { + return NextResponse.json({ error: 'Client not found' }, { status: 404 }) + } + + // Check if this is a request to seed default rules + if (body.seedDefaults === true) { + try { + const count = await seedDefaultRules(clientId) + return NextResponse.json({ + success: true, + message: `Seeded ${count} default rules`, + count, + }) + } catch (error) { + console.error('Failed to seed default rules:', error) + return NextResponse.json( + { error: 'Failed to seed default rules' }, + { status: 500 } + ) + } + } + + // Validate required fields + if (!body.name || typeof body.name !== 'string') { + return NextResponse.json({ error: 'Name is required' }, { status: 400 }) + } + + if (!body.pattern || typeof body.pattern !== 'string') { + return NextResponse.json({ error: 'Pattern is required' }, { status: 400 }) + } + + // Validate severity if provided + const validSeverities: ErrorSeverity[] = ['INFO', 'WARNING', 'ERROR', 'CRITICAL'] + if (body.severity && !validSeverities.includes(body.severity)) { + return NextResponse.json( + { error: 'Invalid severity. Must be one of: INFO, WARNING, ERROR, CRITICAL' }, + { status: 400 } + ) + } + + try { + const rule = await createErrorRule(clientId, { + name: body.name, + pattern: body.pattern, + severity: body.severity, + description: body.description, + }) + + return NextResponse.json(rule, { status: 201 }) + } catch (error) { + console.error('Failed to create error rule:', error) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to create error rule' }, + { status: 400 } + ) + } +} diff --git a/src/app/api/v1/admin/enterprise-clients/[id]/errors/[errorId]/acknowledge/route.ts b/src/app/api/v1/admin/enterprise-clients/[id]/errors/[errorId]/acknowledge/route.ts new file mode 100644 index 0000000..b70b49f --- /dev/null +++ b/src/app/api/v1/admin/enterprise-clients/[id]/errors/[errorId]/acknowledge/route.ts @@ -0,0 +1,51 @@ +import { NextRequest, NextResponse } from 'next/server' +import { auth } from '@/lib/auth' +import { prisma } from '@/lib/prisma' +import { acknowledgeError } from '@/lib/services/error-detection-service' + +// POST /api/v1/admin/enterprise-clients/[id]/errors/[errorId]/acknowledge +// Acknowledge a detected error +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string; errorId: string }> } +) { + const session = await auth() + if (!session || session.user.userType !== 'staff') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: clientId, errorId } = await params + + // Verify error belongs to a server owned by this client + const error = await prisma.detectedError.findFirst({ + where: { + id: errorId, + server: { + clientId, + }, + }, + include: { + server: true, + }, + }) + + if (!error) { + return NextResponse.json({ error: 'Error not found' }, { status: 404 }) + } + + if (error.acknowledgedAt) { + return NextResponse.json({ error: 'Error already acknowledged' }, { status: 400 }) + } + + try { + const userId = session.user.id || 'unknown' + await acknowledgeError(errorId, userId) + return NextResponse.json({ success: true }) + } catch (err) { + console.error('Failed to acknowledge error:', err) + return NextResponse.json( + { error: 'Failed to acknowledge error' }, + { status: 500 } + ) + } +} diff --git a/src/app/api/v1/admin/enterprise-clients/[id]/errors/route.ts b/src/app/api/v1/admin/enterprise-clients/[id]/errors/route.ts new file mode 100644 index 0000000..212cde2 --- /dev/null +++ b/src/app/api/v1/admin/enterprise-clients/[id]/errors/route.ts @@ -0,0 +1,57 @@ +import { NextRequest, NextResponse } from 'next/server' +import { auth } from '@/lib/auth' +import { prisma } from '@/lib/prisma' +import { getDetectedErrors } from '@/lib/services/error-detection-service' +import type { ErrorSeverity } from '@prisma/client' + +// GET /api/v1/admin/enterprise-clients/[id]/errors +// List detected errors for a client +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const session = await auth() + if (!session || session.user.userType !== 'staff') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: clientId } = await params + const { searchParams } = new URL(request.url) + + // Verify client exists + const client = await prisma.enterpriseClient.findUnique({ + where: { id: clientId }, + }) + + if (!client) { + return NextResponse.json({ error: 'Client not found' }, { status: 404 }) + } + + // Parse filters + const serverId = searchParams.get('serverId') || undefined + const severity = searchParams.get('severity') as ErrorSeverity | undefined + const acknowledgedParam = searchParams.get('acknowledged') + const acknowledged = acknowledgedParam === 'true' ? true : acknowledgedParam === 'false' ? false : undefined + const ruleId = searchParams.get('ruleId') || undefined + const limit = parseInt(searchParams.get('limit') || '100', 10) + const offset = parseInt(searchParams.get('offset') || '0', 10) + + try { + const errors = await getDetectedErrors(clientId, { + serverId, + severity, + acknowledged, + ruleId, + limit: Math.min(limit, 500), // Cap at 500 + offset, + }) + + return NextResponse.json(errors) + } catch (error) { + console.error('Failed to get detected errors:', error) + return NextResponse.json( + { error: 'Failed to get detected errors' }, + { status: 500 } + ) + } +} diff --git a/src/app/api/v1/admin/enterprise-clients/[id]/notifications/route.ts b/src/app/api/v1/admin/enterprise-clients/[id]/notifications/route.ts new file mode 100644 index 0000000..88e4d98 --- /dev/null +++ b/src/app/api/v1/admin/enterprise-clients/[id]/notifications/route.ts @@ -0,0 +1,92 @@ +import { NextRequest, NextResponse } from 'next/server' +import { auth } from '@/lib/auth' +import { notificationService } from '@/lib/services/notification-service' +import { z } from 'zod' + +// Validation schema for updating notification settings +const updateNotificationSettingsSchema = z.object({ + enabled: z.boolean().optional(), + criticalErrorsOnly: z.boolean().optional(), + containerCrashes: z.boolean().optional(), + recipients: z.array(z.string().email()).optional(), + cooldownMinutes: z.number().min(5).max(1440).optional(), // 5 min to 24 hours +}) + +// GET /api/v1/admin/enterprise-clients/[id]/notifications +// Get notification settings for a client +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const session = await auth() + if (!session || session.user.userType !== 'staff') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: clientId } = await params + + try { + const settings = await notificationService.getNotificationSettings(clientId) + + return NextResponse.json({ + enabled: settings.enabled, + criticalErrorsOnly: settings.criticalErrorsOnly, + containerCrashes: settings.containerCrashes, + recipients: settings.recipients, + cooldownMinutes: settings.cooldownMinutes, + lastNotifiedAt: settings.lastNotifiedAt, + }) + } catch (error) { + console.error('[API] Error fetching notification settings:', error) + return NextResponse.json( + { error: 'Failed to fetch notification settings' }, + { status: 500 } + ) + } +} + +// PATCH /api/v1/admin/enterprise-clients/[id]/notifications +// Update notification settings for a client +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const session = await auth() + if (!session || session.user.userType !== 'staff') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: clientId } = await params + + try { + const body = await request.json() + const parsed = updateNotificationSettingsSchema.safeParse(body) + + if (!parsed.success) { + return NextResponse.json( + { error: 'Invalid request body', details: parsed.error.format() }, + { status: 400 } + ) + } + + const settings = await notificationService.updateNotificationSettings( + clientId, + parsed.data + ) + + return NextResponse.json({ + enabled: settings.enabled, + criticalErrorsOnly: settings.criticalErrorsOnly, + containerCrashes: settings.containerCrashes, + recipients: settings.recipients, + cooldownMinutes: settings.cooldownMinutes, + lastNotifiedAt: settings.lastNotifiedAt, + }) + } catch (error) { + console.error('[API] Error updating notification settings:', error) + return NextResponse.json( + { error: 'Failed to update notification settings' }, + { status: 500 } + ) + } +} diff --git a/src/app/api/v1/admin/enterprise-clients/[id]/route.ts b/src/app/api/v1/admin/enterprise-clients/[id]/route.ts new file mode 100644 index 0000000..70fe009 --- /dev/null +++ b/src/app/api/v1/admin/enterprise-clients/[id]/route.ts @@ -0,0 +1,131 @@ +import { NextRequest, NextResponse } from 'next/server' +import { auth } from '@/lib/auth' +import { enterpriseClientService } from '@/lib/services/enterprise-client-service' +import { z } from 'zod' + +interface RouteContext { + params: Promise<{ id: string }> +} + +const updateClientSchema = z.object({ + name: z.string().min(1).optional(), + companyName: z.string().optional().nullable(), + contactEmail: z.string().email().optional(), + contactPhone: z.string().optional().nullable(), + notes: z.string().optional().nullable(), + isActive: z.boolean().optional() +}) + +/** + * GET /api/v1/admin/enterprise-clients/[id] + * Get enterprise client details with servers and stats overview + */ +export async function GET( + request: NextRequest, + context: RouteContext +) { + const session = await auth() + if (!session || session.user.userType !== 'staff') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id } = await context.params + + try { + const [client, statsOverview] = await Promise.all([ + enterpriseClientService.getClient(id), + enterpriseClientService.getClientStatsOverview(id) + ]) + + if (!client) { + return NextResponse.json({ error: 'Client not found' }, { status: 404 }) + } + + return NextResponse.json({ + ...client, + statsOverview + }) + } catch (error) { + console.error('Failed to get enterprise client:', error) + return NextResponse.json( + { error: 'Failed to get enterprise client' }, + { status: 500 } + ) + } +} + +/** + * PATCH /api/v1/admin/enterprise-clients/[id] + * Update enterprise client + */ +export async function PATCH( + request: NextRequest, + context: RouteContext +) { + const session = await auth() + if (!session || session.user.userType !== 'staff') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id } = await context.params + + try { + const body = await request.json() + const validation = updateClientSchema.safeParse(body) + + if (!validation.success) { + return NextResponse.json( + { error: 'Validation failed', details: validation.error.flatten() }, + { status: 400 } + ) + } + + // Check if client exists + const existingClient = await enterpriseClientService.getClient(id) + if (!existingClient) { + return NextResponse.json({ error: 'Client not found' }, { status: 404 }) + } + + const client = await enterpriseClientService.updateClient(id, validation.data) + return NextResponse.json(client) + } catch (error) { + console.error('Failed to update enterprise client:', error) + return NextResponse.json( + { error: 'Failed to update enterprise client' }, + { status: 500 } + ) + } +} + +/** + * DELETE /api/v1/admin/enterprise-clients/[id] + * Delete enterprise client + */ +export async function DELETE( + request: NextRequest, + context: RouteContext +) { + const session = await auth() + if (!session || session.user.userType !== 'staff') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id } = await context.params + + try { + // Check if client exists + const existingClient = await enterpriseClientService.getClient(id) + if (!existingClient) { + return NextResponse.json({ error: 'Client not found' }, { status: 404 }) + } + + await enterpriseClientService.deleteClient(id) + return NextResponse.json({ success: true }) + } catch (error) { + console.error('Failed to delete enterprise client:', error) + return NextResponse.json( + { error: 'Failed to delete enterprise client' }, + { status: 500 } + ) + } +} diff --git a/src/app/api/v1/admin/enterprise-clients/[id]/servers/[serverId]/actions/route.ts b/src/app/api/v1/admin/enterprise-clients/[id]/servers/[serverId]/actions/route.ts new file mode 100644 index 0000000..49addbb --- /dev/null +++ b/src/app/api/v1/admin/enterprise-clients/[id]/servers/[serverId]/actions/route.ts @@ -0,0 +1,146 @@ +import { NextRequest, NextResponse } from 'next/server' +import { auth } from '@/lib/auth' +import { enterpriseClientService } from '@/lib/services/enterprise-client-service' +import { securityVerificationService } from '@/lib/services/security-verification-service' +import { netcupService } from '@/lib/services/netcup-service' +import { z } from 'zod' + +interface RouteContext { + params: Promise<{ id: string; serverId: string }> +} + +const powerActionSchema = z.object({ + action: z.literal('power'), + command: z.enum(['ON', 'OFF', 'POWERCYCLE', 'RESET', 'POWEROFF']) +}) + +const verifiedActionSchema = z.object({ + action: z.enum(['wipe', 'reinstall']), + verificationCode: z.string().length(6, 'Verification code must be 6 digits'), + imageId: z.string().optional() // Required for reinstall +}) + +const actionSchema = z.discriminatedUnion('action', [ + powerActionSchema, + verifiedActionSchema.extend({ action: z.literal('wipe') }), + verifiedActionSchema.extend({ action: z.literal('reinstall'), imageId: z.string() }) +]) + +/** + * POST /api/v1/admin/enterprise-clients/[id]/servers/[serverId]/actions + * Perform server action (power control or verified wipe/reinstall) + */ +export async function POST( + request: NextRequest, + context: RouteContext +) { + const session = await auth() + if (!session || session.user.userType !== 'staff') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: clientId, serverId } = await context.params + + try { + const body = await request.json() + const validation = actionSchema.safeParse(body) + + if (!validation.success) { + return NextResponse.json( + { error: 'Validation failed', details: validation.error.flatten() }, + { status: 400 } + ) + } + + // Check if server exists and belongs to client + const server = await enterpriseClientService.getServer(clientId, serverId) + if (!server) { + return NextResponse.json({ error: 'Server not found' }, { status: 404 }) + } + + const data = validation.data + + // Handle power actions (no verification needed) + if (data.action === 'power') { + await netcupService.powerAction(server.netcupServerId, data.command) + return NextResponse.json({ + success: true, + message: `Power action ${data.command} initiated` + }) + } + + // Handle verified actions (wipe/reinstall) + if (data.action === 'wipe' || data.action === 'reinstall') { + // Verify the code + const verifyResult = await securityVerificationService.verifyCode( + clientId, + data.verificationCode + ) + + if (!verifyResult.valid) { + return NextResponse.json( + { error: verifyResult.errorMessage || 'Invalid verification code' }, + { status: 400 } + ) + } + + // Ensure the code was for the correct action and server + if (verifyResult.action?.toLowerCase() !== data.action) { + return NextResponse.json( + { error: 'Verification code was issued for a different action' }, + { status: 400 } + ) + } + + if (verifyResult.serverId !== serverId) { + return NextResponse.json( + { error: 'Verification code was issued for a different server' }, + { status: 400 } + ) + } + + // Execute the action + if (data.action === 'reinstall' && 'imageId' in data) { + const task = await netcupService.reinstallServer( + server.netcupServerId, + data.imageId + ) + return NextResponse.json({ + success: true, + message: 'Server reinstall initiated', + taskId: task.taskId + }) + } else if (data.action === 'wipe') { + // Wipe is essentially a reinstall with the same image + // First get available images + const images = await netcupService.getImageFlavours(server.netcupServerId) + const defaultImage = images.find(img => img.name.toLowerCase().includes('debian')) || images[0] + + if (!defaultImage) { + return NextResponse.json( + { error: 'No image available for wipe operation' }, + { status: 400 } + ) + } + + const task = await netcupService.reinstallServer( + server.netcupServerId, + defaultImage.id + ) + return NextResponse.json({ + success: true, + message: 'Server wipe initiated', + taskId: task.taskId + }) + } + } + + return NextResponse.json({ error: 'Unknown action' }, { status: 400 }) + } catch (error) { + console.error('Failed to execute server action:', error) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to execute action' }, + { status: 500 } + ) + } +} diff --git a/src/app/api/v1/admin/enterprise-clients/[id]/servers/[serverId]/containers/[containerId]/logs/route.ts b/src/app/api/v1/admin/enterprise-clients/[id]/servers/[serverId]/containers/[containerId]/logs/route.ts new file mode 100644 index 0000000..9743193 --- /dev/null +++ b/src/app/api/v1/admin/enterprise-clients/[id]/servers/[serverId]/containers/[containerId]/logs/route.ts @@ -0,0 +1,60 @@ +import { NextRequest, NextResponse } from 'next/server' +import { auth } from '@/lib/auth' +import { prisma } from '@/lib/prisma' +import { createPortainerClientForServer } from '@/lib/services/portainer-client' + +// GET /api/v1/admin/enterprise-clients/[id]/servers/[serverId]/containers/[containerId]/logs +// Get container logs +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string; serverId: string; containerId: string }> } +) { + const session = await auth() + if (!session || session.user.userType !== 'staff') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: clientId, serverId, containerId } = await params + const { searchParams } = new URL(request.url) + const tail = parseInt(searchParams.get('tail') || '500', 10) + + // Verify server belongs to client + const server = await prisma.enterpriseServer.findFirst({ + where: { + id: serverId, + clientId, + }, + }) + + if (!server) { + return NextResponse.json( + { error: 'Server not found' }, + { status: 404 } + ) + } + + const portainerClient = await createPortainerClientForServer(serverId) + if (!portainerClient) { + return NextResponse.json( + { error: 'Portainer not configured for this server' }, + { status: 400 } + ) + } + + try { + const logs = await portainerClient.getContainerLogs(containerId, tail) + + return NextResponse.json({ + containerId, + tail, + logs, + timestamp: new Date().toISOString(), + }) + } catch (error) { + console.error('Failed to get container logs:', error) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to get container logs' }, + { status: 500 } + ) + } +} diff --git a/src/app/api/v1/admin/enterprise-clients/[id]/servers/[serverId]/containers/[containerId]/route.ts b/src/app/api/v1/admin/enterprise-clients/[id]/servers/[serverId]/containers/[containerId]/route.ts new file mode 100644 index 0000000..8be25ff --- /dev/null +++ b/src/app/api/v1/admin/enterprise-clients/[id]/servers/[serverId]/containers/[containerId]/route.ts @@ -0,0 +1,198 @@ +import { NextRequest, NextResponse } from 'next/server' +import { auth } from '@/lib/auth' +import { prisma } from '@/lib/prisma' +import { createPortainerClientForServer } from '@/lib/services/portainer-client' + +// GET /api/v1/admin/enterprise-clients/[id]/servers/[serverId]/containers/[containerId] +// Get container details +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string; serverId: string; containerId: string }> } +) { + const session = await auth() + if (!session || session.user.userType !== 'staff') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: clientId, serverId, containerId } = await params + + // Verify server belongs to client + const server = await prisma.enterpriseServer.findFirst({ + where: { + id: serverId, + clientId, + }, + }) + + if (!server) { + return NextResponse.json( + { error: 'Server not found' }, + { status: 404 } + ) + } + + const portainerClient = await createPortainerClientForServer(serverId) + if (!portainerClient) { + return NextResponse.json( + { error: 'Portainer not configured for this server' }, + { status: 400 } + ) + } + + try { + const container = await portainerClient.getContainer(containerId) + const stats = await portainerClient.getContainerStats(containerId) + + return NextResponse.json({ + id: container.Id, + name: container.Name.replace(/^\//, ''), + image: container.Image, + created: container.Created, + state: container.State, + config: { + hostname: container.Config.Hostname, + env: container.Config.Env, + image: container.Config.Image, + workingDir: container.Config.WorkingDir, + }, + hostConfig: { + restartPolicy: container.HostConfig.RestartPolicy, + }, + networkSettings: { + networks: container.NetworkSettings.Networks, + ports: container.NetworkSettings.Ports, + }, + stats, + }) + } catch (error) { + console.error('Failed to get container:', error) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to get container' }, + { status: 500 } + ) + } +} + +// DELETE /api/v1/admin/enterprise-clients/[id]/servers/[serverId]/containers/[containerId] +// Remove a container +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string; serverId: string; containerId: string }> } +) { + const session = await auth() + if (!session || session.user.userType !== 'staff') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: clientId, serverId, containerId } = await params + const { searchParams } = new URL(request.url) + const force = searchParams.get('force') === 'true' + + // Verify server belongs to client + const server = await prisma.enterpriseServer.findFirst({ + where: { + id: serverId, + clientId, + }, + }) + + if (!server) { + return NextResponse.json( + { error: 'Server not found' }, + { status: 404 } + ) + } + + const portainerClient = await createPortainerClientForServer(serverId) + if (!portainerClient) { + return NextResponse.json( + { error: 'Portainer not configured for this server' }, + { status: 400 } + ) + } + + try { + await portainerClient.removeContainer(containerId, force) + return NextResponse.json({ + success: true, + message: 'Container removed successfully', + }) + } catch (error) { + console.error('Failed to remove container:', error) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to remove container' }, + { status: 500 } + ) + } +} + +// POST /api/v1/admin/enterprise-clients/[id]/servers/[serverId]/containers/[containerId] +// Perform container action (start, stop, restart) +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string; serverId: string; containerId: string }> } +) { + const session = await auth() + if (!session || session.user.userType !== 'staff') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: clientId, serverId, containerId } = await params + const body = await request.json() + const action = body.action as 'start' | 'stop' | 'restart' + + if (!['start', 'stop', 'restart'].includes(action)) { + return NextResponse.json( + { error: 'Invalid action. Must be one of: start, stop, restart' }, + { status: 400 } + ) + } + + // Verify server belongs to client + const server = await prisma.enterpriseServer.findFirst({ + where: { + id: serverId, + clientId, + }, + }) + + if (!server) { + return NextResponse.json( + { error: 'Server not found' }, + { status: 404 } + ) + } + + const portainerClient = await createPortainerClientForServer(serverId) + if (!portainerClient) { + return NextResponse.json( + { error: 'Portainer not configured for this server' }, + { status: 400 } + ) + } + + try { + switch (action) { + case 'start': + await portainerClient.startContainer(containerId) + break + case 'stop': + await portainerClient.stopContainer(containerId) + break + case 'restart': + await portainerClient.restartContainer(containerId) + break + } + + return NextResponse.json({ + success: true, + message: `Container ${action} successful`, + }) + } catch (error) { + console.error(`Failed to ${action} container:`, error) + return NextResponse.json( + { error: error instanceof Error ? error.message : `Failed to ${action} container` }, + { status: 500 } + ) + } +} diff --git a/src/app/api/v1/admin/enterprise-clients/[id]/servers/[serverId]/containers/route.ts b/src/app/api/v1/admin/enterprise-clients/[id]/servers/[serverId]/containers/route.ts new file mode 100644 index 0000000..f88dbd1 --- /dev/null +++ b/src/app/api/v1/admin/enterprise-clients/[id]/servers/[serverId]/containers/route.ts @@ -0,0 +1,82 @@ +import { NextRequest, NextResponse } from 'next/server' +import { auth } from '@/lib/auth' +import { prisma } from '@/lib/prisma' +import { createPortainerClientForServer } from '@/lib/services/portainer-client' + +// GET /api/v1/admin/enterprise-clients/[id]/servers/[serverId]/containers +// List all containers for a server +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string; serverId: string }> } +) { + const session = await auth() + if (!session || session.user.userType !== 'staff') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: clientId, serverId } = await params + const { searchParams } = new URL(request.url) + const all = searchParams.get('all') !== 'false' // Default to showing all containers + + // Verify server belongs to client + const server = await prisma.enterpriseServer.findFirst({ + where: { + id: serverId, + clientId, + }, + }) + + if (!server) { + return NextResponse.json( + { error: 'Server not found' }, + { status: 404 } + ) + } + + // Check if Portainer is configured + const portainerClient = await createPortainerClientForServer(serverId) + if (!portainerClient) { + return NextResponse.json( + { error: 'Portainer not configured for this server' }, + { status: 400 } + ) + } + + try { + // List containers + const containers = await portainerClient.listContainers(all) + + // Get stats for running containers + const stats = await portainerClient.getAllContainerStats() + + // Combine containers with stats + const containersWithStats = containers.map(container => ({ + id: container.Id, + names: container.Names.map(n => n.replace(/^\//, '')), // Remove leading slash + image: container.Image, + imageId: container.ImageID, + command: container.Command, + created: container.Created, + state: container.State, + status: container.Status, + ports: container.Ports, + labels: container.Labels, + networks: container.NetworkSettings?.Networks || {}, + stats: stats[container.Id] || null, + })) + + return NextResponse.json({ + serverId, + containers: containersWithStats, + total: containersWithStats.length, + running: containersWithStats.filter(c => c.state === 'running').length, + stopped: containersWithStats.filter(c => c.state !== 'running').length, + }) + } catch (error) { + console.error('Failed to list containers:', error) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to list containers' }, + { status: 500 } + ) + } +} diff --git a/src/app/api/v1/admin/enterprise-clients/[id]/servers/[serverId]/route.ts b/src/app/api/v1/admin/enterprise-clients/[id]/servers/[serverId]/route.ts new file mode 100644 index 0000000..9165ac9 --- /dev/null +++ b/src/app/api/v1/admin/enterprise-clients/[id]/servers/[serverId]/route.ts @@ -0,0 +1,137 @@ +import { NextRequest, NextResponse } from 'next/server' +import { auth } from '@/lib/auth' +import { enterpriseClientService } from '@/lib/services/enterprise-client-service' +import { netcupService } from '@/lib/services/netcup-service' +import { z } from 'zod' + +interface RouteContext { + params: Promise<{ id: string; serverId: string }> +} + +const updateServerSchema = z.object({ + nickname: z.string().optional().nullable(), + purpose: z.string().optional().nullable(), + isActive: z.boolean().optional(), + portainerUrl: z.string().url().optional().nullable(), + portainerUsername: z.string().optional().nullable(), + portainerPassword: z.string().optional() +}) + +/** + * GET /api/v1/admin/enterprise-clients/[id]/servers/[serverId] + * Get server details with Netcup live info + */ +export async function GET( + request: NextRequest, + context: RouteContext +) { + const session = await auth() + if (!session || session.user.userType !== 'staff') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: clientId, serverId } = await context.params + + try { + const server = await enterpriseClientService.getServer(clientId, serverId) + + if (!server) { + return NextResponse.json({ error: 'Server not found' }, { status: 404 }) + } + + // Get Netcup live info + let netcupInfo = null + try { + netcupInfo = await netcupService.getServer(server.netcupServerId, true) + } catch (error) { + console.error('Failed to get Netcup server info:', error) + } + + return NextResponse.json({ + ...server, + netcup: netcupInfo + }) + } catch (error) { + console.error('Failed to get server:', error) + return NextResponse.json( + { error: 'Failed to get server' }, + { status: 500 } + ) + } +} + +/** + * PATCH /api/v1/admin/enterprise-clients/[id]/servers/[serverId] + * Update server + */ +export async function PATCH( + request: NextRequest, + context: RouteContext +) { + const session = await auth() + if (!session || session.user.userType !== 'staff') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: clientId, serverId } = await context.params + + try { + const body = await request.json() + const validation = updateServerSchema.safeParse(body) + + if (!validation.success) { + return NextResponse.json( + { error: 'Validation failed', details: validation.error.flatten() }, + { status: 400 } + ) + } + + // Check if server exists and belongs to client + const existingServer = await enterpriseClientService.getServer(clientId, serverId) + if (!existingServer) { + return NextResponse.json({ error: 'Server not found' }, { status: 404 }) + } + + const server = await enterpriseClientService.updateServer(serverId, validation.data) + return NextResponse.json(server) + } catch (error) { + console.error('Failed to update server:', error) + return NextResponse.json( + { error: 'Failed to update server' }, + { status: 500 } + ) + } +} + +/** + * DELETE /api/v1/admin/enterprise-clients/[id]/servers/[serverId] + * Remove server from client (does not delete from Netcup) + */ +export async function DELETE( + request: NextRequest, + context: RouteContext +) { + const session = await auth() + if (!session || session.user.userType !== 'staff') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: clientId, serverId } = await context.params + + try { + // Check if server exists and belongs to client + const existingServer = await enterpriseClientService.getServer(clientId, serverId) + if (!existingServer) { + return NextResponse.json({ error: 'Server not found' }, { status: 404 }) + } + + await enterpriseClientService.removeServer(clientId, serverId) + return NextResponse.json({ success: true }) + } catch (error) { + console.error('Failed to remove server:', error) + return NextResponse.json( + { error: 'Failed to remove server' }, + { status: 500 } + ) + } +} diff --git a/src/app/api/v1/admin/enterprise-clients/[id]/servers/[serverId]/stats/route.ts b/src/app/api/v1/admin/enterprise-clients/[id]/servers/[serverId]/stats/route.ts new file mode 100644 index 0000000..6660420 --- /dev/null +++ b/src/app/api/v1/admin/enterprise-clients/[id]/servers/[serverId]/stats/route.ts @@ -0,0 +1,109 @@ +import { NextRequest, NextResponse } from 'next/server' +import { auth } from '@/lib/auth' +import { enterpriseClientService } from '@/lib/services/enterprise-client-service' +import { statsCollectionService } from '@/lib/services/stats-collection-service' +import type { StatsRange } from '@/lib/services/stats-collection-service' + +interface RouteContext { + params: Promise<{ id: string; serverId: string }> +} + +/** + * GET /api/v1/admin/enterprise-clients/[id]/servers/[serverId]/stats + * Get stats history for a server + */ +export async function GET( + request: NextRequest, + context: RouteContext +) { + const session = await auth() + if (!session || session.user.userType !== 'staff') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: clientId, serverId } = await context.params + const searchParams = request.nextUrl.searchParams + const range = (searchParams.get('range') || '24h') as StatsRange + + try { + // Verify server belongs to client + const server = await enterpriseClientService.getServer(clientId, serverId) + if (!server) { + return NextResponse.json({ error: 'Server not found' }, { status: 404 }) + } + + const [history, latest] = await Promise.all([ + statsCollectionService.getServerStatsHistory(serverId, range), + statsCollectionService.getServerLatestStats(serverId) + ]) + + return NextResponse.json({ + serverId, + range, + latest, + history, + dataPoints: history.length + }) + } catch (error) { + console.error('Failed to get server stats:', error) + return NextResponse.json( + { error: 'Failed to get server stats' }, + { status: 500 } + ) + } +} + +/** + * POST /api/v1/admin/enterprise-clients/[id]/servers/[serverId]/stats + * Trigger manual stats collection for a server + */ +export async function POST( + request: NextRequest, + context: RouteContext +) { + const session = await auth() + if (!session || session.user.userType !== 'staff') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: clientId, serverId } = await context.params + + try { + // Verify server belongs to client + const server = await enterpriseClientService.getServer(clientId, serverId) + if (!server) { + return NextResponse.json({ error: 'Server not found' }, { status: 404 }) + } + + const snapshot = await statsCollectionService.collectServerStats(serverId) + + if (!snapshot) { + return NextResponse.json( + { error: 'Failed to collect stats' }, + { status: 500 } + ) + } + + return NextResponse.json({ + success: true, + message: 'Stats collected successfully', + snapshot: { + id: snapshot.id, + timestamp: snapshot.timestamp, + cpuPercent: snapshot.cpuPercent, + memoryUsedMb: snapshot.memoryUsedMb, + memoryTotalMb: snapshot.memoryTotalMb, + diskReadMbps: snapshot.diskReadMbps, + diskWriteMbps: snapshot.diskWriteMbps, + networkInMbps: snapshot.networkInMbps, + networkOutMbps: snapshot.networkOutMbps + } + }) + } catch (error) { + console.error('Failed to collect server stats:', error) + return NextResponse.json( + { error: 'Failed to collect stats' }, + { status: 500 } + ) + } +} diff --git a/src/app/api/v1/admin/enterprise-clients/[id]/servers/[serverId]/test-portainer/route.ts b/src/app/api/v1/admin/enterprise-clients/[id]/servers/[serverId]/test-portainer/route.ts new file mode 100644 index 0000000..99bfe41 --- /dev/null +++ b/src/app/api/v1/admin/enterprise-clients/[id]/servers/[serverId]/test-portainer/route.ts @@ -0,0 +1,81 @@ +import { NextRequest, NextResponse } from 'next/server' +import { auth } from '@/lib/auth' +import { PortainerClient } from '@/lib/services/portainer-client' + +interface RouteParams { + params: Promise<{ + id: string + serverId: string + }> +} + +/** + * POST /api/v1/admin/enterprise-clients/[id]/servers/[serverId]/test-portainer + * Test Portainer connection with provided credentials (doesn't require saved credentials) + */ +export async function POST(request: NextRequest, { params }: RouteParams) { + const session = await auth() + if (!session || session.user.userType !== 'staff') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: clientId, serverId } = await params + + try { + const body = await request.json() + const { portainerUrl, portainerUsername, portainerPassword } = body + + if (!portainerUrl || !portainerUsername || !portainerPassword) { + return NextResponse.json( + { error: 'Missing required fields: portainerUrl, portainerUsername, portainerPassword' }, + { status: 400 } + ) + } + + // Create a temporary client with provided credentials + const client = new PortainerClient({ + url: portainerUrl, + username: portainerUsername, + password: portainerPassword, + }) + + // Test the connection + const success = await client.testConnection() + + if (success) { + return NextResponse.json({ + success: true, + message: 'Connection successful', + }) + } else { + return NextResponse.json({ + success: false, + message: 'Connection failed - check credentials and URL', + }) + } + } catch (error) { + console.error(`[API] Test Portainer connection failed for server ${serverId} in client ${clientId}:`, error) + + const message = error instanceof Error ? error.message : 'Unknown error' + + // Provide more specific error messages + if (message.includes('ECONNREFUSED') || message.includes('ENOTFOUND')) { + return NextResponse.json({ + success: false, + message: 'Cannot connect to Portainer - check the URL is correct and the server is reachable', + }) + } + + if (message.includes('401') || message.includes('authentication failed')) { + return NextResponse.json({ + success: false, + message: 'Authentication failed - check username and password', + }) + } + + return NextResponse.json({ + success: false, + message: `Connection test failed: ${message}`, + }) + } +} diff --git a/src/app/api/v1/admin/enterprise-clients/[id]/servers/[serverId]/verify/route.ts b/src/app/api/v1/admin/enterprise-clients/[id]/servers/[serverId]/verify/route.ts new file mode 100644 index 0000000..55c3468 --- /dev/null +++ b/src/app/api/v1/admin/enterprise-clients/[id]/servers/[serverId]/verify/route.ts @@ -0,0 +1,67 @@ +import { NextRequest, NextResponse } from 'next/server' +import { auth } from '@/lib/auth' +import { enterpriseClientService } from '@/lib/services/enterprise-client-service' +import { securityVerificationService } from '@/lib/services/security-verification-service' +import { z } from 'zod' + +interface RouteContext { + params: Promise<{ id: string; serverId: string }> +} + +const requestCodeSchema = z.object({ + action: z.enum(['WIPE', 'REINSTALL']) +}) + +/** + * POST /api/v1/admin/enterprise-clients/[id]/servers/[serverId]/verify + * Request a verification code for a destructive action + */ +export async function POST( + request: NextRequest, + context: RouteContext +) { + const session = await auth() + if (!session || session.user.userType !== 'staff') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: clientId, serverId } = await context.params + + try { + const body = await request.json() + const validation = requestCodeSchema.safeParse(body) + + if (!validation.success) { + return NextResponse.json( + { error: 'Validation failed', details: validation.error.flatten() }, + { status: 400 } + ) + } + + // Check if server exists and belongs to client + const server = await enterpriseClientService.getServer(clientId, serverId) + if (!server) { + return NextResponse.json({ error: 'Server not found' }, { status: 404 }) + } + + // Request verification code + const result = await securityVerificationService.requestVerificationCode( + clientId, + serverId, + validation.data.action + ) + + return NextResponse.json({ + success: true, + message: `Verification code sent to ${result.email}`, + email: result.email, + expiresAt: result.expiresAt.toISOString() + }) + } catch (error) { + console.error('Failed to request verification code:', error) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to request verification code' }, + { status: 500 } + ) + } +} diff --git a/src/app/api/v1/admin/enterprise-clients/[id]/servers/route.ts b/src/app/api/v1/admin/enterprise-clients/[id]/servers/route.ts new file mode 100644 index 0000000..d0f48c5 --- /dev/null +++ b/src/app/api/v1/admin/enterprise-clients/[id]/servers/route.ts @@ -0,0 +1,137 @@ +import { NextRequest, NextResponse } from 'next/server' +import { auth } from '@/lib/auth' +import { enterpriseClientService } from '@/lib/services/enterprise-client-service' +import { netcupService } from '@/lib/services/netcup-service' +import { z } from 'zod' + +interface RouteContext { + params: Promise<{ id: string }> +} + +const addServerSchema = z.object({ + netcupServerId: z.string().min(1, 'Netcup server ID is required'), + nickname: z.string().optional(), + purpose: z.string().optional(), + portainerUrl: z.string().url().optional(), + portainerUsername: z.string().optional(), + portainerPassword: z.string().optional() +}) + +/** + * GET /api/v1/admin/enterprise-clients/[id]/servers + * List all servers for an enterprise client with live status from Netcup + */ +export async function GET( + request: NextRequest, + context: RouteContext +) { + const session = await auth() + if (!session || session.user.userType !== 'staff') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: clientId } = await context.params + + try { + // Get client's servers from database + const servers = await enterpriseClientService.getClientServers(clientId) + + // Enrich with live Netcup status if possible + const enrichedServers = await Promise.all( + servers.map(async (server) => { + try { + const netcupServer = await netcupService.getServer(server.netcupServerId, true) + return { + ...server, + netcupStatus: netcupServer?.state || 'unknown', + netcupHostname: netcupServer?.hostname, + netcupIps: [netcupServer?.primaryIpv4, netcupServer?.primaryIpv6].filter(Boolean) as string[] + } + } catch { + return { + ...server, + netcupStatus: 'error', + netcupHostname: null, + netcupIps: [] + } + } + }) + ) + + return NextResponse.json(enrichedServers) + } catch (error) { + console.error('Failed to list servers:', error) + return NextResponse.json( + { error: 'Failed to list servers' }, + { status: 500 } + ) + } +} + +/** + * POST /api/v1/admin/enterprise-clients/[id]/servers + * Add a server to an enterprise client + */ +export async function POST( + request: NextRequest, + context: RouteContext +) { + const session = await auth() + if (!session || session.user.userType !== 'staff') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: clientId } = await context.params + + try { + const body = await request.json() + const validation = addServerSchema.safeParse(body) + + if (!validation.success) { + return NextResponse.json( + { error: 'Validation failed', details: validation.error.flatten() }, + { status: 400 } + ) + } + + // Verify client exists + const client = await enterpriseClientService.getClient(clientId) + if (!client) { + return NextResponse.json({ error: 'Client not found' }, { status: 404 }) + } + + // Verify Netcup server exists + try { + const netcupServer = await netcupService.getServer(validation.data.netcupServerId) + if (!netcupServer) { + return NextResponse.json( + { error: 'Netcup server not found' }, + { status: 400 } + ) + } + } catch (error) { + return NextResponse.json( + { error: 'Failed to verify Netcup server. Is Netcup connected?' }, + { status: 400 } + ) + } + + const server = await enterpriseClientService.addServer(clientId, validation.data) + return NextResponse.json(server, { status: 201 }) + } catch (error) { + console.error('Failed to add server:', error) + + // Check for unique constraint violation + if (error instanceof Error && error.message.includes('Unique constraint')) { + return NextResponse.json( + { error: 'This Netcup server is already linked to this client' }, + { status: 400 } + ) + } + + return NextResponse.json( + { error: 'Failed to add server' }, + { status: 500 } + ) + } +} diff --git a/src/app/api/v1/admin/enterprise-clients/[id]/stats/route.ts b/src/app/api/v1/admin/enterprise-clients/[id]/stats/route.ts new file mode 100644 index 0000000..7084957 --- /dev/null +++ b/src/app/api/v1/admin/enterprise-clients/[id]/stats/route.ts @@ -0,0 +1,40 @@ +import { NextRequest, NextResponse } from 'next/server' +import { auth } from '@/lib/auth' +import { statsCollectionService } from '@/lib/services/stats-collection-service' +import type { StatsRange } from '@/lib/services/stats-collection-service' + +interface RouteContext { + params: Promise<{ id: string }> +} + +/** + * GET /api/v1/admin/enterprise-clients/[id]/stats + * Get aggregated stats overview for an enterprise client + */ +export async function GET( + request: NextRequest, + context: RouteContext +) { + const session = await auth() + if (!session || session.user.userType !== 'staff') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: clientId } = await context.params + const searchParams = request.nextUrl.searchParams + const range = (searchParams.get('range') || '24h') as StatsRange + + try { + const overview = await statsCollectionService.getClientStatsOverview(clientId) + return NextResponse.json({ + ...overview, + range + }) + } catch (error) { + console.error('Failed to get client stats:', error) + return NextResponse.json( + { error: 'Failed to get client stats' }, + { status: 500 } + ) + } +} diff --git a/src/app/api/v1/admin/enterprise-clients/error-summary/route.ts b/src/app/api/v1/admin/enterprise-clients/error-summary/route.ts new file mode 100644 index 0000000..d201b50 --- /dev/null +++ b/src/app/api/v1/admin/enterprise-clients/error-summary/route.ts @@ -0,0 +1,52 @@ +import { NextRequest, NextResponse } from 'next/server' +import { auth } from '@/lib/auth' +import { errorDashboardService } from '@/lib/services/error-dashboard-service' + +// GET /api/v1/admin/enterprise-clients/error-summary +// Get error summary for ALL clients (for main enterprise page widget) +export async function GET(request: NextRequest) { + const session = await auth() + if (!session || session.user.userType !== 'staff') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + try { + const summaries = await errorDashboardService.getAllClientsErrorSummary() + + // Calculate totals + const totals = summaries.reduce( + (acc, client) => ({ + criticalErrors24h: acc.criticalErrors24h + client.criticalErrors24h, + totalErrors24h: acc.totalErrors24h + client.totalErrors24h, + crashes24h: acc.crashes24h + client.crashes24h, + clientsWithIssues: acc.clientsWithIssues + (client.criticalErrors24h > 0 || client.crashes24h > 0 ? 1 : 0), + }), + { criticalErrors24h: 0, totalErrors24h: 0, crashes24h: 0, clientsWithIssues: 0 } + ) + + // Calculate overall trend + const increasingCount = summaries.filter(s => s.errorTrend === 'increasing').length + const decreasingCount = summaries.filter(s => s.errorTrend === 'decreasing').length + let overallTrend: 'increasing' | 'decreasing' | 'stable' = 'stable' + if (increasingCount > decreasingCount) { + overallTrend = 'increasing' + } else if (decreasingCount > increasingCount) { + overallTrend = 'decreasing' + } + + return NextResponse.json({ + clients: summaries, + totals: { + ...totals, + overallTrend, + totalClients: summaries.length, + }, + }) + } catch (error) { + console.error('Failed to get error summary:', error) + return NextResponse.json( + { error: 'Failed to get error summary' }, + { status: 500 } + ) + } +} diff --git a/src/app/api/v1/admin/enterprise-clients/route.ts b/src/app/api/v1/admin/enterprise-clients/route.ts new file mode 100644 index 0000000..9ec8029 --- /dev/null +++ b/src/app/api/v1/admin/enterprise-clients/route.ts @@ -0,0 +1,66 @@ +import { NextRequest, NextResponse } from 'next/server' +import { auth } from '@/lib/auth' +import { enterpriseClientService } from '@/lib/services/enterprise-client-service' +import { z } from 'zod' + +const createClientSchema = z.object({ + name: z.string().min(1, 'Name is required'), + companyName: z.string().optional(), + contactEmail: z.string().email('Valid email is required'), + contactPhone: z.string().optional(), + notes: z.string().optional() +}) + +/** + * GET /api/v1/admin/enterprise-clients + * List all enterprise clients + */ +export async function GET() { + const session = await auth() + if (!session || session.user.userType !== 'staff') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + try { + const clients = await enterpriseClientService.getClients() + return NextResponse.json(clients) + } catch (error) { + console.error('Failed to list enterprise clients:', error) + return NextResponse.json( + { error: 'Failed to list enterprise clients' }, + { status: 500 } + ) + } +} + +/** + * POST /api/v1/admin/enterprise-clients + * Create a new enterprise client + */ +export async function POST(request: NextRequest) { + const session = await auth() + if (!session || session.user.userType !== 'staff') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + try { + const body = await request.json() + const validation = createClientSchema.safeParse(body) + + if (!validation.success) { + return NextResponse.json( + { error: 'Validation failed', details: validation.error.flatten() }, + { status: 400 } + ) + } + + const client = await enterpriseClientService.createClient(validation.data) + return NextResponse.json(client, { status: 201 }) + } catch (error) { + console.error('Failed to create enterprise client:', error) + return NextResponse.json( + { error: 'Failed to create enterprise client' }, + { status: 500 } + ) + } +} diff --git a/src/app/api/v1/admin/netcup/auth/route.ts b/src/app/api/v1/admin/netcup/auth/route.ts new file mode 100644 index 0000000..629f94b --- /dev/null +++ b/src/app/api/v1/admin/netcup/auth/route.ts @@ -0,0 +1,185 @@ +import { NextRequest, NextResponse } from 'next/server' +import { auth } from '@/lib/auth' +import { netcupService, NetcupAuthError } from '@/lib/services/netcup-service' + +// Store pending device auth sessions (in-memory for simplicity) +// In production, consider storing in Redis or database +const pendingAuthSessions = new Map< + string, + { + deviceCode: string + expiresAt: number + interval: number + } +>() + +/** + * GET /api/v1/admin/netcup/auth + * Get current authentication status + */ +export async function GET(request: NextRequest) { + try { + const session = await auth() + + if (!session || session.user.userType !== 'staff') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const status = await netcupService.getAuthStatus() + + return NextResponse.json(status) + } catch (error) { + console.error('Error getting Netcup auth status:', error) + return NextResponse.json( + { error: 'Failed to get auth status' }, + { status: 500 } + ) + } +} + +/** + * POST /api/v1/admin/netcup/auth + * Initiate device auth flow or poll for token + * + * Body: + * - action: 'initiate' | 'poll' | 'disconnect' + * - sessionId?: string (for poll action) + */ +export async function POST(request: NextRequest) { + try { + const session = await auth() + + if (!session || session.user.userType !== 'staff') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const body = await request.json() + const { action, sessionId } = body as { + action: 'initiate' | 'poll' | 'disconnect' + sessionId?: string + } + + if (!action || !['initiate', 'poll', 'disconnect'].includes(action)) { + return NextResponse.json( + { error: 'Invalid action. Must be: initiate, poll, or disconnect' }, + { status: 400 } + ) + } + + switch (action) { + case 'initiate': { + // Start device auth flow + const deviceAuth = await netcupService.initiateDeviceAuth() + + // Store session for polling + const newSessionId = crypto.randomUUID() + pendingAuthSessions.set(newSessionId, { + deviceCode: deviceAuth.device_code, + expiresAt: Date.now() + deviceAuth.expires_in * 1000, + interval: deviceAuth.interval, + }) + + // Clean up expired sessions + for (const [id, sess] of pendingAuthSessions) { + if (sess.expiresAt < Date.now()) { + pendingAuthSessions.delete(id) + } + } + + return NextResponse.json({ + success: true, + sessionId: newSessionId, + userCode: deviceAuth.user_code, + verificationUri: deviceAuth.verification_uri, + verificationUriComplete: deviceAuth.verification_uri_complete, + expiresIn: deviceAuth.expires_in, + interval: deviceAuth.interval, + }) + } + + case 'poll': { + if (!sessionId) { + return NextResponse.json( + { error: 'Session ID required for polling' }, + { status: 400 } + ) + } + + const pendingSession = pendingAuthSessions.get(sessionId) + + if (!pendingSession) { + return NextResponse.json( + { error: 'Session not found or expired' }, + { status: 404 } + ) + } + + if (pendingSession.expiresAt < Date.now()) { + pendingAuthSessions.delete(sessionId) + return NextResponse.json( + { error: 'Session expired' }, + { status: 410 } + ) + } + + try { + const tokens = await netcupService.pollForToken(pendingSession.deviceCode) + + if (!tokens) { + // Still waiting for user authorization + return NextResponse.json({ + success: false, + status: 'pending', + message: 'Waiting for user authorization', + }) + } + + // Success! Clean up session + pendingAuthSessions.delete(sessionId) + + return NextResponse.json({ + success: true, + status: 'authenticated', + message: 'Successfully authenticated with Netcup', + }) + } catch (error) { + if (error instanceof NetcupAuthError) { + pendingAuthSessions.delete(sessionId) + return NextResponse.json( + { error: error.message }, + { status: 400 } + ) + } + throw error + } + } + + case 'disconnect': { + // Clear stored tokens + await netcupService.clearTokens() + + return NextResponse.json({ + success: true, + message: 'Disconnected from Netcup', + }) + } + + default: + return NextResponse.json({ error: 'Invalid action' }, { status: 400 }) + } + } catch (error) { + console.error('Error in Netcup auth:', error) + + if (error instanceof NetcupAuthError) { + return NextResponse.json( + { error: error.message }, + { status: 400 } + ) + } + + return NextResponse.json( + { error: 'Failed to process auth request' }, + { status: 500 } + ) + } +} diff --git a/src/app/api/v1/admin/netcup/servers/[id]/metrics/route.ts b/src/app/api/v1/admin/netcup/servers/[id]/metrics/route.ts new file mode 100644 index 0000000..d111f71 --- /dev/null +++ b/src/app/api/v1/admin/netcup/servers/[id]/metrics/route.ts @@ -0,0 +1,82 @@ +import { NextRequest, NextResponse } from 'next/server' +import { auth } from '@/lib/auth' +import { + netcupService, + NetcupAuthError, + NetcupApiError, +} from '@/lib/services/netcup-service' + +/** + * GET /api/v1/admin/netcup/servers/[id]/metrics + * Get server metrics (CPU, disk, network) + * + * Query params: + * - hours: Number of hours of history (default: 24, max: 1440) + * - type: 'all' | 'cpu' | 'disk' | 'network' (default: 'all') + */ +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await auth() + + if (!session || session.user.userType !== 'staff') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: serverId } = await params + const searchParams = request.nextUrl.searchParams + const hours = Math.min(parseInt(searchParams.get('hours') || '24', 10), 1440) + const type = searchParams.get('type') || 'all' + + // Check if authenticated with Netcup + const isAuth = await netcupService.isAuthenticated() + if (!isAuth) { + return NextResponse.json( + { error: 'Not authenticated with Netcup' }, + { status: 401 } + ) + } + + switch (type) { + case 'cpu': { + const cpu = await netcupService.getCpuMetrics(serverId, hours) + console.log('CPU metrics response:', JSON.stringify(cpu, null, 2)) + return NextResponse.json({ cpu, period: `${hours}h` }) + } + case 'disk': { + const disk = await netcupService.getDiskMetrics(serverId, hours) + return NextResponse.json({ disk, period: `${hours}h` }) + } + case 'network': { + const network = await netcupService.getNetworkMetrics(serverId, hours) + return NextResponse.json({ network, period: `${hours}h` }) + } + case 'all': + default: { + const metrics = await netcupService.getAllMetrics(serverId, hours) + console.log('All metrics response:', JSON.stringify(metrics, null, 2)) + return NextResponse.json(metrics) + } + } + } catch (error) { + console.error('Error getting server metrics:', error) + + if (error instanceof NetcupAuthError) { + return NextResponse.json({ error: error.message }, { status: 401 }) + } + + if (error instanceof NetcupApiError) { + return NextResponse.json( + { error: error.message }, + { status: error.statusCode } + ) + } + + return NextResponse.json( + { error: 'Failed to get server metrics' }, + { status: 500 } + ) + } +} diff --git a/src/app/api/v1/admin/netcup/servers/[id]/route.ts b/src/app/api/v1/admin/netcup/servers/[id]/route.ts new file mode 100644 index 0000000..b2631be --- /dev/null +++ b/src/app/api/v1/admin/netcup/servers/[id]/route.ts @@ -0,0 +1,221 @@ +import { NextRequest, NextResponse } from 'next/server' +import { auth } from '@/lib/auth' +import { + netcupService, + NetcupAuthError, + NetcupApiError, + PowerAction, +} from '@/lib/services/netcup-service' + +/** + * GET /api/v1/admin/netcup/servers/[id] + * Get server details + * + * Query params: + * - liveInfo=true: Include live CPU/RAM/disk usage + */ +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await auth() + + if (!session || session.user.userType !== 'staff') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: serverId } = await params + const searchParams = request.nextUrl.searchParams + const loadLiveInfo = searchParams.get('liveInfo') === 'true' + + const server = await netcupService.getServer(serverId, loadLiveInfo) + + return NextResponse.json(server) + } catch (error) { + console.error('Error getting Netcup server:', error) + + if (error instanceof NetcupAuthError) { + return NextResponse.json({ error: error.message }, { status: 401 }) + } + + if (error instanceof NetcupApiError) { + return NextResponse.json( + { error: error.message }, + { status: error.statusCode } + ) + } + + return NextResponse.json( + { error: 'Failed to get server' }, + { status: 500 } + ) + } +} + +/** + * POST /api/v1/admin/netcup/servers/[id] + * Perform action on server + * + * Body: + * - action: 'power' | 'reinstall' | 'rescue' | 'hostname' | 'nickname' + * - powerAction?: 'ON' | 'OFF' | 'POWERCYCLE' | 'RESET' | 'POWEROFF' + * - imageFlavour?: string (for reinstall) + * - rescueAction?: 'activate' | 'deactivate' (for rescue) + * - hostname?: string (for hostname update) + * - nickname?: string (for nickname update) + */ +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await auth() + + if (!session || session.user.userType !== 'staff') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: serverId } = await params + const body = await request.json() + + const { action, powerAction, imageFlavour, rescueAction, hostname, nickname } = body as { + action: 'power' | 'reinstall' | 'rescue' | 'hostname' | 'nickname' | 'imageFlavours' + powerAction?: PowerAction + imageFlavour?: string + rescueAction?: 'activate' | 'deactivate' + hostname?: string + nickname?: string + } + + if (!action) { + return NextResponse.json( + { error: 'Action required' }, + { status: 400 } + ) + } + + switch (action) { + case 'power': { + if (!powerAction || !['ON', 'OFF', 'POWERCYCLE', 'RESET', 'POWEROFF'].includes(powerAction)) { + return NextResponse.json( + { error: 'Valid powerAction required: ON, OFF, POWERCYCLE, RESET, POWEROFF' }, + { status: 400 } + ) + } + + await netcupService.powerAction(serverId, powerAction) + + return NextResponse.json({ + success: true, + message: `Power action ${powerAction} executed`, + }) + } + + case 'reinstall': { + if (!imageFlavour) { + return NextResponse.json( + { error: 'imageFlavour required for reinstall' }, + { status: 400 } + ) + } + + const task = await netcupService.reinstallServer(serverId, imageFlavour) + + return NextResponse.json({ + success: true, + message: 'Reinstall started', + taskId: task.taskId, + }) + } + + case 'rescue': { + if (!rescueAction || !['activate', 'deactivate'].includes(rescueAction)) { + return NextResponse.json( + { error: 'rescueAction required: activate or deactivate' }, + { status: 400 } + ) + } + + if (rescueAction === 'activate') { + const credentials = await netcupService.activateRescue(serverId) + return NextResponse.json({ + success: true, + message: 'Rescue mode activated', + credentials, + }) + } else { + await netcupService.deactivateRescue(serverId) + return NextResponse.json({ + success: true, + message: 'Rescue mode deactivated', + }) + } + } + + case 'hostname': { + if (!hostname) { + return NextResponse.json( + { error: 'hostname required' }, + { status: 400 } + ) + } + + await netcupService.updateHostname(serverId, hostname) + + return NextResponse.json({ + success: true, + message: 'Hostname updated', + }) + } + + case 'nickname': { + if (nickname === undefined) { + return NextResponse.json( + { error: 'nickname required' }, + { status: 400 } + ) + } + + await netcupService.updateNickname(serverId, nickname) + + return NextResponse.json({ + success: true, + message: 'Nickname updated', + }) + } + + case 'imageFlavours': { + const flavours = await netcupService.getImageFlavours(serverId) + return NextResponse.json({ + success: true, + flavours, + }) + } + + default: + return NextResponse.json( + { error: 'Invalid action' }, + { status: 400 } + ) + } + } catch (error) { + console.error('Error performing Netcup server action:', error) + + if (error instanceof NetcupAuthError) { + return NextResponse.json({ error: error.message }, { status: 401 }) + } + + if (error instanceof NetcupApiError) { + return NextResponse.json( + { error: error.message }, + { status: error.statusCode } + ) + } + + return NextResponse.json( + { error: 'Failed to perform server action' }, + { status: 500 } + ) + } +} diff --git a/src/app/api/v1/admin/netcup/servers/[id]/snapshots/route.ts b/src/app/api/v1/admin/netcup/servers/[id]/snapshots/route.ts new file mode 100644 index 0000000..55be6d5 --- /dev/null +++ b/src/app/api/v1/admin/netcup/servers/[id]/snapshots/route.ts @@ -0,0 +1,176 @@ +import { NextRequest, NextResponse } from 'next/server' +import { auth } from '@/lib/auth' +import { + netcupService, + NetcupAuthError, + NetcupApiError, +} from '@/lib/services/netcup-service' + +/** + * GET /api/v1/admin/netcup/servers/[id]/snapshots + * Get list of server snapshots + */ +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await auth() + + if (!session || session.user.userType !== 'staff') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: serverId } = await params + + // Check if authenticated with Netcup + const isAuth = await netcupService.isAuthenticated() + if (!isAuth) { + return NextResponse.json( + { error: 'Not authenticated with Netcup' }, + { status: 401 } + ) + } + + const snapshots = await netcupService.getSnapshots(serverId) + + return NextResponse.json({ + snapshots, + count: snapshots.length, + }) + } catch (error) { + console.error('Error getting server snapshots:', error) + + if (error instanceof NetcupAuthError) { + return NextResponse.json({ error: error.message }, { status: 401 }) + } + + if (error instanceof NetcupApiError) { + return NextResponse.json( + { error: error.message }, + { status: error.statusCode } + ) + } + + return NextResponse.json( + { error: 'Failed to get server snapshots' }, + { status: 500 } + ) + } +} + +/** + * POST /api/v1/admin/netcup/servers/[id]/snapshots + * Create a new snapshot or perform snapshot actions + * + * Body: + * - action: 'create' | 'delete' | 'revert' | 'check' + * - name?: string (snapshot name for create/delete/revert) + */ +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await auth() + + if (!session || session.user.userType !== 'staff') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: serverId } = await params + const body = await request.json() + + const { action, name } = body as { + action: 'create' | 'delete' | 'revert' | 'check' + name?: string + } + + if (!action) { + return NextResponse.json( + { error: 'Action required' }, + { status: 400 } + ) + } + + // Check if authenticated with Netcup + const isAuth = await netcupService.isAuthenticated() + if (!isAuth) { + return NextResponse.json( + { error: 'Not authenticated with Netcup' }, + { status: 401 } + ) + } + + switch (action) { + case 'check': { + const result = await netcupService.canCreateSnapshot(serverId) + return NextResponse.json(result) + } + + case 'create': { + const result = await netcupService.createSnapshot(serverId, name) + return NextResponse.json({ + success: true, + message: `Snapshot created: ${result.name}`, + snapshot: result, + }) + } + + case 'delete': { + if (!name) { + return NextResponse.json( + { error: 'Snapshot name required for delete' }, + { status: 400 } + ) + } + + await netcupService.deleteSnapshot(serverId, name) + return NextResponse.json({ + success: true, + message: `Snapshot deleted: ${name}`, + }) + } + + case 'revert': { + if (!name) { + return NextResponse.json( + { error: 'Snapshot name required for revert' }, + { status: 400 } + ) + } + + const task = await netcupService.revertSnapshot(serverId, name) + return NextResponse.json({ + success: true, + message: `Reverting to snapshot: ${name}`, + taskId: task.taskId, + }) + } + + default: + return NextResponse.json( + { error: 'Invalid action. Use: create, delete, revert, or check' }, + { status: 400 } + ) + } + } catch (error) { + console.error('Error performing snapshot action:', error) + + if (error instanceof NetcupAuthError) { + return NextResponse.json({ error: error.message }, { status: 401 }) + } + + if (error instanceof NetcupApiError) { + return NextResponse.json( + { error: error.message }, + { status: error.statusCode } + ) + } + + return NextResponse.json( + { error: 'Failed to perform snapshot action' }, + { status: 500 } + ) + } +} diff --git a/src/app/api/v1/admin/netcup/servers/route.ts b/src/app/api/v1/admin/netcup/servers/route.ts new file mode 100644 index 0000000..01afd35 --- /dev/null +++ b/src/app/api/v1/admin/netcup/servers/route.ts @@ -0,0 +1,61 @@ +import { NextRequest, NextResponse } from 'next/server' +import { auth } from '@/lib/auth' +import { netcupService, NetcupAuthError, NetcupApiError } from '@/lib/services/netcup-service' + +/** + * GET /api/v1/admin/netcup/servers + * Get list of all Netcup servers + * + * Query params: + * - liveInfo: "true" to load live status (ON/OFF) for each server (slower) + */ +export async function GET(request: NextRequest) { + try { + const session = await auth() + + if (!session || session.user.userType !== 'staff') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + // Check if authenticated + const isAuth = await netcupService.isAuthenticated() + if (!isAuth) { + return NextResponse.json( + { error: 'Not authenticated with Netcup. Please connect your account first.' }, + { status: 401 } + ) + } + + // Check for liveInfo query parameter + const searchParams = request.nextUrl.searchParams + const loadLiveInfo = searchParams.get('liveInfo') === 'true' + + const servers = await netcupService.getServers(loadLiveInfo) + + return NextResponse.json({ + servers, + count: servers.length, + }) + } catch (error) { + console.error('Error getting Netcup servers:', error) + + if (error instanceof NetcupAuthError) { + return NextResponse.json( + { error: error.message }, + { status: 401 } + ) + } + + if (error instanceof NetcupApiError) { + return NextResponse.json( + { error: error.message }, + { status: error.statusCode } + ) + } + + return NextResponse.json( + { error: 'Failed to get servers' }, + { status: 500 } + ) + } +} diff --git a/src/app/api/v1/admin/netcup/tasks/[id]/route.ts b/src/app/api/v1/admin/netcup/tasks/[id]/route.ts new file mode 100644 index 0000000..25b3a4e --- /dev/null +++ b/src/app/api/v1/admin/netcup/tasks/[id]/route.ts @@ -0,0 +1,40 @@ +import { NextRequest, NextResponse } from 'next/server' +import { auth } from '@/lib/auth' +import { netcupService } from '@/lib/services/netcup-service' + +/** + * GET /api/v1/admin/netcup/tasks/[id] + * Get task status for polling during long-running operations (reinstall, etc.) + */ +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await auth() + if (!session || session.user.userType !== 'staff') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: taskId } = await params + + const task = await netcupService.getTask(taskId) + + return NextResponse.json(task) + } catch (error) { + console.error('Error getting task status:', error) + + // Check if it's a 404 (task not found or expired) + if (error instanceof Error && error.message.includes('404')) { + return NextResponse.json( + { error: 'Task not found', taskId: (await params).id, status: 'UNKNOWN' }, + { status: 404 } + ) + } + + return NextResponse.json( + { error: 'Failed to get task status' }, + { status: 500 } + ) + } +} diff --git a/src/app/api/v1/admin/orders/[id]/automation/route.ts b/src/app/api/v1/admin/orders/[id]/automation/route.ts new file mode 100644 index 0000000..903e0ae --- /dev/null +++ b/src/app/api/v1/admin/orders/[id]/automation/route.ts @@ -0,0 +1,216 @@ +import { NextRequest, NextResponse } from 'next/server' +import { auth } from '@/lib/auth' +import { prisma } from '@/lib/prisma' +import { AutomationMode } from '@prisma/client' +import { + processAutomation, + setAutomationMode, + resumeAutomation, + pauseAutomation, + takeManualControl, + enableAutoMode, +} from '@/lib/services/automation-worker' + +/** + * GET /api/v1/admin/orders/[id]/automation + * Get current automation status + */ +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await auth() + + if (!session || session.user.userType !== 'staff') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: orderId } = await params + + const order = await prisma.order.findUnique({ + where: { id: orderId }, + select: { + id: true, + status: true, + automationMode: true, + automationPausedAt: true, + automationPausedReason: true, + source: true, + }, + }) + + if (!order) { + return NextResponse.json({ error: 'Order not found' }, { status: 404 }) + } + + return NextResponse.json({ + mode: order.automationMode, + pausedAt: order.automationPausedAt, + pausedReason: order.automationPausedReason, + source: order.source, + status: order.status, + }) + } catch (error) { + console.error('Error getting automation status:', error) + return NextResponse.json( + { error: 'Failed to get automation status' }, + { status: 500 } + ) + } +} + +/** + * PATCH /api/v1/admin/orders/[id]/automation + * Change automation mode + * + * Body: + * - action: 'auto' | 'manual' | 'pause' | 'resume' + * - reason?: string (optional reason for pause) + */ +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await auth() + + if (!session || session.user.userType !== 'staff') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: orderId } = await params + const body = await request.json() + + const { action, reason } = body as { action: string; reason?: string } + + if (!action || !['auto', 'manual', 'pause', 'resume'].includes(action)) { + return NextResponse.json( + { error: 'Invalid action. Must be: auto, manual, pause, or resume' }, + { status: 400 } + ) + } + + // Check order exists + const order = await prisma.order.findUnique({ + where: { id: orderId }, + }) + + if (!order) { + return NextResponse.json({ error: 'Order not found' }, { status: 404 }) + } + + let result + switch (action) { + case 'auto': + result = await enableAutoMode(orderId) + break + + case 'manual': + await takeManualControl(orderId) + result = { triggered: false, action: 'Switched to manual mode' } + break + + case 'pause': + await pauseAutomation(orderId, reason) + result = { triggered: false, action: 'Automation paused' } + break + + case 'resume': + result = await resumeAutomation(orderId) + break + + default: + return NextResponse.json({ error: 'Invalid action' }, { status: 400 }) + } + + // Get updated order + const updatedOrder = await prisma.order.findUnique({ + where: { id: orderId }, + select: { + id: true, + status: true, + automationMode: true, + automationPausedAt: true, + automationPausedReason: true, + source: true, + }, + }) + + return NextResponse.json({ + success: true, + result, + automation: { + mode: updatedOrder?.automationMode, + pausedAt: updatedOrder?.automationPausedAt, + pausedReason: updatedOrder?.automationPausedReason, + source: updatedOrder?.source, + status: updatedOrder?.status, + }, + }) + } catch (error) { + console.error('Error updating automation:', error) + return NextResponse.json( + { error: 'Failed to update automation' }, + { status: 500 } + ) + } +} + +/** + * POST /api/v1/admin/orders/[id]/automation + * Trigger automation processing (useful for manual refresh) + */ +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await auth() + + if (!session || session.user.userType !== 'staff') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: orderId } = await params + + // Check order exists + const order = await prisma.order.findUnique({ + where: { id: orderId }, + }) + + if (!order) { + return NextResponse.json({ error: 'Order not found' }, { status: 404 }) + } + + // Process automation + const result = await processAutomation(orderId) + + // Get updated order + const updatedOrder = await prisma.order.findUnique({ + where: { id: orderId }, + select: { + id: true, + status: true, + automationMode: true, + automationPausedAt: true, + automationPausedReason: true, + }, + }) + + return NextResponse.json({ + success: true, + result, + automation: { + mode: updatedOrder?.automationMode, + status: updatedOrder?.status, + }, + }) + } catch (error) { + console.error('Error processing automation:', error) + return NextResponse.json( + { error: 'Failed to process automation' }, + { status: 500 } + ) + } +} diff --git a/src/app/api/v1/admin/orders/[id]/containers/[containerId]/[action]/route.ts b/src/app/api/v1/admin/orders/[id]/containers/[containerId]/[action]/route.ts new file mode 100644 index 0000000..3b23c17 --- /dev/null +++ b/src/app/api/v1/admin/orders/[id]/containers/[containerId]/[action]/route.ts @@ -0,0 +1,66 @@ +import { NextRequest, NextResponse } from 'next/server' +import { auth } from '@/lib/auth' +import { createPortainerClient } from '@/lib/services/portainer-client' + +interface RouteContext { + params: Promise<{ id: string; containerId: string; action: string }> +} + +const ALLOWED_ACTIONS = ['start', 'stop', 'restart'] as const +type ContainerAction = (typeof ALLOWED_ACTIONS)[number] + +/** + * POST /api/v1/admin/orders/[id]/containers/[containerId]/[action] + * Perform a container action (start, stop, restart) + */ +export async function POST( + request: NextRequest, + context: RouteContext +) { + const session = await auth() + if (!session || session.user.userType !== 'staff') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: orderId, containerId, action } = await context.params + + // Validate action + if (!ALLOWED_ACTIONS.includes(action as ContainerAction)) { + return NextResponse.json( + { error: `Invalid action: ${action}. Allowed actions: ${ALLOWED_ACTIONS.join(', ')}` }, + { status: 400 } + ) + } + + try { + const client = await createPortainerClient(orderId) + + if (!client) { + return NextResponse.json( + { error: 'Portainer credentials not configured for this order' }, + { status: 400 } + ) + } + + // Execute the action + switch (action as ContainerAction) { + case 'start': + await client.startContainer(containerId) + break + case 'stop': + await client.stopContainer(containerId) + break + case 'restart': + await client.restartContainer(containerId) + break + } + + return NextResponse.json({ success: true, action }) + } catch (error) { + console.error(`Failed to ${action} container:`, error) + return NextResponse.json( + { error: error instanceof Error ? error.message : `Failed to ${action} container` }, + { status: 500 } + ) + } +} diff --git a/src/app/api/v1/admin/orders/[id]/containers/[containerId]/logs/route.ts b/src/app/api/v1/admin/orders/[id]/containers/[containerId]/logs/route.ts new file mode 100644 index 0000000..008c8ce --- /dev/null +++ b/src/app/api/v1/admin/orders/[id]/containers/[containerId]/logs/route.ts @@ -0,0 +1,47 @@ +import { NextRequest, NextResponse } from 'next/server' +import { auth } from '@/lib/auth' +import { createPortainerClient } from '@/lib/services/portainer-client' + +interface RouteContext { + params: Promise<{ id: string; containerId: string }> +} + +/** + * GET /api/v1/admin/orders/[id]/containers/[containerId]/logs + * Get container logs + * Query: ?tail=100 (number of lines) + */ +export async function GET( + request: NextRequest, + context: RouteContext +) { + const session = await auth() + if (!session || session.user.userType !== 'staff') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: orderId, containerId } = await context.params + const searchParams = request.nextUrl.searchParams + const tail = parseInt(searchParams.get('tail') || '100', 10) + + try { + const client = await createPortainerClient(orderId) + + if (!client) { + return NextResponse.json( + { error: 'Portainer credentials not configured for this order' }, + { status: 400 } + ) + } + + const logs = await client.getContainerLogs(containerId, tail) + + return NextResponse.json({ logs }) + } catch (error) { + console.error('Failed to get container logs:', error) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to get container logs' }, + { status: 500 } + ) + } +} diff --git a/src/app/api/v1/admin/orders/[id]/containers/[containerId]/route.ts b/src/app/api/v1/admin/orders/[id]/containers/[containerId]/route.ts new file mode 100644 index 0000000..10ff204 --- /dev/null +++ b/src/app/api/v1/admin/orders/[id]/containers/[containerId]/route.ts @@ -0,0 +1,133 @@ +import { NextRequest, NextResponse } from 'next/server' +import { auth } from '@/lib/auth' +import { createPortainerClient } from '@/lib/services/portainer-client' + +interface RouteContext { + params: Promise<{ id: string; containerId: string }> +} + +/** + * GET /api/v1/admin/orders/[id]/containers/[containerId] + * Get container details + */ +export async function GET( + request: NextRequest, + context: RouteContext +) { + const session = await auth() + if (!session || session.user.userType !== 'staff') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: orderId, containerId } = await context.params + + try { + const client = await createPortainerClient(orderId) + + if (!client) { + return NextResponse.json( + { error: 'Portainer credentials not configured for this order' }, + { status: 400 } + ) + } + + const container = await client.getContainer(containerId) + + // Parse ports from NetworkSettings.Ports (inspect format) + const ports: Array<{ private: number; public?: number; type: string }> = [] + if (container.NetworkSettings.Ports) { + for (const [portKey, bindings] of Object.entries(container.NetworkSettings.Ports)) { + // portKey is like "80/tcp" or "443/tcp" + const [port, type] = portKey.split('/') + const privatePort = parseInt(port, 10) + + if (bindings && bindings.length > 0) { + // Has host bindings + for (const binding of bindings) { + ports.push({ + private: privatePort, + public: binding.HostPort ? parseInt(binding.HostPort, 10) : undefined, + type: type || 'tcp', + }) + } + } else { + // Exposed but not published + ports.push({ + private: privatePort, + type: type || 'tcp', + }) + } + } + } + + // Transform to a more readable format + const formattedContainer = { + id: container.Id, + shortId: container.Id.substring(0, 12), + name: container.Name?.replace(/^\//, '') || container.Id.substring(0, 12), + image: container.Image, + state: container.State.Status, // State is an object in inspect response + status: container.State.Running ? 'running' : container.State.Status, + created: container.Created, + config: { + hostname: container.Config.Hostname, + image: container.Config.Image, + workingDir: container.Config.WorkingDir, + env: container.Config.Env || [], + }, + hostConfig: { + restartPolicy: container.HostConfig.RestartPolicy, + }, + ports, + networks: container.NetworkSettings.Networks, + } + + return NextResponse.json(formattedContainer) + } catch (error) { + console.error('Failed to get container details:', error) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to get container details' }, + { status: 500 } + ) + } +} + +/** + * DELETE /api/v1/admin/orders/[id]/containers/[containerId] + * Remove a container + * Query: ?force=true (force remove running container) + */ +export async function DELETE( + request: NextRequest, + context: RouteContext +) { + const session = await auth() + if (!session || session.user.userType !== 'staff') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: orderId, containerId } = await context.params + const searchParams = request.nextUrl.searchParams + const force = searchParams.get('force') === 'true' + + try { + const client = await createPortainerClient(orderId) + + if (!client) { + return NextResponse.json( + { error: 'Portainer credentials not configured for this order' }, + { status: 400 } + ) + } + + await client.removeContainer(containerId, force) + + return NextResponse.json({ success: true }) + } catch (error) { + console.error('Failed to remove container:', error) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to remove container' }, + { status: 500 } + ) + } +} diff --git a/src/app/api/v1/admin/orders/[id]/containers/[containerId]/stats/route.ts b/src/app/api/v1/admin/orders/[id]/containers/[containerId]/stats/route.ts new file mode 100644 index 0000000..11b9e26 --- /dev/null +++ b/src/app/api/v1/admin/orders/[id]/containers/[containerId]/stats/route.ts @@ -0,0 +1,51 @@ +import { NextRequest, NextResponse } from 'next/server' +import { auth } from '@/lib/auth' +import { createPortainerClient } from '@/lib/services/portainer-client' + +interface RouteContext { + params: Promise<{ id: string; containerId: string }> +} + +/** + * GET /api/v1/admin/orders/[id]/containers/[containerId]/stats + * Get stats for a single container + */ +export async function GET( + request: NextRequest, + context: RouteContext +) { + const session = await auth() + if (!session || session.user.userType !== 'staff') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: orderId, containerId } = await context.params + + try { + const client = await createPortainerClient(orderId) + + if (!client) { + return NextResponse.json( + { error: 'Portainer credentials not configured for this order' }, + { status: 400 } + ) + } + + const stats = await client.getContainerStats(containerId) + + if (!stats) { + return NextResponse.json( + { error: 'Container not running or stats unavailable' }, + { status: 404 } + ) + } + + return NextResponse.json(stats) + } catch (error) { + console.error('Failed to get container stats:', error) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to get container stats' }, + { status: 500 } + ) + } +} diff --git a/src/app/api/v1/admin/orders/[id]/containers/route.ts b/src/app/api/v1/admin/orders/[id]/containers/route.ts new file mode 100644 index 0000000..fdce2c9 --- /dev/null +++ b/src/app/api/v1/admin/orders/[id]/containers/route.ts @@ -0,0 +1,63 @@ +import { NextRequest, NextResponse } from 'next/server' +import { auth } from '@/lib/auth' +import { createPortainerClient } from '@/lib/services/portainer-client' + +interface RouteContext { + params: Promise<{ id: string }> +} + +/** + * GET /api/v1/admin/orders/[id]/containers + * List all containers for an order's Portainer instance + * Query: ?all=true (include stopped containers) + */ +export async function GET( + request: NextRequest, + context: RouteContext +) { + const session = await auth() + if (!session || session.user.userType !== 'staff') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: orderId } = await context.params + const searchParams = request.nextUrl.searchParams + const all = searchParams.get('all') !== 'false' // Default to true + + try { + const client = await createPortainerClient(orderId) + + if (!client) { + return NextResponse.json( + { error: 'Portainer credentials not configured for this order' }, + { status: 400 } + ) + } + + const containers = await client.listContainers(all) + + // Transform to a simpler format for the frontend + const formattedContainers = containers.map((c) => ({ + id: c.Id, + shortId: c.Id.substring(0, 12), + name: c.Names[0]?.replace(/^\//, '') || c.Id.substring(0, 12), + image: c.Image, + state: c.State, + status: c.Status, + created: c.Created, + ports: c.Ports.map((p) => ({ + private: p.PrivatePort, + public: p.PublicPort, + type: p.Type, + })), + })) + + return NextResponse.json(formattedContainers) + } catch (error) { + console.error('Failed to list containers:', error) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to list containers' }, + { status: 500 } + ) + } +} diff --git a/src/app/api/v1/admin/orders/[id]/containers/stats/route.ts b/src/app/api/v1/admin/orders/[id]/containers/stats/route.ts new file mode 100644 index 0000000..8a0824e --- /dev/null +++ b/src/app/api/v1/admin/orders/[id]/containers/stats/route.ts @@ -0,0 +1,44 @@ +import { NextRequest, NextResponse } from 'next/server' +import { auth } from '@/lib/auth' +import { createPortainerClient } from '@/lib/services/portainer-client' + +interface RouteContext { + params: Promise<{ id: string }> +} + +/** + * GET /api/v1/admin/orders/[id]/containers/stats + * Get stats for all running containers + */ +export async function GET( + request: NextRequest, + context: RouteContext +) { + const session = await auth() + if (!session || session.user.userType !== 'staff') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: orderId } = await context.params + + try { + const client = await createPortainerClient(orderId) + + if (!client) { + return NextResponse.json( + { error: 'Portainer credentials not configured for this order' }, + { status: 400 } + ) + } + + const stats = await client.getAllContainerStats() + + return NextResponse.json(stats) + } catch (error) { + console.error('Failed to get container stats:', error) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to get container stats' }, + { status: 500 } + ) + } +} diff --git a/src/app/api/v1/admin/orders/[id]/dns/route.ts b/src/app/api/v1/admin/orders/[id]/dns/route.ts new file mode 100644 index 0000000..e29d14e --- /dev/null +++ b/src/app/api/v1/admin/orders/[id]/dns/route.ts @@ -0,0 +1,37 @@ +import { NextRequest, NextResponse } from 'next/server' +import { auth } from '@/lib/auth' +import { getDnsStatus } from '@/lib/services/dns-service' + +/** + * GET /api/v1/admin/orders/[id]/dns + * Get DNS verification status for an order + */ +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await auth() + + if (!session || session.user.userType !== 'staff') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: orderId } = await params + + const status = await getDnsStatus(orderId) + + return NextResponse.json(status) + } catch (error) { + console.error('Error getting DNS status:', error) + + if (error instanceof Error && error.message === 'Order not found') { + return NextResponse.json({ error: 'Order not found' }, { status: 404 }) + } + + return NextResponse.json( + { error: 'Failed to get DNS status' }, + { status: 500 } + ) + } +} diff --git a/src/app/api/v1/admin/orders/[id]/dns/skip/route.ts b/src/app/api/v1/admin/orders/[id]/dns/skip/route.ts new file mode 100644 index 0000000..de51eca --- /dev/null +++ b/src/app/api/v1/admin/orders/[id]/dns/skip/route.ts @@ -0,0 +1,53 @@ +import { NextRequest, NextResponse } from 'next/server' +import { auth } from '@/lib/auth' +import { skipDnsVerification, getDnsStatus } from '@/lib/services/dns-service' +import { processAutomation } from '@/lib/services/automation-worker' + +/** + * POST /api/v1/admin/orders/[id]/dns/skip + * Skip DNS verification with manual override (staff only) + * + * This allows staff to bypass DNS verification when they know + * DNS is configured correctly but checks are failing (e.g., DNS propagation delay). + */ +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await auth() + + if (!session || session.user.userType !== 'staff') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: orderId } = await params + + // Skip DNS verification + await skipDnsVerification(orderId) + + // Get updated status + const status = await getDnsStatus(orderId) + + // Process automation (will auto-trigger next step if in AUTO mode) + const automationResult = await processAutomation(orderId) + + return NextResponse.json({ + success: true, + message: 'DNS verification skipped via manual override', + status, + automation: automationResult, + }) + } catch (error) { + console.error('Error skipping DNS verification:', error) + + if (error instanceof Error && error.message === 'Order not found') { + return NextResponse.json({ error: 'Order not found' }, { status: 404 }) + } + + return NextResponse.json( + { error: 'Failed to skip DNS verification' }, + { status: 500 } + ) + } +} diff --git a/src/app/api/v1/admin/orders/[id]/dns/verify/route.ts b/src/app/api/v1/admin/orders/[id]/dns/verify/route.ts new file mode 100644 index 0000000..d64fd0a --- /dev/null +++ b/src/app/api/v1/admin/orders/[id]/dns/verify/route.ts @@ -0,0 +1,54 @@ +import { NextRequest, NextResponse } from 'next/server' +import { auth } from '@/lib/auth' +import { runDnsVerification } from '@/lib/services/dns-service' +import { processAutomation } from '@/lib/services/automation-worker' + +/** + * POST /api/v1/admin/orders/[id]/dns/verify + * Trigger DNS verification for an order + */ +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await auth() + + if (!session || session.user.userType !== 'staff') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: orderId } = await params + + // Run DNS verification + const result = await runDnsVerification(orderId) + + // Process automation (will auto-trigger next step if in AUTO mode) + const automationResult = await processAutomation(orderId) + + return NextResponse.json({ + success: true, + verification: result, + automation: automationResult, + }) + } catch (error) { + console.error('Error verifying DNS:', error) + + if (error instanceof Error) { + if (error.message === 'Order not found') { + return NextResponse.json({ error: 'Order not found' }, { status: 404 }) + } + if (error.message === 'Server IP not configured') { + return NextResponse.json( + { error: 'Server IP not configured. Please add server credentials first.' }, + { status: 400 } + ) + } + } + + return NextResponse.json( + { error: 'Failed to verify DNS' }, + { status: 500 } + ) + } +} diff --git a/src/app/api/v1/admin/orders/[id]/logs/stream/route.ts b/src/app/api/v1/admin/orders/[id]/logs/stream/route.ts index 1328cc4..f72ea44 100644 --- a/src/app/api/v1/admin/orders/[id]/logs/stream/route.ts +++ b/src/app/api/v1/admin/orders/[id]/logs/stream/route.ts @@ -3,9 +3,17 @@ import { auth } from '@/lib/auth' import { prisma } from '@/lib/prisma' import { OrderStatus } from '@prisma/client' +// Polling interval in milliseconds - faster = smoother logs +const POLL_INTERVAL_MS = 500 + /** * GET /api/v1/admin/orders/[id]/logs/stream * Stream provisioning logs via Server-Sent Events + * + * Optimized for low-latency log streaming: + * - 500ms polling interval for near-real-time updates + * - Only sends status updates when changed + * - Batches multiple logs per event for efficiency */ export async function GET( request: NextRequest, @@ -33,6 +41,7 @@ export async function GET( // Create SSE response const encoder = new TextEncoder() let lastLogId: string | null = null + let lastStatus: OrderStatus = order.status let isActive = true const stream = new ReadableStream({ @@ -42,16 +51,31 @@ export async function GET( encoder.encode(`event: connected\ndata: ${JSON.stringify({ orderId })}\n\n`) ) + // Send initial status + controller.enqueue( + encoder.encode(`event: status\ndata: ${JSON.stringify({ status: order.status })}\n\n`) + ) + // Poll for new logs const poll = async () => { if (!isActive) return try { - // Get current order status - const currentOrder = await prisma.order.findUnique({ - where: { id: orderId }, - select: { status: true }, - }) + // Fetch new logs and current status in parallel for speed + const [newLogs, currentOrder] = await Promise.all([ + prisma.provisioningLog.findMany({ + where: { + orderId, + ...(lastLogId ? { id: { gt: lastLogId } } : {}), + }, + orderBy: { timestamp: 'asc' }, + take: 100, // Increased batch size + }), + prisma.order.findUnique({ + where: { id: orderId }, + select: { status: true }, + }), + ]) if (!currentOrder) { controller.enqueue( @@ -61,23 +85,11 @@ export async function GET( return } - // Build query for new logs - const query: Parameters[0] = { - where: { - orderId, - ...(lastLogId ? { id: { gt: lastLogId } } : {}), - }, - orderBy: { timestamp: 'asc' as const }, - take: 50, - } - - const newLogs = await prisma.provisioningLog.findMany(query) - + // Send logs immediately as they arrive if (newLogs.length > 0) { - // Update last seen log ID lastLogId = newLogs[newLogs.length - 1].id - // Send each log as an event + // Send each log individually for smooth streaming for (const log of newLogs) { controller.enqueue( encoder.encode(`event: log\ndata: ${JSON.stringify({ @@ -91,10 +103,13 @@ export async function GET( } } - // Send status update - controller.enqueue( - encoder.encode(`event: status\ndata: ${JSON.stringify({ status: currentOrder.status })}\n\n`) - ) + // Only send status update if changed + if (currentOrder.status !== lastStatus) { + lastStatus = currentOrder.status + controller.enqueue( + encoder.encode(`event: status\ndata: ${JSON.stringify({ status: currentOrder.status })}\n\n`) + ) + } // Check if provisioning is complete const terminalStatuses: OrderStatus[] = [ @@ -114,8 +129,8 @@ export async function GET( return } - // Continue polling if still provisioning - setTimeout(poll, 2000) // Poll every 2 seconds + // Continue polling with fast interval + setTimeout(poll, POLL_INTERVAL_MS) } catch (err) { console.error('SSE polling error:', err) controller.enqueue( @@ -125,7 +140,7 @@ export async function GET( } } - // Start polling + // Start polling immediately poll() }, cancel() { diff --git a/src/app/api/v1/admin/orders/[id]/portainer/init/route.ts b/src/app/api/v1/admin/orders/[id]/portainer/init/route.ts new file mode 100644 index 0000000..81ef8c1 --- /dev/null +++ b/src/app/api/v1/admin/orders/[id]/portainer/init/route.ts @@ -0,0 +1,135 @@ +import { NextRequest, NextResponse } from 'next/server' +import { auth } from '@/lib/auth' +import { prisma } from '@/lib/prisma' +import { credentialService } from '@/lib/services/credential-service' +import { Agent, fetch as undiciFetch, FormData as UndiciFormData } from 'undici' + +// Create undici agent that accepts self-signed certificates +const insecureAgent = new Agent({ + connect: { + rejectUnauthorized: false, + }, +}) + +/** + * POST /api/v1/admin/orders/[id]/portainer/init + * Initialize Portainer by creating the local Docker endpoint + * This is needed when Portainer is set up with --admin-password-file (skips wizard) + */ +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + // Auth check + const session = await auth() + if (!session || session.user.userType !== 'staff') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: orderId } = await params + + // Get order with Portainer credentials + const order = await prisma.order.findUnique({ + where: { id: orderId }, + select: { + serverIp: true, + portainerUsername: true, + portainerPasswordEnc: true, + }, + }) + + if (!order) { + return NextResponse.json({ error: 'Order not found' }, { status: 404 }) + } + + if (!order.serverIp || !order.portainerUsername || !order.portainerPasswordEnc) { + return NextResponse.json( + { error: 'Portainer credentials not configured' }, + { status: 400 } + ) + } + + const portainerUrl = `https://${order.serverIp}:9443` + const password = credentialService.decrypt(order.portainerPasswordEnc) + + try { + // Step 1: Authenticate with Portainer + const authResponse = await undiciFetch(`${portainerUrl}/api/auth`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + username: order.portainerUsername, + password: password, + }), + dispatcher: insecureAgent, + }) + + if (!authResponse.ok) { + const error = await authResponse.text() + return NextResponse.json( + { error: `Authentication failed: ${error}` }, + { status: 401 } + ) + } + + const authData = await authResponse.json() as { jwt: string } + const jwt = authData.jwt + + // Step 2: Check if endpoint already exists + const endpointsResponse = await undiciFetch(`${portainerUrl}/api/endpoints`, { + headers: { + Authorization: `Bearer ${jwt}`, + }, + dispatcher: insecureAgent, + }) + + if (endpointsResponse.ok) { + const endpoints = await endpointsResponse.json() as Array<{ Id: number; Name: string }> + if (endpoints.length > 0) { + return NextResponse.json({ + success: true, + message: 'Endpoint already exists', + endpoint: endpoints[0], + }) + } + } + + // Step 3: Create local Docker socket endpoint + const formData = new UndiciFormData() + formData.append('Name', 'local') + formData.append('EndpointCreationType', '1') // 1 = Docker socket + + const createResponse = await undiciFetch(`${portainerUrl}/api/endpoints`, { + method: 'POST', + headers: { + Authorization: `Bearer ${jwt}`, + }, + body: formData, + dispatcher: insecureAgent, + }) + + if (!createResponse.ok) { + const error = await createResponse.text() + return NextResponse.json( + { error: `Failed to create endpoint: ${error}` }, + { status: 500 } + ) + } + + const endpoint = await createResponse.json() as { Id: number; Name: string } + + return NextResponse.json({ + success: true, + message: 'Local Docker endpoint created successfully', + endpoint, + }) + } catch (error) { + console.error('Failed to initialize Portainer:', error) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Unknown error' }, + { status: 500 } + ) + } +} diff --git a/src/app/api/v1/admin/orders/[id]/portainer/route.ts b/src/app/api/v1/admin/orders/[id]/portainer/route.ts new file mode 100644 index 0000000..006fda3 --- /dev/null +++ b/src/app/api/v1/admin/orders/[id]/portainer/route.ts @@ -0,0 +1,153 @@ +import { NextRequest, NextResponse } from 'next/server' +import { auth } from '@/lib/auth' +import { prisma } from '@/lib/prisma' +import { credentialService } from '@/lib/services/credential-service' +import { createPortainerClient } from '@/lib/services/portainer-client' + +interface RouteContext { + params: Promise<{ id: string }> +} + +/** + * GET /api/v1/admin/orders/[id]/portainer + * Get Portainer credentials for an order (decrypted) + */ +export async function GET( + request: NextRequest, + context: RouteContext +) { + const session = await auth() + if (!session || session.user.userType !== 'staff') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: orderId } = await context.params + + const order = await prisma.order.findUnique({ + where: { id: orderId }, + select: { + id: true, + serverIp: true, + portainerUsername: true, + portainerPasswordEnc: true, + credentialsSyncedAt: true, + }, + }) + + if (!order) { + return NextResponse.json({ error: 'Order not found' }, { status: 404 }) + } + + // Construct Portainer URL from server IP (Portainer is accessed directly, not via subdomain) + const portainerUrl = order.serverIp ? `https://${order.serverIp}:9443` : null + + // Decrypt password if present + let password: string | null = null + if (order.portainerPasswordEnc) { + try { + password = credentialService.decrypt(order.portainerPasswordEnc) + } catch (error) { + console.error('Failed to decrypt Portainer password:', error) + } + } + + return NextResponse.json({ + url: portainerUrl, + username: order.portainerUsername, + password, + syncedAt: order.credentialsSyncedAt, + isConfigured: !!(order.portainerUsername && order.portainerPasswordEnc), + }) +} + +/** + * PATCH /api/v1/admin/orders/[id]/portainer + * Update Portainer credentials (manual entry) + */ +export async function PATCH( + request: NextRequest, + context: RouteContext +) { + const session = await auth() + if (!session || session.user.userType !== 'staff') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: orderId } = await context.params + + const order = await prisma.order.findUnique({ + where: { id: orderId }, + select: { id: true }, + }) + + if (!order) { + return NextResponse.json({ error: 'Order not found' }, { status: 404 }) + } + + const body = await request.json() + const { username, password } = body + + // Build update data + const updateData: { + portainerUsername?: string + portainerPasswordEnc?: string + credentialsSyncedAt?: Date + } = {} + + if (username !== undefined) { + updateData.portainerUsername = username + } + + if (password !== undefined) { + updateData.portainerPasswordEnc = credentialService.encrypt(password) + } + + // Mark as manually entered (update sync timestamp) + updateData.credentialsSyncedAt = new Date() + + await prisma.order.update({ + where: { id: orderId }, + data: updateData, + }) + + return NextResponse.json({ success: true }) +} + +/** + * POST /api/v1/admin/orders/[id]/portainer + * Test Portainer connection + */ +export async function POST( + request: NextRequest, + context: RouteContext +) { + const session = await auth() + if (!session || session.user.userType !== 'staff') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: orderId } = await context.params + + try { + const client = await createPortainerClient(orderId) + + if (!client) { + return NextResponse.json({ + success: false, + error: 'Portainer credentials not configured', + }) + } + + const connected = await client.testConnection() + + return NextResponse.json({ + success: connected, + error: connected ? null : 'Failed to connect to Portainer', + }) + } catch (error) { + return NextResponse.json({ + success: false, + error: error instanceof Error ? error.message : 'Connection test failed', + }) + } +} diff --git a/src/app/api/v1/admin/orders/[id]/provision/route.ts b/src/app/api/v1/admin/orders/[id]/provision/route.ts index f8fefcd..689238d 100644 --- a/src/app/api/v1/admin/orders/[id]/provision/route.ts +++ b/src/app/api/v1/admin/orders/[id]/provision/route.ts @@ -1,12 +1,26 @@ import { NextRequest, NextResponse } from 'next/server' import { auth } from '@/lib/auth' import { prisma } from '@/lib/prisma' -import { OrderStatus } from '@prisma/client' -import { jobService } from '@/lib/services/job-service' +import { JobStatus, OrderStatus } from '@prisma/client' +import { randomBytes } from 'crypto' +import { + generateJobConfig, + decryptPassword, + generateRunnerToken, + hashRunnerToken, + DockerHubCredentials, +} from '@/lib/services/config-generator' +import { spawnProvisioningContainer, isDockerAvailable } from '@/lib/services/docker-spawner' +import { netcupService } from '@/lib/services/netcup-service' +import { settingsService } from '@/lib/services/settings-service' /** * POST /api/v1/admin/orders/[id]/provision * Trigger provisioning for an order + * + * This spawns a Docker container to handle provisioning asynchronously. + * The container streams logs back to the Hub via the job logs API. + * Use the SSE endpoint at /logs/stream to monitor progress in real-time. */ export async function POST( request: NextRequest, @@ -24,18 +38,25 @@ export async function POST( // Check if order exists and is ready for provisioning const order = await prisma.order.findUnique({ where: { id: orderId }, + include: { + serverConnection: true, + }, }) if (!order) { return NextResponse.json({ error: 'Order not found' }, { status: 404 }) } - // Validate order status - can only provision from DNS_READY or FAILED - const validStatuses: OrderStatus[] = [OrderStatus.DNS_READY, OrderStatus.FAILED] + // Validate order status - can only provision from SERVER_READY, DNS_READY, or FAILED + const validStatuses: OrderStatus[] = [ + OrderStatus.SERVER_READY, + OrderStatus.DNS_READY, + OrderStatus.FAILED, + ] if (!validStatuses.includes(order.status)) { return NextResponse.json( { - error: `Cannot provision order in status ${order.status}. Must be DNS_READY or FAILED.`, + error: `Cannot provision order in status ${order.status}. Must be SERVER_READY, DNS_READY, or FAILED.`, }, { status: 400 } ) @@ -49,14 +70,219 @@ export async function POST( ) } - // Create provisioning job - const result = await jobService.createJobForOrder(orderId) - const { jobId } = JSON.parse(result) + // Validate provisioning config + if (!order.customer || !order.companyName || !order.licenseKey) { + return NextResponse.json( + { error: 'Provisioning config not configured. Please set customer, company name, and license key.' }, + { status: 400 } + ) + } + + // Check Docker availability + if (!await isDockerAvailable()) { + return NextResponse.json( + { error: 'Docker is not available on the server' }, + { status: 503 } + ) + } + + // Set Netcup hostname if server is linked + // Hostname format: {customer}-{first 8 chars of orderId} + if (order.netcupServerId && order.customer) { + try { + await netcupService.setServerHostname(order.netcupServerId, order.customer, orderId) + const hostname = `${order.customer.toLowerCase().replace(/[^a-z0-9]/g, '')}-${orderId.slice(0, 8)}` + + await prisma.provisioningLog.create({ + data: { + orderId, + level: 'INFO', + message: `Set Netcup server hostname to: ${hostname}`, + step: 'init', + }, + }) + } catch (error) { + // Not critical - log and continue + console.error('Failed to set Netcup hostname:', error) + await prisma.provisioningLog.create({ + data: { + orderId, + level: 'WARN', + message: `Could not set Netcup hostname: ${error instanceof Error ? error.message : 'Unknown error'}`, + step: 'init', + }, + }) + } + } + + // Create or update server connection with registration token + let serverConnection = order.serverConnection + const registrationToken = `rt_${randomBytes(32).toString('hex')}` + + if (!serverConnection) { + // Generate registration token for phone-home + serverConnection = await prisma.serverConnection.create({ + data: { + orderId, + registrationToken, + status: 'PENDING', + }, + }) + } else { + // Regenerate token if retrying + serverConnection = await prisma.serverConnection.update({ + where: { id: serverConnection.id }, + data: { + registrationToken, + status: 'PENDING', + hubApiKey: null, // Clear old API key + registeredAt: null, + lastHeartbeat: null, + }, + }) + } + + // Generate runner token for container authentication + const runnerToken = generateRunnerToken() + const runnerTokenHash = hashRunnerToken(runnerToken) + + // Decrypt server password + const serverPassword = decryptPassword(order.serverPasswordEncrypted) + + // Fetch Docker Hub credentials from settings + const dockerHubSettings = await settingsService.getDockerHubCredentials() + let dockerHub: DockerHubCredentials | undefined + if (dockerHubSettings.username && dockerHubSettings.token) { + dockerHub = { + username: dockerHubSettings.username, + token: dockerHubSettings.token, + registry: dockerHubSettings.registry || undefined, + } + console.log(`[Provision] Using Docker Hub credentials for user: ${dockerHubSettings.username}`) + } + + // Generate job config + const jobConfig = generateJobConfig(order, serverPassword, dockerHub) + + // Create provisioning job record + const job = await prisma.provisioningJob.create({ + data: { + orderId, + jobType: 'PROVISION', + status: JobStatus.PENDING, + configSnapshot: jobConfig as object, + runnerTokenHash, + attempt: 1, + maxAttempts: 3, + }, + }) + + // Update order status to PROVISIONING + await prisma.order.update({ + where: { id: orderId }, + data: { + status: OrderStatus.PROVISIONING, + provisioningStartedAt: new Date(), + }, + }) + + // Log the provisioning start + await prisma.provisioningLog.create({ + data: { + orderId, + level: 'INFO', + message: `Provisioning initiated. Spawning Docker container for job ${job.id}.`, + step: 'init', + }, + }) + + // Determine Hub API URL for container callback + // In development, Docker containers need to use host.docker.internal instead of localhost + // IMPORTANT: Next.js auto-increments port if 3000 is in use, so we detect the actual port + let hubApiUrl: string + if (process.env.HUB_URL) { + // Production: use explicit HUB_URL + hubApiUrl = process.env.HUB_URL + } else { + // Development: detect actual port from request headers + const host = request.headers.get('host') || 'localhost:3000' + const port = host.split(':')[1] || '3000' + hubApiUrl = `http://host.docker.internal:${port}` + } + + // Spawn provisioning container + const spawnResult = await spawnProvisioningContainer( + job.id, + jobConfig, + runnerToken, + hubApiUrl + ) + + if (!spawnResult.success) { + // Update job status to failed + await prisma.provisioningJob.update({ + where: { id: job.id }, + data: { + status: JobStatus.FAILED, + error: spawnResult.error, + completedAt: new Date(), + }, + }) + + // Update order status to failed + await prisma.order.update({ + where: { id: orderId }, + data: { + status: OrderStatus.FAILED, + failureReason: `Failed to spawn provisioning container: ${spawnResult.error}`, + }, + }) + + // Log the failure + await prisma.provisioningLog.create({ + data: { + orderId, + level: 'ERROR', + message: `Failed to spawn container: ${spawnResult.error}`, + step: 'init', + }, + }) + + return NextResponse.json( + { + success: false, + error: spawnResult.error || 'Failed to spawn provisioning container', + }, + { status: 500 } + ) + } + + // Update job with container info + await prisma.provisioningJob.update({ + where: { id: job.id }, + data: { + status: JobStatus.RUNNING, + containerName: spawnResult.containerName, + claimedAt: new Date(), + }, + }) + + // Log successful spawn + await prisma.provisioningLog.create({ + data: { + orderId, + level: 'INFO', + message: `Container ${spawnResult.containerName} spawned successfully.`, + step: 'init', + }, + }) return NextResponse.json({ success: true, - message: 'Provisioning job created', - jobId, + message: 'Provisioning container spawned', + jobId: job.id, + containerName: spawnResult.containerName, + serverConnectionId: serverConnection.id, }) } catch (error) { console.error('Error triggering provisioning:', error) diff --git a/src/app/api/v1/admin/orders/[id]/route.ts b/src/app/api/v1/admin/orders/[id]/route.ts index 0f70fd6..8d17036 100644 --- a/src/app/api/v1/admin/orders/[id]/route.ts +++ b/src/app/api/v1/admin/orders/[id]/route.ts @@ -1,8 +1,12 @@ import { NextRequest, NextResponse } from 'next/server' +import crypto from 'crypto' import { auth } from '@/lib/auth' import { prisma } from '@/lib/prisma' import { OrderStatus } from '@prisma/client' -import crypto from 'crypto' +import { processAutomation } from '@/lib/services/automation-worker' +import { netcupService } from '@/lib/services/netcup-service' +import { runDnsVerification } from '@/lib/services/dns-service' +import { credentialService } from '@/lib/services/credential-service' /** * GET /api/v1/admin/orders/[id] @@ -101,6 +105,12 @@ export async function PATCH( sshPort?: number serverReadyAt?: Date failureReason?: string + tools?: string[] + domain?: string + customer?: string + companyName?: string + licenseKey?: string + netcupServerId?: string } = {} // Handle status update @@ -111,25 +121,136 @@ export async function PATCH( // Handle server credentials if (body.serverIp) { updateData.serverIp = body.serverIp + + // Try to auto-link to Netcup server by IP + if (!existingOrder.netcupServerId) { + try { + const netcupServer = await netcupService.findServerByIp(body.serverIp) + if (netcupServer) { + updateData.netcupServerId = netcupServer.id + console.log(`Auto-linked order ${orderId} to Netcup server ${netcupServer.id} (${netcupServer.name})`) + } + } catch (error) { + // Not critical - just log and continue + console.log('Could not auto-link Netcup server:', error instanceof Error ? error.message : 'Not authenticated') + } + } + } + + // Handle explicit netcupServerId (manual linking) + if (body.netcupServerId) { + updateData.netcupServerId = body.netcupServerId } if (body.serverPassword) { // Encrypt the password before storing - // TODO: Use proper encryption with environment-based key - const encrypted = encryptPassword(body.serverPassword) - updateData.serverPasswordEncrypted = encrypted + updateData.serverPasswordEncrypted = credentialService.encrypt(body.serverPassword) } if (body.sshPort) { updateData.sshPort = body.sshPort } - // If server credentials are being set and status is AWAITING_SERVER, move to SERVER_READY + // Handle tools update (only before provisioning starts) + if (body.tools && Array.isArray(body.tools)) { + // Only allow tools update before provisioning + const provisioningStatuses: OrderStatus[] = [ + OrderStatus.PROVISIONING, + OrderStatus.FULFILLED, + OrderStatus.EMAIL_CONFIGURED, + ] + if (provisioningStatuses.includes(existingOrder.status)) { + return NextResponse.json( + { error: 'Cannot modify tools after provisioning has started' }, + { status: 400 } + ) + } + updateData.tools = body.tools + } + + // Handle domain update (only before provisioning starts) + if (body.domain) { + const provisioningStatuses: OrderStatus[] = [ + OrderStatus.PROVISIONING, + OrderStatus.FULFILLED, + OrderStatus.EMAIL_CONFIGURED, + ] + if (provisioningStatuses.includes(existingOrder.status)) { + return NextResponse.json( + { error: 'Cannot modify domain after provisioning has started' }, + { status: 400 } + ) + } + updateData.domain = body.domain + } + + // Handle provisioning config fields (only before provisioning starts) + if (body.customer !== undefined || body.companyName !== undefined || body.licenseKey !== undefined) { + const provisioningStatuses: OrderStatus[] = [ + OrderStatus.PROVISIONING, + OrderStatus.FULFILLED, + OrderStatus.EMAIL_CONFIGURED, + ] + if (provisioningStatuses.includes(existingOrder.status)) { + return NextResponse.json( + { error: 'Cannot modify provisioning config after provisioning has started' }, + { status: 400 } + ) + } + if (body.customer) { + updateData.customer = body.customer + } + if (body.companyName) { + updateData.companyName = body.companyName + } + if (body.licenseKey) { + updateData.licenseKey = body.licenseKey + } + } + + // Track if we should trigger DNS verification after update + let shouldTriggerDnsVerification = false + + // Auto-transition when credentials are saved: + // AWAITING_SERVER → DNS_PENDING (skip SERVER_READY, go straight to DNS verification) + // This happens regardless of automation mode if ( (body.serverIp || body.serverPassword) && - existingOrder.status === OrderStatus.AWAITING_SERVER + existingOrder.status === OrderStatus.AWAITING_SERVER && + !body.status // Only auto-transition if no explicit status is provided + ) { + updateData.status = OrderStatus.DNS_PENDING + updateData.serverReadyAt = new Date() + shouldTriggerDnsVerification = true + + // Auto-populate provisioning config if not already set + // This ensures provisioning can proceed without manual config step + if (!existingOrder.customer && !updateData.customer) { + // Generate customer identifier from domain (e.g., "example.com" → "example") + const domain = existingOrder.domain || '' + updateData.customer = domain.split('.')[0].toLowerCase().replace(/[^a-z0-9]/g, '') || 'customer' + } + if (!existingOrder.companyName && !updateData.companyName) { + // Use user's company or name, or derive from domain + const user = await prisma.user.findUnique({ where: { id: existingOrder.userId } }) + updateData.companyName = user?.company || user?.name || existingOrder.domain || 'Company' + } + if (!existingOrder.licenseKey && !updateData.licenseKey) { + // Generate a unique license key + updateData.licenseKey = `LB-${crypto.randomBytes(8).toString('hex').toUpperCase()}` + } + } + + // Also trigger DNS verification when explicitly setting status to DNS_PENDING + if (body.status === OrderStatus.DNS_PENDING) { + shouldTriggerDnsVerification = true + } + + // Set serverReadyAt when explicitly setting status to SERVER_READY or DNS_PENDING + if ( + (body.status === OrderStatus.SERVER_READY || body.status === OrderStatus.DNS_PENDING) && + !existingOrder.serverReadyAt ) { - updateData.status = OrderStatus.SERVER_READY updateData.serverReadyAt = new Date() } @@ -143,11 +264,43 @@ export async function PATCH( id: true, name: true, email: true, + company: true, }, }, }, }) + // Trigger DNS verification if credentials were saved or status changed to DNS_PENDING + // This runs regardless of automation mode - always verify DNS when credentials are ready + if (shouldTriggerDnsVerification && order.serverIp) { + try { + console.log(`Triggering DNS verification for order ${orderId}`) + const dnsResult = await runDnsVerification(orderId) + console.log( + `DNS verification for order ${orderId}: ${dnsResult.allPassed ? 'PASSED' : 'PENDING'} ` + + `(${dnsResult.passedCount}/${dnsResult.totalSubdomains} subdomains)` + ) + // Note: runDnsVerification automatically transitions to DNS_READY if all checks pass + } catch (error) { + // Log but don't fail the request - DNS verification can be retried + console.error('DNS verification failed:', error) + } + } + + // Trigger automation processing if credentials were saved or status changed + // This will check if the order is in AUTO mode and process the next step + if (body.serverIp || body.serverPassword || body.status) { + try { + const result = await processAutomation(orderId) + if (result.triggered) { + console.log(`Automation triggered for order ${orderId}: ${result.action}`) + } + } catch (error) { + // Log but don't fail the request - automation is async + console.error('Automation processing failed:', error) + } + } + return NextResponse.json(order) } catch (error) { console.error('Error updating order:', error) @@ -158,18 +311,72 @@ export async function PATCH( } } -// Helper function to encrypt password -function encryptPassword(password: string): string { - // TODO: Implement proper encryption using environment-based key - // For now, use a simple encryption for development - const key = crypto.scryptSync( - process.env.ENCRYPTION_KEY || 'dev-key-change-in-production', - 'salt', - 32 - ) - const iv = crypto.randomBytes(16) - const cipher = crypto.createCipheriv('aes-256-cbc', key, iv) - let encrypted = cipher.update(password, 'utf8', 'hex') - encrypted += cipher.final('hex') - return iv.toString('hex') + ':' + encrypted +/** + * DELETE /api/v1/admin/orders/[id] + * Delete an order and all related records (logs, jobs, DNS verification) + * Does NOT touch the actual server - just removes from Hub database + */ +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await auth() + + if (!session || session.user.userType !== 'staff') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: orderId } = await params + + // Find existing order + const existingOrder = await prisma.order.findUnique({ + where: { id: orderId }, + include: { + dnsVerification: true, + }, + }) + + if (!existingOrder) { + return NextResponse.json({ error: 'Order not found' }, { status: 404 }) + } + + // Delete in correct order to respect foreign key constraints + // 1. Delete DNS records (if DNS verification exists) + if (existingOrder.dnsVerification) { + await prisma.dnsRecord.deleteMany({ + where: { dnsVerificationId: existingOrder.dnsVerification.id }, + }) + await prisma.dnsVerification.delete({ + where: { id: existingOrder.dnsVerification.id }, + }) + } + + // 2. Delete provisioning logs + await prisma.provisioningLog.deleteMany({ + where: { orderId }, + }) + + // 3. Delete jobs + await prisma.provisioningJob.deleteMany({ + where: { orderId }, + }) + + // 4. Delete the order itself + await prisma.order.delete({ + where: { id: orderId }, + }) + + return NextResponse.json({ + success: true, + message: `Order ${orderId} and all related records deleted`, + }) + } catch (error) { + console.error('Error deleting order:', error) + return NextResponse.json( + { error: 'Failed to delete order' }, + { status: 500 } + ) + } } + diff --git a/src/app/api/v1/admin/orders/[id]/test-ssh/route.ts b/src/app/api/v1/admin/orders/[id]/test-ssh/route.ts new file mode 100644 index 0000000..7836c13 --- /dev/null +++ b/src/app/api/v1/admin/orders/[id]/test-ssh/route.ts @@ -0,0 +1,131 @@ +import { NextRequest, NextResponse } from 'next/server' +import { auth } from '@/lib/auth' +import { prisma } from '@/lib/prisma' +import { testServerConnection } from '@/lib/ansible' +import { SSH_PORT_BEFORE_PROVISION } from '@/lib/ssh/constants' +import { decryptPassword } from '@/lib/services/config-generator' + +interface RouteContext { + params: Promise<{ id: string }> +} + +/** + * POST /api/v1/admin/orders/[id]/test-ssh + * Test SSH connection to the server + * + * Accepts optional body with credentials to test before saving: + * { serverIp?: string, password?: string, sshPort?: number } + * + * If body credentials provided, uses those (for testing before save) + * Otherwise uses saved order credentials + */ +export async function POST(request: NextRequest, context: RouteContext) { + try { + const session = await auth() + + if (!session || session.user.userType !== 'staff') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: orderId } = await context.params + + // Parse optional body for testing unsaved credentials + let body: { serverIp?: string; password?: string; sshPort?: number } = {} + try { + body = await request.json() + } catch { + // No body provided, will use saved credentials + } + + // Get order + const order = await prisma.order.findUnique({ + where: { id: orderId }, + }) + + if (!order) { + return NextResponse.json({ error: 'Order not found' }, { status: 404 }) + } + + // Determine which credentials to use + // Priority: body params > saved order data + const serverIp = body.serverIp || order.serverIp + const sshPort = body.sshPort || order.sshPort || SSH_PORT_BEFORE_PROVISION + + // For password: use body.password directly, or decrypt saved password + let password: string | null = null + if (body.password) { + password = body.password + } else if (order.serverPasswordEncrypted) { + password = decryptPassword(order.serverPasswordEncrypted) + } + + // Validate we have required credentials + if (!serverIp) { + return NextResponse.json( + { error: 'Server IP not configured' }, + { status: 400 } + ) + } + + if (!password) { + return NextResponse.json( + { error: 'Server password not configured' }, + { status: 400 } + ) + } + + // Test SSH connection + const result = await testServerConnection( + serverIp, + password, + sshPort + ) + + if (result.success) { + // Log successful test + await prisma.provisioningLog.create({ + data: { + orderId, + level: 'INFO', + message: `SSH connection test successful to ${serverIp}:${sshPort} (latency: ${result.latency}ms)`, + step: 'ssh-test', + }, + }) + + return NextResponse.json({ + success: true, + latency: result.latency, + message: 'SSH connection successful', + serverIp, + sshPort, + }) + } else { + // Log failed test + await prisma.provisioningLog.create({ + data: { + orderId, + level: 'ERROR', + message: `SSH connection test failed to ${serverIp}:${sshPort}: ${result.error}`, + step: 'ssh-test', + }, + }) + + return NextResponse.json( + { + success: false, + error: result.error, + message: 'SSH connection failed', + serverIp, + sshPort, + }, + { status: 400 } + ) + } + } catch (error) { + console.error('SSH test error:', error) + return NextResponse.json( + { error: 'Failed to test SSH connection' }, + { status: 500 } + ) + } +} diff --git a/src/app/api/v1/admin/orders/route.ts b/src/app/api/v1/admin/orders/route.ts index 403b85c..7487a40 100644 --- a/src/app/api/v1/admin/orders/route.ts +++ b/src/app/api/v1/admin/orders/route.ts @@ -1,7 +1,27 @@ import { NextRequest, NextResponse } from 'next/server' +import { randomBytes } from 'crypto' import { auth } from '@/lib/auth' import { prisma } from '@/lib/prisma' -import { OrderStatus, SubscriptionTier, Prisma } from '@prisma/client' +import { OrderStatus, SubscriptionTier, AutomationMode, Prisma } from '@prisma/client' + +/** + * Generate a customer ID slug from company/user name + * Only lowercase letters allowed (env_setup.sh requires ^[a-z]+$) + */ +function slugifyCustomer(name: string): string { + return name + .toLowerCase() + .replace(/[^a-z]/g, '') + .substring(0, 32) +} + +/** + * Generate a unique license key for the order + */ +function generateLicenseKey(): string { + const hex = randomBytes(16).toString('hex') + return `lb_inst_${hex}` +} /** * GET /api/v1/admin/orders @@ -112,7 +132,13 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'User not found' }, { status: 404 }) } - // Create order + // Auto-generate provisioning config from user's company/name + const displayName = user.company || user.name || 'customer' + const customer = slugifyCustomer(displayName) || 'customer' + const companyName = displayName + const licenseKey = generateLicenseKey() + + // Create order with MANUAL automation mode (staff-created) const order = await prisma.order.create({ data: { userId, @@ -121,6 +147,12 @@ export async function POST(request: NextRequest) { tools, status: OrderStatus.PAYMENT_CONFIRMED, configJson: { tools, tier, domain }, + automationMode: AutomationMode.MANUAL, + source: 'staff', + // Auto-generated provisioning config + customer, + companyName, + licenseKey, }, include: { user: { diff --git a/src/app/api/v1/admin/portainer/ping/route.ts b/src/app/api/v1/admin/portainer/ping/route.ts new file mode 100644 index 0000000..50e3ecd --- /dev/null +++ b/src/app/api/v1/admin/portainer/ping/route.ts @@ -0,0 +1,114 @@ +import { NextRequest, NextResponse } from 'next/server' +import { auth } from '@/lib/auth' +import https from 'https' + +/** + * GET /api/v1/admin/portainer/ping + * Check if Portainer is reachable at a given IP + * + * Query params: + * - ip: Server IP address + * - port: Portainer port (default: 9443) + */ +export async function GET(request: NextRequest) { + try { + const session = await auth() + + if (!session || session.user.userType !== 'staff') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const searchParams = request.nextUrl.searchParams + const ip = searchParams.get('ip') + const port = searchParams.get('port') || '9443' + + if (!ip) { + return NextResponse.json( + { error: 'IP address required' }, + { status: 400 } + ) + } + + // Validate IP format (basic check) + const ipRegex = /^(\d{1,3}\.){3}\d{1,3}$/ + if (!ipRegex.test(ip)) { + return NextResponse.json( + { error: 'Invalid IP address format' }, + { status: 400 } + ) + } + + // Use native https module to handle self-signed certificates + const result = await new Promise<{ available: boolean; version?: string; instanceId?: string | null; needsSetup?: boolean; error?: string }>((resolve) => { + const req = https.request( + { + hostname: ip, + port: parseInt(port), + path: '/api/status', + method: 'GET', + timeout: 5000, + rejectUnauthorized: false, // Accept self-signed certificates + }, + (res) => { + let data = '' + res.on('data', (chunk) => { + data += chunk + }) + res.on('end', () => { + if (res.statusCode === 200) { + try { + const json = JSON.parse(data) + // InstanceID is only set after Portainer has been initialized + // (admin account created). If it's empty, Portainer is running + // but not configured yet. + const isInitialized = !!json.InstanceID + resolve({ + available: isInitialized, + version: json.Version || 'unknown', + instanceId: json.InstanceID || null, + needsSetup: !isInitialized, + }) + } catch { + resolve({ + available: false, + version: 'unknown', + error: 'Failed to parse response', + }) + } + } else { + resolve({ + available: false, + error: `Portainer returned status ${res.statusCode}`, + }) + } + }) + } + ) + + req.on('error', (err) => { + resolve({ + available: false, + error: err.message, + }) + }) + + req.on('timeout', () => { + req.destroy() + resolve({ + available: false, + error: 'Connection timed out', + }) + }) + + req.end() + }) + + return NextResponse.json(result) + } catch (error) { + console.error('Error checking Portainer status:', error) + return NextResponse.json( + { error: 'Failed to check Portainer status' }, + { status: 500 } + ) + } +} diff --git a/src/app/api/v1/admin/servers/[id]/command/route.ts b/src/app/api/v1/admin/servers/[id]/command/route.ts new file mode 100644 index 0000000..a22cd26 --- /dev/null +++ b/src/app/api/v1/admin/servers/[id]/command/route.ts @@ -0,0 +1,176 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { auth } from '@/lib/auth' + +interface RouteContext { + params: Promise<{ id: string }> +} + +interface CommandRequest { + type: string // SHELL, RESTART_SERVICE, UPDATE, ECHO, etc. + payload?: Record +} + +// Common command types +const ALLOWED_COMMAND_TYPES = [ + 'ECHO', // Test connectivity + 'SHELL', // Execute shell command + 'RESTART_SERVICE', // Restart a Docker service + 'UPDATE', // Update orchestrator/agent + 'DOCKER_COMPOSE_UP', // Start stack + 'DOCKER_COMPOSE_DOWN', // Stop stack + 'DOCKER_COMPOSE_RESTART', // Restart stack + 'GET_LOGS', // Get container logs + 'GET_STATUS', // Get system status +] + +/** + * GET /api/v1/admin/servers/[id]/command + * Get command history for a server + */ +export async function GET(request: NextRequest, context: RouteContext) { + try { + // Authentication check + const session = await auth() + if (!session || session.user.userType !== 'staff') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: orderId } = await context.params + const searchParams = request.nextUrl.searchParams + const limit = parseInt(searchParams.get('limit') || '50', 10) + const offset = parseInt(searchParams.get('offset') || '0', 10) + + // Find server connection by order ID + const serverConnection = await prisma.serverConnection.findUnique({ + where: { orderId }, + }) + + if (!serverConnection) { + return NextResponse.json( + { error: 'Server connection not found' }, + { status: 404 } + ) + } + + // Get command history + const [commands, total] = await Promise.all([ + prisma.remoteCommand.findMany({ + where: { serverConnectionId: serverConnection.id }, + orderBy: { queuedAt: 'desc' }, + take: limit, + skip: offset, + }), + prisma.remoteCommand.count({ + where: { serverConnectionId: serverConnection.id }, + }), + ]) + + return NextResponse.json({ + commands, + pagination: { + total, + limit, + offset, + hasMore: offset + commands.length < total, + }, + }) + } catch (error) { + console.error('Get commands error:', error) + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ) + } +} + +/** + * POST /api/v1/admin/servers/[id]/command + * Queue a command for a server + */ +export async function POST(request: NextRequest, context: RouteContext) { + try { + // Authentication check + const session = await auth() + if (!session || session.user.userType !== 'staff') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: orderId } = await context.params + const body: CommandRequest = await request.json() + + // Validate command type + if (!body.type) { + return NextResponse.json( + { error: 'Missing required field: type' }, + { status: 400 } + ) + } + + if (!ALLOWED_COMMAND_TYPES.includes(body.type)) { + return NextResponse.json( + { + error: `Invalid command type. Allowed types: ${ALLOWED_COMMAND_TYPES.join(', ')}`, + }, + { status: 400 } + ) + } + + // Find server connection by order ID + const serverConnection = await prisma.serverConnection.findUnique({ + where: { orderId }, + include: { + order: { + select: { domain: true, serverIp: true }, + }, + }, + }) + + if (!serverConnection) { + return NextResponse.json( + { error: 'Server connection not found' }, + { status: 404 } + ) + } + + // Check if server is registered + if (serverConnection.status === 'PENDING') { + return NextResponse.json( + { error: 'Server has not registered with Hub yet' }, + { status: 400 } + ) + } + + // Queue the command + const command = await prisma.remoteCommand.create({ + data: { + serverConnectionId: serverConnection.id, + type: body.type, + payload: (body.payload || {}) as object, + initiatedBy: session?.user?.email || 'unknown', + }, + }) + + return NextResponse.json({ + success: true, + command: { + id: command.id, + type: command.type, + status: command.status, + queuedAt: command.queuedAt, + }, + server: { + domain: serverConnection.order.domain, + ip: serverConnection.order.serverIp, + status: serverConnection.status, + lastHeartbeat: serverConnection.lastHeartbeat, + }, + }) + } catch (error) { + console.error('Queue command error:', error) + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ) + } +} diff --git a/src/app/api/v1/admin/servers/[id]/health/route.ts b/src/app/api/v1/admin/servers/[id]/health/route.ts new file mode 100644 index 0000000..8274275 --- /dev/null +++ b/src/app/api/v1/admin/servers/[id]/health/route.ts @@ -0,0 +1,158 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' + +interface RouteContext { + params: Promise<{ id: string }> +} + +// Threshold for considering a server offline (5 minutes without heartbeat) +const OFFLINE_THRESHOLD_MS = 5 * 60 * 1000 +// Threshold for degraded status (2 minutes without heartbeat) +const DEGRADED_THRESHOLD_MS = 2 * 60 * 1000 + +type HealthStatus = 'unknown' | 'pending' | 'online' | 'degraded' | 'offline' + +function calculateHealthStatus( + connectionStatus: string, + lastHeartbeat: Date | null +): HealthStatus { + if (connectionStatus === 'PENDING') { + return 'pending' + } + + if (!lastHeartbeat) { + return 'unknown' + } + + const timeSinceHeartbeat = Date.now() - lastHeartbeat.getTime() + + if (timeSinceHeartbeat > OFFLINE_THRESHOLD_MS) { + return 'offline' + } + + if (timeSinceHeartbeat > DEGRADED_THRESHOLD_MS) { + return 'degraded' + } + + return 'online' +} + +/** + * GET /api/v1/admin/servers/[id]/health + * Get health status for a server + */ +export async function GET(_request: NextRequest, context: RouteContext) { + try { + const { id: orderId } = await context.params + + // Find order with server connection + const order = await prisma.order.findUnique({ + where: { id: orderId }, + include: { + serverConnection: true, + user: { + select: { email: true, name: true }, + }, + }, + }) + + if (!order) { + return NextResponse.json( + { error: 'Order not found' }, + { status: 404 } + ) + } + + const serverConnection = order.serverConnection + + // If no server connection exists yet + if (!serverConnection) { + return NextResponse.json({ + orderId: order.id, + domain: order.domain, + serverIp: order.serverIp, + health: { + status: 'not_provisioned' as const, + message: 'Server has not been provisioned yet', + }, + connection: null, + }) + } + + // Calculate health status + const healthStatus = calculateHealthStatus( + serverConnection.status, + serverConnection.lastHeartbeat + ) + + // Get recent command stats + const [pendingCommands, recentFailedCommands] = await Promise.all([ + prisma.remoteCommand.count({ + where: { + serverConnectionId: serverConnection.id, + status: { in: ['PENDING', 'SENT', 'EXECUTING'] }, + }, + }), + prisma.remoteCommand.count({ + where: { + serverConnectionId: serverConnection.id, + status: 'FAILED', + completedAt: { + gte: new Date(Date.now() - 24 * 60 * 60 * 1000), // Last 24 hours + }, + }, + }), + ]) + + const timeSinceHeartbeat = serverConnection.lastHeartbeat + ? Date.now() - serverConnection.lastHeartbeat.getTime() + : null + + return NextResponse.json({ + orderId: order.id, + domain: order.domain, + serverIp: order.serverIp, + orderStatus: order.status, + health: { + status: healthStatus, + message: getHealthMessage(healthStatus, timeSinceHeartbeat), + }, + connection: { + id: serverConnection.id, + status: serverConnection.status, + registeredAt: serverConnection.registeredAt, + lastHeartbeat: serverConnection.lastHeartbeat, + timeSinceHeartbeat, + orchestratorUrl: serverConnection.orchestratorUrl, + agentVersion: serverConnection.agentVersion, + }, + commands: { + pending: pendingCommands, + recentFailures: recentFailedCommands, + }, + }) + } catch (error) { + console.error('Health check error:', error) + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ) + } +} + +function getHealthMessage(status: HealthStatus, timeSinceHeartbeat: number | null): string { + switch (status) { + case 'online': + return 'Server is healthy and responding' + case 'degraded': + return `Last heartbeat ${Math.round((timeSinceHeartbeat || 0) / 1000)}s ago - may be experiencing issues` + case 'offline': + return `No heartbeat for ${Math.round((timeSinceHeartbeat || 0) / 60000)} minutes - server appears offline` + case 'pending': + return 'Waiting for orchestrator to register with Hub' + case 'unknown': + return 'Server status unknown - no heartbeat data available' + default: + return 'Unable to determine server status' + } +} diff --git a/src/app/api/v1/admin/settings/[key]/route.ts b/src/app/api/v1/admin/settings/[key]/route.ts new file mode 100644 index 0000000..447e9bc --- /dev/null +++ b/src/app/api/v1/admin/settings/[key]/route.ts @@ -0,0 +1,144 @@ +import { NextRequest, NextResponse } from 'next/server' +import { auth } from '@/lib/auth' +import { settingsService, SETTING_KEYS, SettingKey } from '@/lib/services/settings-service' + +type RouteParams = { params: Promise<{ key: string }> } + +/** + * GET /api/v1/admin/settings/{key} + * Get a single setting value + */ +export async function GET(request: NextRequest, { params }: RouteParams) { + try { + const session = await auth() + + if (!session || session.user.userType !== 'staff') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { key } = await params + + if (!(key in SETTING_KEYS)) { + return NextResponse.json( + { error: `Unknown setting key: ${key}` }, + { status: 400 } + ) + } + + const config = SETTING_KEYS[key as SettingKey] + const value = await settingsService.get(key as SettingKey) + + // Don't return actual encrypted values via individual get + if (config.encrypted && value) { + return NextResponse.json({ + key, + hasValue: true, + encrypted: true, + category: config.category, + }) + } + + return NextResponse.json({ + key, + value: value || '', + hasValue: !!value, + encrypted: config.encrypted, + category: config.category, + }) + } catch (error) { + console.error('Error getting setting:', error) + return NextResponse.json( + { error: 'Failed to get setting' }, + { status: 500 } + ) + } +} + +/** + * PUT /api/v1/admin/settings/{key} + * Update a single setting + */ +export async function PUT(request: NextRequest, { params }: RouteParams) { + try { + const session = await auth() + + if (!session || session.user.userType !== 'staff') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { key } = await params + + if (!(key in SETTING_KEYS)) { + return NextResponse.json( + { error: `Unknown setting key: ${key}` }, + { status: 400 } + ) + } + + const body = await request.json() + const { value } = body as { value: string } + + if (value === undefined) { + return NextResponse.json( + { error: 'Value is required' }, + { status: 400 } + ) + } + + await settingsService.set(key as SettingKey, value) + + return NextResponse.json({ + message: 'Setting updated successfully', + key, + }) + } catch (error) { + console.error('Error updating setting:', error) + return NextResponse.json( + { error: 'Failed to update setting' }, + { status: 500 } + ) + } +} + +/** + * DELETE /api/v1/admin/settings/{key} + * Remove a setting (revert to default) + */ +export async function DELETE(request: NextRequest, { params }: RouteParams) { + try { + const session = await auth() + + if (!session || session.user.userType !== 'staff') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { key } = await params + + if (!(key in SETTING_KEYS)) { + return NextResponse.json( + { error: `Unknown setting key: ${key}` }, + { status: 400 } + ) + } + + const deleted = await settingsService.delete(key as SettingKey) + + if (deleted) { + return NextResponse.json({ + message: 'Setting removed (reverted to default)', + key, + }) + } else { + return NextResponse.json({ + message: 'Setting was not set', + key, + }) + } + } catch (error) { + console.error('Error deleting setting:', error) + return NextResponse.json( + { error: 'Failed to delete setting' }, + { status: 500 } + ) + } +} diff --git a/src/app/api/v1/admin/settings/email/test/route.ts b/src/app/api/v1/admin/settings/email/test/route.ts new file mode 100644 index 0000000..bdc4054 --- /dev/null +++ b/src/app/api/v1/admin/settings/email/test/route.ts @@ -0,0 +1,65 @@ +import { NextRequest, NextResponse } from 'next/server' +import { requireStaffPermission } from '@/lib/auth-helpers' +import { emailService } from '@/lib/services/email-service' + +/** + * POST /api/v1/admin/settings/email/test + * Test SMTP connection and optionally send a test email + */ +export async function POST(request: NextRequest) { + try { + await requireStaffPermission('settings:edit') + } catch (err) { + const error = err as { status?: number; message?: string } + return NextResponse.json( + { error: error.message || 'Unauthorized' }, + { status: error.status || 401 } + ) + } + + try { + const body = await request.json().catch(() => ({})) + const { testEmail } = body as { testEmail?: string } + + // First, test the connection + const connectionResult = await emailService.testConnection() + if (!connectionResult.success) { + return NextResponse.json( + { + success: false, + error: connectionResult.error, + connectionTest: false, + }, + { status: 400 } + ) + } + + // If a test email address is provided, send a test email + if (testEmail) { + const sendResult = await emailService.sendTestEmail(testEmail) + return NextResponse.json({ + success: sendResult.success, + connectionTest: true, + emailSent: sendResult.success, + error: sendResult.error, + messageId: sendResult.messageId, + }) + } + + // Just connection test + return NextResponse.json({ + success: true, + connectionTest: true, + emailSent: false, + }) + } catch (err) { + console.error('Email test error:', err) + return NextResponse.json( + { + success: false, + error: err instanceof Error ? err.message : 'Failed to test email', + }, + { status: 500 } + ) + } +} diff --git a/src/app/api/v1/admin/settings/route.ts b/src/app/api/v1/admin/settings/route.ts new file mode 100644 index 0000000..0bc21b4 --- /dev/null +++ b/src/app/api/v1/admin/settings/route.ts @@ -0,0 +1,108 @@ +import { NextRequest, NextResponse } from 'next/server' +import { auth } from '@/lib/auth' +import { settingsService, SETTING_KEYS, SettingKey } from '@/lib/services/settings-service' + +/** + * GET /api/v1/admin/settings + * List all settings (with masked values for encrypted ones) + */ +export async function GET(request: NextRequest) { + try { + const session = await auth() + + if (!session || session.user.userType !== 'staff') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const searchParams = request.nextUrl.searchParams + const category = searchParams.get('category') + + if (category) { + const settings = await settingsService.getCategory(category) + return NextResponse.json({ settings }) + } + + const settings = await settingsService.list() + + // Group by category for easier UI consumption + const grouped: Record = {} + for (const setting of settings) { + if (!grouped[setting.category]) { + grouped[setting.category] = [] + } + grouped[setting.category].push(setting) + } + + return NextResponse.json({ + settings, + grouped, + categories: Object.keys(grouped).sort(), + }) + } catch (error) { + console.error('Error listing settings:', error) + return NextResponse.json( + { error: 'Failed to list settings' }, + { status: 500 } + ) + } +} + +/** + * PUT /api/v1/admin/settings + * Batch update settings + */ +export async function PUT(request: NextRequest) { + try { + const session = await auth() + + if (!session || session.user.userType !== 'staff') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const body = await request.json() + const { settings } = body as { settings: Array<{ key: string; value: string }> } + + if (!settings || !Array.isArray(settings)) { + return NextResponse.json( + { error: 'Settings array is required' }, + { status: 400 } + ) + } + + // Validate all keys + for (const { key } of settings) { + if (!(key in SETTING_KEYS)) { + return NextResponse.json( + { error: `Unknown setting key: ${key}` }, + { status: 400 } + ) + } + } + + // Filter out empty values for encrypted fields (don't overwrite with empty) + const validSettings = settings.filter(({ key, value }) => { + const config = SETTING_KEYS[key as SettingKey] + // For encrypted fields, skip if value is empty (user didn't want to change it) + if (config.encrypted && !value) { + return false + } + return true + }) as Array<{ key: SettingKey; value: string }> + + await settingsService.batchUpdate(validSettings) + + // Return updated settings + const updatedSettings = await settingsService.list() + + return NextResponse.json({ + message: 'Settings updated successfully', + settings: updatedSettings, + }) + } catch (error) { + console.error('Error updating settings:', error) + return NextResponse.json( + { error: 'Failed to update settings' }, + { status: 500 } + ) + } +} diff --git a/src/app/api/v1/admin/settings/storage/test/route.ts b/src/app/api/v1/admin/settings/storage/test/route.ts new file mode 100644 index 0000000..3056f45 --- /dev/null +++ b/src/app/api/v1/admin/settings/storage/test/route.ts @@ -0,0 +1,45 @@ +import { NextRequest, NextResponse } from 'next/server' +import { requireStaffPermission } from '@/lib/auth-helpers' +import { storageService } from '@/lib/services/storage-service' + +/** + * POST /api/v1/admin/settings/storage/test + * Test S3/MinIO connection + * Requires: settings:edit permission + */ +export async function POST(request: NextRequest) { + try { + await requireStaffPermission('settings:edit') + + // Get optional config override from body + const body = await request.json().catch(() => ({})) + + // If config is provided in body, test with that config (for testing before saving) + if (body.endpoint && body.bucket && body.accessKey && body.secretKey) { + const result = await storageService.testConnection({ + endpoint: body.endpoint, + bucket: body.bucket, + accessKey: body.accessKey, + secretKey: body.secretKey, + region: body.region || 'us-east-1', + useSsl: body.useSsl !== false, + }) + + return NextResponse.json(result) + } + + // Otherwise test with stored settings + const result = await storageService.testConnection() + return NextResponse.json(result) + } catch (error) { + if (typeof error === 'object' && error !== null && 'status' in error) { + const err = error as { status: number; message: string } + return NextResponse.json({ error: err.message }, { status: err.status }) + } + console.error('Error testing storage connection:', error) + return NextResponse.json( + { success: false, message: 'Failed to test storage connection' }, + { status: 500 } + ) + } +} diff --git a/src/app/api/v1/admin/staff/[id]/route.ts b/src/app/api/v1/admin/staff/[id]/route.ts new file mode 100644 index 0000000..1cc557c --- /dev/null +++ b/src/app/api/v1/admin/staff/[id]/route.ts @@ -0,0 +1,221 @@ +import { NextRequest, NextResponse } from 'next/server' +import { requireStaffPermission } from '@/lib/auth-helpers' +import { prisma } from '@/lib/prisma' +import { StaffRole, StaffStatus } from '@prisma/client' +import { canDeleteRole, getAssignableRoles } from '@/lib/services/permission-service' + +interface UpdateStaffRequest { + role?: StaffRole + status?: StaffStatus + name?: string +} + +/** + * GET /api/v1/admin/staff/[id] + * Get staff member details + * Requires: staff:view permission + */ +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + await requireStaffPermission('staff:view') + const { id } = await params + + const staff = await prisma.staff.findUnique({ + where: { id }, + select: { + id: true, + email: true, + name: true, + role: true, + status: true, + invitedBy: true, + twoFactorEnabled: true, + twoFactorVerifiedAt: true, + createdAt: true, + updatedAt: true, + }, + }) + + if (!staff) { + return NextResponse.json({ error: 'Staff not found' }, { status: 404 }) + } + + // Get inviter details if applicable + let invitedByStaff = null + if (staff.invitedBy) { + invitedByStaff = await prisma.staff.findUnique({ + where: { id: staff.invitedBy }, + select: { id: true, name: true, email: true }, + }) + } + + return NextResponse.json({ + ...staff, + invitedByStaff, + }) + } catch (error) { + if (typeof error === 'object' && error !== null && 'status' in error) { + const err = error as { status: number; message: string } + return NextResponse.json({ error: err.message }, { status: err.status }) + } + console.error('Error getting staff:', error) + return NextResponse.json({ error: 'Failed to get staff' }, { status: 500 }) + } +} + +/** + * PATCH /api/v1/admin/staff/[id] + * Update staff member (role, status, name) + * Requires: staff:manage permission + */ +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await requireStaffPermission('staff:manage') + const { id } = await params + const body: UpdateStaffRequest = await request.json() + + // Get the target staff member + const targetStaff = await prisma.staff.findUnique({ + where: { id }, + select: { id: true, role: true }, + }) + + if (!targetStaff) { + return NextResponse.json({ error: 'Staff not found' }, { status: 404 }) + } + + // Check if trying to modify self + if (id === session.user.id) { + // Can only update own name + if (body.role || body.status) { + return NextResponse.json( + { error: 'Cannot modify your own role or status' }, + { status: 403 } + ) + } + } + + // Validate role change is allowed + if (body.role) { + const assignableRoles = getAssignableRoles(session.user.role) + if (!assignableRoles.includes(body.role)) { + return NextResponse.json( + { error: 'Cannot assign this role' }, + { status: 403 } + ) + } + } + + // Cannot change OWNER role if you're not OWNER + if (targetStaff.role === 'OWNER' && session.user.role !== 'OWNER') { + return NextResponse.json( + { error: 'Cannot modify an OWNER' }, + { status: 403 } + ) + } + + // Build update data + const updateData: Partial = {} + if (body.name !== undefined) updateData.name = body.name + if (body.role !== undefined) updateData.role = body.role + if (body.status !== undefined) updateData.status = body.status + + const updatedStaff = await prisma.staff.update({ + where: { id }, + data: updateData, + select: { + id: true, + email: true, + name: true, + role: true, + status: true, + twoFactorEnabled: true, + createdAt: true, + updatedAt: true, + }, + }) + + return NextResponse.json(updatedStaff) + } catch (error) { + if (typeof error === 'object' && error !== null && 'status' in error) { + const err = error as { status: number; message: string } + return NextResponse.json({ error: err.message }, { status: err.status }) + } + console.error('Error updating staff:', error) + return NextResponse.json( + { error: 'Failed to update staff' }, + { status: 500 } + ) + } +} + +/** + * DELETE /api/v1/admin/staff/[id] + * Delete staff member + * Requires: staff:delete permission (OWNER only) + */ +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await requireStaffPermission('staff:delete') + const { id } = await params + + // Cannot delete yourself + if (id === session.user.id) { + return NextResponse.json( + { error: 'Cannot delete yourself' }, + { status: 403 } + ) + } + + // Get the target staff member + const targetStaff = await prisma.staff.findUnique({ + where: { id }, + select: { id: true, role: true, email: true }, + }) + + if (!targetStaff) { + return NextResponse.json({ error: 'Staff not found' }, { status: 404 }) + } + + // Check if can delete this role + if (!canDeleteRole(session.user.role, targetStaff.role)) { + return NextResponse.json( + { error: 'Cannot delete staff with this role' }, + { status: 403 } + ) + } + + // Prevent deleting another OWNER + if (targetStaff.role === 'OWNER') { + return NextResponse.json( + { error: 'Cannot delete an OWNER' }, + { status: 403 } + ) + } + + await prisma.staff.delete({ + where: { id }, + }) + + return NextResponse.json({ deleted: true, email: targetStaff.email }) + } catch (error) { + if (typeof error === 'object' && error !== null && 'status' in error) { + const err = error as { status: number; message: string } + return NextResponse.json({ error: err.message }, { status: err.status }) + } + console.error('Error deleting staff:', error) + return NextResponse.json( + { error: 'Failed to delete staff' }, + { status: 500 } + ) + } +} diff --git a/src/app/api/v1/admin/staff/invite/route.ts b/src/app/api/v1/admin/staff/invite/route.ts new file mode 100644 index 0000000..1aee39c --- /dev/null +++ b/src/app/api/v1/admin/staff/invite/route.ts @@ -0,0 +1,199 @@ +import { NextRequest, NextResponse } from 'next/server' +import crypto from 'crypto' +import { requireStaffPermission } from '@/lib/auth-helpers' +import { prisma } from '@/lib/prisma' +import { StaffRole } from '@prisma/client' +import { getAssignableRoles } from '@/lib/services/permission-service' +import { emailService } from '@/lib/services/email-service' + +interface InviteStaffRequest { + email: string + role: StaffRole +} + +/** + * POST /api/v1/admin/staff/invite + * Send a staff invitation + * Requires: staff:invite permission + */ +export async function POST(request: NextRequest) { + try { + const session = await requireStaffPermission('staff:invite') + const body: InviteStaffRequest = await request.json() + + // Validate required fields + if (!body.email) { + return NextResponse.json( + { error: 'Email is required' }, + { status: 400 } + ) + } + + if (!body.role) { + return NextResponse.json( + { error: 'Role is required' }, + { status: 400 } + ) + } + + // Validate email format + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + if (!emailRegex.test(body.email)) { + return NextResponse.json( + { error: 'Invalid email format' }, + { status: 400 } + ) + } + + // Validate role can be assigned + const assignableRoles = getAssignableRoles(session.user.role) + if (!assignableRoles.includes(body.role)) { + return NextResponse.json( + { error: 'Cannot invite staff with this role' }, + { status: 403 } + ) + } + + // Check if email already exists as staff + const existingStaff = await prisma.staff.findUnique({ + where: { email: body.email }, + }) + + if (existingStaff) { + return NextResponse.json( + { error: 'A staff member with this email already exists' }, + { status: 409 } + ) + } + + // Check if pending invitation exists + const existingInvite = await prisma.staffInvitation.findUnique({ + where: { email: body.email }, + }) + + if (existingInvite) { + return NextResponse.json( + { error: 'A pending invitation for this email already exists' }, + { status: 409 } + ) + } + + // Generate secure token + const token = crypto.randomBytes(32).toString('hex') + + // Create invitation with 7-day expiry + const expiresAt = new Date() + expiresAt.setDate(expiresAt.getDate() + 7) + + const invitation = await prisma.staffInvitation.create({ + data: { + email: body.email, + role: body.role, + token, + expiresAt, + invitedBy: session.user.id, + }, + select: { + id: true, + email: true, + role: true, + expiresAt: true, + createdAt: true, + }, + }) + + // Get the inviter's name for the response + const inviter = await prisma.staff.findUnique({ + where: { id: session.user.id }, + select: { name: true, email: true }, + }) + + const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000' + const inviteUrl = `${baseUrl}/invite/${token}` + + // Send invitation email + let emailSent = false + let emailError: string | undefined + + const isEmailConfigured = await emailService.isConfigured() + if (isEmailConfigured) { + const inviterName = inviter?.name || inviter?.email || 'A team member' + const roleDisplay = body.role.charAt(0) + body.role.slice(1).toLowerCase() + + const emailResult = await emailService.sendEmail({ + to: body.email, + subject: `You've been invited to join LetsBe Hub`, + html: ` +
+
+

LetsBe Hub

+
+
+

You're Invited!

+

+ ${inviterName} has invited you to join LetsBe Hub as a ${roleDisplay}. +

+

+ Click the button below to create your account and get started. +

+ + + + +
+ + Accept Invitation + +
+

+ Or copy and paste this link into your browser: +

+

+ ${inviteUrl} +

+
+

+ This invitation expires in 7 days. If you didn't expect this invitation, you can safely ignore this email. +

+
+
+

+ LetsBe Hub - Infrastructure Management Platform +

+
+
+ `, + text: `You've been invited to join LetsBe Hub!\n\n${inviterName} has invited you to join LetsBe Hub as a ${roleDisplay}.\n\nClick here to accept: ${inviteUrl}\n\nThis invitation expires in 7 days.`, + }) + + emailSent = emailResult.success + emailError = emailResult.error + } + + return NextResponse.json( + { + ...invitation, + invitedBy: inviter, + inviteUrl, // Always include URL so it can be copied manually if email fails + emailSent, + emailError, + message: emailSent + ? 'Invitation sent successfully' + : isEmailConfigured + ? 'Invitation created but email failed to send' + : 'Invitation created (email not configured)', + }, + { status: 201 } + ) + } catch (error) { + if (typeof error === 'object' && error !== null && 'status' in error) { + const err = error as { status: number; message: string } + return NextResponse.json({ error: err.message }, { status: err.status }) + } + console.error('Error creating invitation:', error) + return NextResponse.json( + { error: 'Failed to create invitation' }, + { status: 500 } + ) + } +} diff --git a/src/app/api/v1/admin/staff/invites/[id]/route.ts b/src/app/api/v1/admin/staff/invites/[id]/route.ts new file mode 100644 index 0000000..286fc9b --- /dev/null +++ b/src/app/api/v1/admin/staff/invites/[id]/route.ts @@ -0,0 +1,106 @@ +import { NextRequest, NextResponse } from 'next/server' +import { requireStaffPermission } from '@/lib/auth-helpers' +import { prisma } from '@/lib/prisma' + +/** + * GET /api/v1/admin/staff/invites/[id] + * Get invitation details + * Requires: staff:view permission + */ +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + await requireStaffPermission('staff:view') + const { id } = await params + + const invitation = await prisma.staffInvitation.findUnique({ + where: { id }, + select: { + id: true, + email: true, + role: true, + expiresAt: true, + invitedBy: true, + createdAt: true, + }, + }) + + if (!invitation) { + return NextResponse.json( + { error: 'Invitation not found' }, + { status: 404 } + ) + } + + // Get inviter details + const inviter = await prisma.staff.findUnique({ + where: { id: invitation.invitedBy }, + select: { id: true, name: true, email: true }, + }) + + return NextResponse.json({ + ...invitation, + isExpired: invitation.expiresAt < new Date(), + invitedByStaff: inviter, + }) + } catch (error) { + if (typeof error === 'object' && error !== null && 'status' in error) { + const err = error as { status: number; message: string } + return NextResponse.json({ error: err.message }, { status: err.status }) + } + console.error('Error getting invitation:', error) + return NextResponse.json( + { error: 'Failed to get invitation' }, + { status: 500 } + ) + } +} + +/** + * DELETE /api/v1/admin/staff/invites/[id] + * Cancel/delete a staff invitation + * Requires: staff:invite permission + */ +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + await requireStaffPermission('staff:invite') + const { id } = await params + + // Check if invitation exists + const invitation = await prisma.staffInvitation.findUnique({ + where: { id }, + select: { id: true, email: true }, + }) + + if (!invitation) { + return NextResponse.json( + { error: 'Invitation not found' }, + { status: 404 } + ) + } + + await prisma.staffInvitation.delete({ + where: { id }, + }) + + return NextResponse.json({ + deleted: true, + email: invitation.email, + }) + } catch (error) { + if (typeof error === 'object' && error !== null && 'status' in error) { + const err = error as { status: number; message: string } + return NextResponse.json({ error: err.message }, { status: err.status }) + } + console.error('Error deleting invitation:', error) + return NextResponse.json( + { error: 'Failed to delete invitation' }, + { status: 500 } + ) + } +} diff --git a/src/app/api/v1/admin/staff/invites/route.ts b/src/app/api/v1/admin/staff/invites/route.ts new file mode 100644 index 0000000..c129920 --- /dev/null +++ b/src/app/api/v1/admin/staff/invites/route.ts @@ -0,0 +1,66 @@ +import { NextRequest, NextResponse } from 'next/server' +import { requireStaffPermission } from '@/lib/auth-helpers' +import { prisma } from '@/lib/prisma' + +/** + * GET /api/v1/admin/staff/invites + * List pending staff invitations + * Requires: staff:view permission + */ +export async function GET(request: NextRequest) { + try { + await requireStaffPermission('staff:view') + + const searchParams = request.nextUrl.searchParams + const includeExpired = searchParams.get('includeExpired') === 'true' + + const now = new Date() + + const where = includeExpired + ? {} + : { expiresAt: { gt: now } } + + const invitations = await prisma.staffInvitation.findMany({ + where, + select: { + id: true, + email: true, + role: true, + expiresAt: true, + invitedBy: true, + createdAt: true, + }, + orderBy: { createdAt: 'desc' }, + }) + + // Get inviter details + const inviterIds = [...new Set(invitations.map((i) => i.invitedBy))] + const inviters = await prisma.staff.findMany({ + where: { id: { in: inviterIds } }, + select: { id: true, name: true, email: true }, + }) + + const inviterMap = new Map(inviters.map((i) => [i.id, i])) + + const invitationsWithDetails = invitations.map((inv) => ({ + ...inv, + isExpired: inv.expiresAt < now, + invitedByStaff: inviterMap.get(inv.invitedBy) || null, + })) + + return NextResponse.json({ + invitations: invitationsWithDetails, + total: invitationsWithDetails.length, + }) + } catch (error) { + if (typeof error === 'object' && error !== null && 'status' in error) { + const err = error as { status: number; message: string } + return NextResponse.json({ error: err.message }, { status: err.status }) + } + console.error('Error listing invitations:', error) + return NextResponse.json( + { error: 'Failed to list invitations' }, + { status: 500 } + ) + } +} diff --git a/src/app/api/v1/admin/staff/route.ts b/src/app/api/v1/admin/staff/route.ts new file mode 100644 index 0000000..2ad77ca --- /dev/null +++ b/src/app/api/v1/admin/staff/route.ts @@ -0,0 +1,94 @@ +import { NextRequest, NextResponse } from 'next/server' +import { requireStaffPermission } from '@/lib/auth-helpers' +import { prisma } from '@/lib/prisma' +import { StaffStatus, Prisma } from '@prisma/client' + +/** + * GET /api/v1/admin/staff + * List all staff members + * Requires: staff:view permission + */ +export async function GET(request: NextRequest) { + try { + const session = await requireStaffPermission('staff:view') + const currentUserId = session.user.id + + const searchParams = request.nextUrl.searchParams + const status = searchParams.get('status') as StaffStatus | null + const search = searchParams.get('search') + const page = parseInt(searchParams.get('page') || '1') + const limit = parseInt(searchParams.get('limit') || '50') + + const where: Prisma.StaffWhereInput = {} + + if (status) { + where.status = status + } + + if (search) { + where.OR = [ + { email: { contains: search, mode: 'insensitive' } }, + { name: { contains: search, mode: 'insensitive' } }, + ] + } + + const [staff, total] = await Promise.all([ + prisma.staff.findMany({ + where, + select: { + id: true, + email: true, + name: true, + role: true, + status: true, + invitedBy: true, + twoFactorEnabled: true, + createdAt: true, + updatedAt: true, + }, + orderBy: { createdAt: 'desc' }, + skip: (page - 1) * limit, + take: limit, + }), + prisma.staff.count({ where }), + ]) + + // Get inviter names for staff who were invited + const inviterIds = staff + .map((s) => s.invitedBy) + .filter((id): id is string => id !== null) + + const inviters = await prisma.staff.findMany({ + where: { id: { in: inviterIds } }, + select: { id: true, name: true, email: true }, + }) + + const inviterMap = new Map(inviters.map((i) => [i.id, i])) + + const staffWithInviter = staff.map((s) => ({ + ...s, + isCurrentUser: s.id === currentUserId, + invitedByStaff: s.invitedBy ? inviterMap.get(s.invitedBy) || null : null, + })) + + return NextResponse.json({ + staff: staffWithInviter, + pagination: { + page, + limit, + total, + totalPages: Math.ceil(total / limit), + }, + }) + } catch (error) { + if (typeof error === 'object' && error !== null && 'status' in error) { + const err = error as { status: number; message: string } + return NextResponse.json({ error: err.message }, { status: err.status }) + } + console.error('Error listing staff:', error) + return NextResponse.json( + { error: 'Failed to list staff' }, + { status: 500 } + ) + } +} diff --git a/src/app/api/v1/auth/2fa/backup-codes/route.ts b/src/app/api/v1/auth/2fa/backup-codes/route.ts new file mode 100644 index 0000000..86ad818 --- /dev/null +++ b/src/app/api/v1/auth/2fa/backup-codes/route.ts @@ -0,0 +1,107 @@ +import { NextRequest, NextResponse } from 'next/server' +import { compare } from 'bcryptjs' +import { auth } from '@/lib/auth' +import { prisma } from '@/lib/prisma' +import { totpService } from '@/lib/services/totp-service' + +/** + * POST /api/v1/auth/2fa/backup-codes + * Regenerate backup codes - requires password + * Invalidates all previous backup codes + */ +export async function POST(request: NextRequest) { + try { + const session = await auth() + + if (!session?.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: userId, userType } = session.user + const body = await request.json() + const { password } = body + + if (!password) { + return NextResponse.json( + { error: 'Password is required' }, + { status: 400 } + ) + } + + // Get user/staff data + let passwordHash: string | null = null + let twoFactorEnabled = false + + if (userType === 'staff') { + const staff = await prisma.staff.findUnique({ + where: { id: userId }, + select: { + passwordHash: true, + twoFactorEnabled: true, + }, + }) + passwordHash = staff?.passwordHash ?? null + twoFactorEnabled = staff?.twoFactorEnabled ?? false + } else { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { + passwordHash: true, + twoFactorEnabled: true, + }, + }) + passwordHash = user?.passwordHash ?? null + twoFactorEnabled = user?.twoFactorEnabled ?? false + } + + if (!twoFactorEnabled) { + return NextResponse.json( + { error: '2FA is not enabled. Enable 2FA first.' }, + { status: 400 } + ) + } + + // Verify password + if (!passwordHash) { + return NextResponse.json( + { error: 'User not found' }, + { status: 404 } + ) + } + + const isValidPassword = await compare(password, passwordHash) + if (!isValidPassword) { + return NextResponse.json( + { error: 'Invalid password' }, + { status: 400 } + ) + } + + // Generate new backup codes + const { plainCodes, hashedCodesJson } = await totpService.generateBackupCodes() + + // Store new backup codes + if (userType === 'staff') { + await prisma.staff.update({ + where: { id: userId }, + data: { backupCodesEnc: hashedCodesJson }, + }) + } else { + await prisma.user.update({ + where: { id: userId }, + data: { backupCodesEnc: hashedCodesJson }, + }) + } + + return NextResponse.json({ + backupCodes: totpService.formatBackupCodes(plainCodes), + message: 'New backup codes generated. Previous codes are now invalid.', + }) + } catch (error) { + console.error('Error regenerating backup codes:', error) + return NextResponse.json( + { error: 'Failed to regenerate backup codes' }, + { status: 500 } + ) + } +} diff --git a/src/app/api/v1/auth/2fa/disable/route.ts b/src/app/api/v1/auth/2fa/disable/route.ts new file mode 100644 index 0000000..e2c8ddd --- /dev/null +++ b/src/app/api/v1/auth/2fa/disable/route.ts @@ -0,0 +1,148 @@ +import { NextRequest, NextResponse } from 'next/server' +import { compare } from 'bcryptjs' +import { auth } from '@/lib/auth' +import { prisma } from '@/lib/prisma' +import { totpService } from '@/lib/services/totp-service' + +/** + * POST /api/v1/auth/2fa/disable + * Disable 2FA - requires password and either TOTP token or backup code + */ +export async function POST(request: NextRequest) { + try { + const session = await auth() + + if (!session?.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: userId, userType } = session.user + const body = await request.json() + const { password, token, backupCode } = body + + if (!password) { + return NextResponse.json( + { error: 'Password is required' }, + { status: 400 } + ) + } + + if (!token && !backupCode) { + return NextResponse.json( + { error: 'TOTP token or backup code is required' }, + { status: 400 } + ) + } + + // Get user/staff data + let passwordHash: string | null = null + let encryptedSecret: string | null = null + let backupCodesEnc: string | null = null + let twoFactorEnabled = false + + if (userType === 'staff') { + const staff = await prisma.staff.findUnique({ + where: { id: userId }, + select: { + passwordHash: true, + twoFactorSecretEnc: true, + twoFactorEnabled: true, + backupCodesEnc: true, + }, + }) + passwordHash = staff?.passwordHash ?? null + encryptedSecret = staff?.twoFactorSecretEnc ?? null + twoFactorEnabled = staff?.twoFactorEnabled ?? false + backupCodesEnc = staff?.backupCodesEnc ?? null + } else { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { + passwordHash: true, + twoFactorSecretEnc: true, + twoFactorEnabled: true, + backupCodesEnc: true, + }, + }) + passwordHash = user?.passwordHash ?? null + encryptedSecret = user?.twoFactorSecretEnc ?? null + twoFactorEnabled = user?.twoFactorEnabled ?? false + backupCodesEnc = user?.backupCodesEnc ?? null + } + + if (!twoFactorEnabled) { + return NextResponse.json( + { error: '2FA is not enabled' }, + { status: 400 } + ) + } + + // Verify password + if (!passwordHash) { + return NextResponse.json( + { error: 'User not found' }, + { status: 404 } + ) + } + + const isValidPassword = await compare(password, passwordHash) + if (!isValidPassword) { + return NextResponse.json( + { error: 'Invalid password' }, + { status: 400 } + ) + } + + // Verify TOTP or backup code + let isValid = false + + if (token && encryptedSecret) { + const secret = totpService.decryptSecret(encryptedSecret) + isValid = totpService.verifyToken(token, secret) + } else if (backupCode && backupCodesEnc) { + const result = await totpService.verifyBackupCode(backupCode, backupCodesEnc) + isValid = result.valid + } + + if (!isValid) { + return NextResponse.json( + { error: 'Invalid TOTP token or backup code' }, + { status: 400 } + ) + } + + // Disable 2FA + if (userType === 'staff') { + await prisma.staff.update({ + where: { id: userId }, + data: { + twoFactorEnabled: false, + twoFactorSecretEnc: null, + twoFactorVerifiedAt: null, + backupCodesEnc: null, + }, + }) + } else { + await prisma.user.update({ + where: { id: userId }, + data: { + twoFactorEnabled: false, + twoFactorSecretEnc: null, + twoFactorVerifiedAt: null, + backupCodesEnc: null, + }, + }) + } + + return NextResponse.json({ + disabled: true, + message: '2FA has been disabled successfully.', + }) + } catch (error) { + console.error('Error disabling 2FA:', error) + return NextResponse.json( + { error: 'Failed to disable 2FA' }, + { status: 500 } + ) + } +} diff --git a/src/app/api/v1/auth/2fa/setup/route.ts b/src/app/api/v1/auth/2fa/setup/route.ts new file mode 100644 index 0000000..5a0795b --- /dev/null +++ b/src/app/api/v1/auth/2fa/setup/route.ts @@ -0,0 +1,77 @@ +import { NextRequest, NextResponse } from 'next/server' +import { auth } from '@/lib/auth' +import { prisma } from '@/lib/prisma' +import { totpService } from '@/lib/services/totp-service' + +/** + * POST /api/v1/auth/2fa/setup + * Start 2FA setup - generates secret and returns QR code + * Works for both customers and staff + */ +export async function POST(request: NextRequest) { + try { + const session = await auth() + + if (!session?.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: userId, userType, email } = session.user + + // Check if 2FA is already enabled + let isAlreadyEnabled = false + if (userType === 'staff') { + const staff = await prisma.staff.findUnique({ + where: { id: userId }, + select: { twoFactorEnabled: true }, + }) + isAlreadyEnabled = staff?.twoFactorEnabled ?? false + } else { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { twoFactorEnabled: true }, + }) + isAlreadyEnabled = user?.twoFactorEnabled ?? false + } + + if (isAlreadyEnabled) { + return NextResponse.json( + { error: '2FA is already enabled. Disable it first to set up again.' }, + { status: 400 } + ) + } + + // Generate new secret + const secret = totpService.generateSecret() + const encryptedSecret = totpService.encryptSecret(secret) + + // Store the secret temporarily (not enabled yet) + if (userType === 'staff') { + await prisma.staff.update({ + where: { id: userId }, + data: { twoFactorSecretEnc: encryptedSecret }, + }) + } else { + await prisma.user.update({ + where: { id: userId }, + data: { twoFactorSecretEnc: encryptedSecret }, + }) + } + + // Generate QR code + const qrCode = await totpService.generateQRCode(email!, secret) + const otpauthUri = totpService.getOtpauthUri(email!, secret) + + return NextResponse.json({ + qrCode, + secret, // Also return secret for manual entry + otpauthUri, + }) + } catch (error) { + console.error('Error setting up 2FA:', error) + return NextResponse.json( + { error: 'Failed to set up 2FA' }, + { status: 500 } + ) + } +} diff --git a/src/app/api/v1/auth/2fa/status/route.ts b/src/app/api/v1/auth/2fa/status/route.ts new file mode 100644 index 0000000..2a94932 --- /dev/null +++ b/src/app/api/v1/auth/2fa/status/route.ts @@ -0,0 +1,64 @@ +import { NextRequest, NextResponse } from 'next/server' +import { auth } from '@/lib/auth' +import { prisma } from '@/lib/prisma' +import { totpService } from '@/lib/services/totp-service' + +/** + * GET /api/v1/auth/2fa/status + * Get 2FA status for the current user + */ +export async function GET(request: NextRequest) { + try { + const session = await auth() + + if (!session?.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: userId, userType } = session.user + + let twoFactorEnabled = false + let twoFactorVerifiedAt: Date | null = null + let backupCodesEnc: string | null = null + + if (userType === 'staff') { + const staff = await prisma.staff.findUnique({ + where: { id: userId }, + select: { + twoFactorEnabled: true, + twoFactorVerifiedAt: true, + backupCodesEnc: true, + }, + }) + twoFactorEnabled = staff?.twoFactorEnabled ?? false + twoFactorVerifiedAt = staff?.twoFactorVerifiedAt ?? null + backupCodesEnc = staff?.backupCodesEnc ?? null + } else { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { + twoFactorEnabled: true, + twoFactorVerifiedAt: true, + backupCodesEnc: true, + }, + }) + twoFactorEnabled = user?.twoFactorEnabled ?? false + twoFactorVerifiedAt = user?.twoFactorVerifiedAt ?? null + backupCodesEnc = user?.backupCodesEnc ?? null + } + + const backupCodeCount = totpService.getBackupCodeCount(backupCodesEnc) + + return NextResponse.json({ + enabled: twoFactorEnabled, + enabledAt: twoFactorVerifiedAt, + backupCodesRemaining: backupCodeCount, + }) + } catch (error) { + console.error('Error getting 2FA status:', error) + return NextResponse.json( + { error: 'Failed to get 2FA status' }, + { status: 500 } + ) + } +} diff --git a/src/app/api/v1/auth/2fa/verify/route.ts b/src/app/api/v1/auth/2fa/verify/route.ts new file mode 100644 index 0000000..3710f83 --- /dev/null +++ b/src/app/api/v1/auth/2fa/verify/route.ts @@ -0,0 +1,112 @@ +import { NextRequest, NextResponse } from 'next/server' +import { auth } from '@/lib/auth' +import { prisma } from '@/lib/prisma' +import { totpService } from '@/lib/services/totp-service' + +/** + * POST /api/v1/auth/2fa/verify + * Verify TOTP code and enable 2FA + * Returns backup codes on success + */ +export async function POST(request: NextRequest) { + try { + const session = await auth() + + if (!session?.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: userId, userType } = session.user + const body = await request.json() + const { token } = body + + if (!token || typeof token !== 'string') { + return NextResponse.json( + { error: 'TOTP token is required' }, + { status: 400 } + ) + } + + // Get the stored secret + let encryptedSecret: string | null = null + let isAlreadyEnabled = false + + if (userType === 'staff') { + const staff = await prisma.staff.findUnique({ + where: { id: userId }, + select: { twoFactorSecretEnc: true, twoFactorEnabled: true }, + }) + encryptedSecret = staff?.twoFactorSecretEnc ?? null + isAlreadyEnabled = staff?.twoFactorEnabled ?? false + } else { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { twoFactorSecretEnc: true, twoFactorEnabled: true }, + }) + encryptedSecret = user?.twoFactorSecretEnc ?? null + isAlreadyEnabled = user?.twoFactorEnabled ?? false + } + + if (isAlreadyEnabled) { + return NextResponse.json( + { error: '2FA is already enabled' }, + { status: 400 } + ) + } + + if (!encryptedSecret) { + return NextResponse.json( + { error: 'No 2FA setup in progress. Please start setup first.' }, + { status: 400 } + ) + } + + // Decrypt and verify + const secret = totpService.decryptSecret(encryptedSecret) + const isValid = totpService.verifyToken(token, secret) + + if (!isValid) { + return NextResponse.json( + { error: 'Invalid TOTP token. Please try again.' }, + { status: 400 } + ) + } + + // Generate backup codes + const { plainCodes, hashedCodesJson } = await totpService.generateBackupCodes() + + // Enable 2FA + const now = new Date() + if (userType === 'staff') { + await prisma.staff.update({ + where: { id: userId }, + data: { + twoFactorEnabled: true, + twoFactorVerifiedAt: now, + backupCodesEnc: hashedCodesJson, + }, + }) + } else { + await prisma.user.update({ + where: { id: userId }, + data: { + twoFactorEnabled: true, + twoFactorVerifiedAt: now, + backupCodesEnc: hashedCodesJson, + }, + }) + } + + return NextResponse.json({ + enabled: true, + backupCodes: totpService.formatBackupCodes(plainCodes), + message: '2FA has been enabled. Please save your backup codes securely.', + }) + } catch (error) { + console.error('Error verifying 2FA:', error) + return NextResponse.json( + { error: 'Failed to verify 2FA' }, + { status: 500 } + ) + } +} diff --git a/src/app/api/v1/auth/accept-invite/route.ts b/src/app/api/v1/auth/accept-invite/route.ts new file mode 100644 index 0000000..0a11066 --- /dev/null +++ b/src/app/api/v1/auth/accept-invite/route.ts @@ -0,0 +1,134 @@ +import { NextRequest, NextResponse } from 'next/server' +import bcrypt from 'bcryptjs' +import { prisma } from '@/lib/prisma' + +interface AcceptInviteRequest { + token: string + name: string + password: string +} + +/** + * POST /api/v1/auth/accept-invite + * Accept invitation and create staff account (public endpoint) + */ +export async function POST(request: NextRequest) { + try { + const body: AcceptInviteRequest = await request.json() + + // Validate required fields + if (!body.token) { + return NextResponse.json( + { error: 'Invitation token is required' }, + { status: 400 } + ) + } + + if (!body.name || body.name.trim().length < 2) { + return NextResponse.json( + { error: 'Name must be at least 2 characters' }, + { status: 400 } + ) + } + + if (!body.password || body.password.length < 8) { + return NextResponse.json( + { error: 'Password must be at least 8 characters' }, + { status: 400 } + ) + } + + // Find the invitation + const invitation = await prisma.staffInvitation.findUnique({ + where: { token: body.token }, + }) + + if (!invitation) { + return NextResponse.json( + { error: 'Invalid or expired invitation' }, + { status: 404 } + ) + } + + // Check if expired + if (invitation.expiresAt < new Date()) { + // Clean up expired invitation + await prisma.staffInvitation.delete({ + where: { id: invitation.id }, + }) + return NextResponse.json( + { error: 'This invitation has expired' }, + { status: 410 } + ) + } + + // Check if email is already registered + const existingStaff = await prisma.staff.findUnique({ + where: { email: invitation.email }, + }) + + if (existingStaff) { + // Clean up invitation since email is taken + await prisma.staffInvitation.delete({ + where: { id: invitation.id }, + }) + return NextResponse.json( + { error: 'An account with this email already exists' }, + { status: 409 } + ) + } + + // Hash password + const passwordHash = await bcrypt.hash(body.password, 10) + + // Create staff account and delete invitation in a transaction + const newStaff = await prisma.$transaction(async (tx) => { + // Create the staff member + const staff = await tx.staff.create({ + data: { + email: invitation.email, + name: body.name.trim(), + passwordHash, + role: invitation.role, + status: 'ACTIVE', + invitedBy: invitation.invitedBy, + }, + select: { + id: true, + email: true, + name: true, + role: true, + status: true, + createdAt: true, + }, + }) + + // Delete the invitation + await tx.staffInvitation.delete({ + where: { id: invitation.id }, + }) + + return staff + }) + + return NextResponse.json( + { + success: true, + message: 'Account created successfully. You can now sign in.', + staff: { + id: newStaff.id, + email: newStaff.email, + name: newStaff.name, + role: newStaff.role, + }, + }, + { status: 201 } + ) + } catch (error) { + console.error('Error accepting invitation:', error) + return NextResponse.json( + { error: 'Failed to create account' }, + { status: 500 } + ) + } +} diff --git a/src/app/api/v1/auth/invite/[token]/route.ts b/src/app/api/v1/auth/invite/[token]/route.ts new file mode 100644 index 0000000..758bde9 --- /dev/null +++ b/src/app/api/v1/auth/invite/[token]/route.ts @@ -0,0 +1,74 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' + +/** + * GET /api/v1/auth/invite/[token] + * Validate an invitation token (public endpoint) + */ +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ token: string }> } +) { + try { + const { token } = await params + + const invitation = await prisma.staffInvitation.findUnique({ + where: { token }, + select: { + id: true, + email: true, + role: true, + expiresAt: true, + invitedBy: true, + createdAt: true, + }, + }) + + if (!invitation) { + return NextResponse.json( + { error: 'Invalid or expired invitation' }, + { status: 404 } + ) + } + + // Check if expired + if (invitation.expiresAt < new Date()) { + return NextResponse.json( + { error: 'This invitation has expired' }, + { status: 410 } + ) + } + + // Check if email is already registered + const existingStaff = await prisma.staff.findUnique({ + where: { email: invitation.email }, + }) + + if (existingStaff) { + return NextResponse.json( + { error: 'An account with this email already exists' }, + { status: 409 } + ) + } + + // Get inviter name for display + const inviter = await prisma.staff.findUnique({ + where: { id: invitation.invitedBy }, + select: { name: true }, + }) + + return NextResponse.json({ + valid: true, + email: invitation.email, + role: invitation.role, + expiresAt: invitation.expiresAt, + invitedBy: inviter?.name || 'Unknown', + }) + } catch (error) { + console.error('Error validating invitation:', error) + return NextResponse.json( + { error: 'Failed to validate invitation' }, + { status: 500 } + ) + } +} diff --git a/src/app/api/v1/jobs/[id]/logs/route.ts b/src/app/api/v1/jobs/[id]/logs/route.ts index fefd643..0e87d6b 100644 --- a/src/app/api/v1/jobs/[id]/logs/route.ts +++ b/src/app/api/v1/jobs/[id]/logs/route.ts @@ -1,28 +1,103 @@ import { NextRequest, NextResponse } from 'next/server' -import { jobService } from '@/lib/services/job-service' +import { prisma } from '@/lib/prisma' +import { verifyRunnerToken } from '@/lib/services/config-generator' +import { LogLevel } from '@prisma/client' +import { auth } from '@/lib/auth' -// Verify runner token from header -async function verifyRunnerAuth( +/** + * POST /api/v1/jobs/[id]/logs + * Called by the container's entrypoint.sh to stream logs to the Hub + * Authenticated via X-Runner-Token header + */ +export async function POST( request: NextRequest, - jobId: string -): Promise<{ authorized: boolean; error?: string }> { - const runnerToken = request.headers.get('X-Runner-Token') + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id: jobId } = await params + const runnerToken = request.headers.get('X-Runner-Token') - if (!runnerToken) { - return { authorized: false, error: 'Missing X-Runner-Token header' } + if (!runnerToken) { + return NextResponse.json( + { error: 'Missing X-Runner-Token header' }, + { status: 401 } + ) + } + + // Find the job + const job = await prisma.provisioningJob.findUnique({ + where: { id: jobId }, + select: { + id: true, + runnerTokenHash: true, + orderId: true, + }, + }) + + if (!job) { + return NextResponse.json( + { error: 'Job not found' }, + { status: 404 } + ) + } + + // Verify the runner token + if (!job.runnerTokenHash || !verifyRunnerToken(runnerToken, job.runnerTokenHash)) { + return NextResponse.json( + { error: 'Invalid runner token' }, + { status: 401 } + ) + } + + // Parse the log entry + const body = await request.json() + const { level, message, step, progress } = body + + if (!message) { + return NextResponse.json( + { error: 'Missing message field' }, + { status: 400 } + ) + } + + // Map level string to LogLevel enum + const logLevel = mapLogLevel(level) + + // Create the log entry + await prisma.jobLog.create({ + data: { + jobId, + level: logLevel, + message, + step: step || null, + progress: typeof progress === 'number' ? progress : null, + }, + }) + + // Also create a provisioning log for the order (for backward compatibility) + await prisma.provisioningLog.create({ + data: { + orderId: job.orderId, + level: logLevel, + message, + step: step || null, + }, + }) + + return NextResponse.json({ ok: true }) + } catch (error) { + console.error('Error creating job log:', error) + return NextResponse.json( + { error: 'Failed to create log entry' }, + { status: 500 } + ) } - - const isValid = await jobService.verifyRunnerToken(jobId, runnerToken) - if (!isValid) { - return { authorized: false, error: 'Invalid runner token' } - } - - return { authorized: true } } /** * GET /api/v1/jobs/[id]/logs - * Get job logs + * Get all logs for a job + * Requires admin session authentication OR valid runner token */ export async function GET( request: NextRequest, @@ -31,74 +106,63 @@ export async function GET( try { const { id: jobId } = await params - const auth = await verifyRunnerAuth(request, jobId) - if (!auth.authorized) { - return NextResponse.json({ error: auth.error }, { status: 401 }) + // Check for runner token (for container callbacks) + const runnerToken = request.headers.get('X-Runner-Token') + + if (runnerToken) { + // Verify runner token for container access + const job = await prisma.provisioningJob.findUnique({ + where: { id: jobId }, + select: { runnerTokenHash: true }, + }) + + if (!job?.runnerTokenHash || !verifyRunnerToken(runnerToken, job.runnerTokenHash)) { + return NextResponse.json( + { error: 'Invalid runner token' }, + { status: 401 } + ) + } + } else { + // Require admin session for UI access + const session = await auth() + if (!session || session.user.userType !== 'staff') { + return NextResponse.json( + { error: 'Unauthorized - admin access required' }, + { status: 401 } + ) + } } - // Optional: filter logs since a timestamp - const sinceParam = request.nextUrl.searchParams.get('since') - const since = sinceParam ? new Date(sinceParam) : undefined - - const logs = await jobService.getLogs(jobId, since) + const logs = await prisma.jobLog.findMany({ + where: { jobId }, + orderBy: { timestamp: 'asc' }, + }) return NextResponse.json({ logs }) } catch (error) { console.error('Error getting job logs:', error) return NextResponse.json( - { error: 'Failed to get job logs' }, + { error: 'Failed to get logs' }, { status: 500 } ) } } /** - * POST /api/v1/jobs/[id]/logs - * Add a log entry + * Map string level to LogLevel enum */ -export async function POST( - request: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - try { - const { id: jobId } = await params - - const auth = await verifyRunnerAuth(request, jobId) - if (!auth.authorized) { - return NextResponse.json({ error: auth.error }, { status: 401 }) - } - - const body = await request.json() - const { level, message, step, progress } = body - - if (!level || !message) { - return NextResponse.json( - { error: 'level and message are required' }, - { status: 400 } - ) - } - - if (!['info', 'warn', 'error'].includes(level)) { - return NextResponse.json( - { error: 'level must be info, warn, or error' }, - { status: 400 } - ) - } - - await jobService.addLog( - jobId, - level as 'info' | 'warn' | 'error', - message, - step, - progress - ) - - return NextResponse.json({ success: true }) - } catch (error) { - console.error('Error adding job log:', error) - return NextResponse.json( - { error: 'Failed to add job log' }, - { status: 500 } - ) +function mapLogLevel(level: string | undefined): LogLevel { + if (!level) return LogLevel.INFO + switch (level.toLowerCase()) { + case 'error': + return LogLevel.ERROR + case 'warn': + case 'warning': + return LogLevel.WARN + case 'debug': + return LogLevel.DEBUG + case 'info': + default: + return LogLevel.INFO } } diff --git a/src/app/api/v1/jobs/[id]/route.ts b/src/app/api/v1/jobs/[id]/route.ts index 4774917..0983419 100644 --- a/src/app/api/v1/jobs/[id]/route.ts +++ b/src/app/api/v1/jobs/[id]/route.ts @@ -1,28 +1,12 @@ import { NextRequest, NextResponse } from 'next/server' -import { jobService } from '@/lib/services/job-service' - -// Verify runner token from header -async function verifyRunnerAuth( - request: NextRequest, - jobId: string -): Promise<{ authorized: boolean; error?: string }> { - const runnerToken = request.headers.get('X-Runner-Token') - - if (!runnerToken) { - return { authorized: false, error: 'Missing X-Runner-Token header' } - } - - const isValid = await jobService.verifyRunnerToken(jobId, runnerToken) - if (!isValid) { - return { authorized: false, error: 'Invalid runner token' } - } - - return { authorized: true } -} +import { prisma } from '@/lib/prisma' +import { verifyRunnerToken } from '@/lib/services/config-generator' +import { credentialService } from '@/lib/services/credential-service' +import { JobStatus, OrderStatus } from '@prisma/client' /** * GET /api/v1/jobs/[id] - * Get job status + * Get job details */ export async function GET( request: NextRequest, @@ -31,21 +15,38 @@ export async function GET( try { const { id: jobId } = await params - const auth = await verifyRunnerAuth(request, jobId) - if (!auth.authorized) { - return NextResponse.json({ error: auth.error }, { status: 401 }) + const job = await prisma.provisioningJob.findUnique({ + where: { id: jobId }, + include: { + order: { + select: { + id: true, + domain: true, + status: true, + }, + }, + logs: { + orderBy: { timestamp: 'desc' }, + take: 50, + }, + }, + }) + + if (!job) { + return NextResponse.json( + { error: 'Job not found' }, + { status: 404 } + ) } - const status = await jobService.getJobStatus(jobId) - if (!status) { - return NextResponse.json({ error: 'Job not found' }, { status: 404 }) - } + // Remove sensitive fields + const { runnerTokenHash, configSnapshot, ...safeJob } = job - return NextResponse.json(status) + return NextResponse.json(safeJob) } catch (error) { - console.error('Error getting job status:', error) + console.error('Error getting job:', error) return NextResponse.json( - { error: 'Failed to get job status' }, + { error: 'Failed to get job' }, { status: 500 } ) } @@ -53,7 +54,8 @@ export async function GET( /** * PATCH /api/v1/jobs/[id] - * Update job status (complete or fail) + * Update job status (called by container on completion/failure) + * Authenticated via X-Runner-Token header */ export async function PATCH( request: NextRequest, @@ -61,42 +63,148 @@ export async function PATCH( ) { try { const { id: jobId } = await params + const runnerToken = request.headers.get('X-Runner-Token') - const auth = await verifyRunnerAuth(request, jobId) - if (!auth.authorized) { - return NextResponse.json({ error: auth.error }, { status: 401 }) + if (!runnerToken) { + return NextResponse.json( + { error: 'Missing X-Runner-Token header' }, + { status: 401 } + ) } + // Find the job + const job = await prisma.provisioningJob.findUnique({ + where: { id: jobId }, + select: { + id: true, + runnerTokenHash: true, + orderId: true, + status: true, + }, + }) + + if (!job) { + return NextResponse.json( + { error: 'Job not found' }, + { status: 404 } + ) + } + + // Verify the runner token + if (!job.runnerTokenHash || !verifyRunnerToken(runnerToken, job.runnerTokenHash)) { + return NextResponse.json( + { error: 'Invalid runner token' }, + { status: 401 } + ) + } + + // Parse the update const body = await request.json() + console.log(`[Job ${jobId}] Raw request body:`, JSON.stringify(body)) + console.log(`[Job ${jobId}] Body keys:`, Object.keys(body)) const { status, error, result } = body - if (status === 'completed') { - await jobService.completeJob(jobId, result) - return NextResponse.json({ success: true, message: 'Job completed successfully' }) - } else if (status === 'failed') { - if (!error) { - return NextResponse.json( - { error: 'Error message required for failed status' }, - { status: 400 } - ) - } - const retryInfo = await jobService.failJob(jobId, error) - return NextResponse.json({ - success: true, - willRetry: retryInfo.willRetry, - nextRetryAt: retryInfo.nextRetryAt?.toISOString(), - }) - } else { + if (!status) { return NextResponse.json( - { error: 'Invalid status. Must be "completed" or "failed"' }, + { error: 'Missing status field' }, { status: 400 } ) } + + // Map status string to JobStatus enum + const jobStatus = mapJobStatus(status) + const isCompleted = jobStatus === JobStatus.COMPLETED + const isFailed = jobStatus === JobStatus.FAILED + + // Update the job + await prisma.provisioningJob.update({ + where: { id: jobId }, + data: { + status: jobStatus, + error: error || null, + result: result || null, + completedAt: (isCompleted || isFailed) ? new Date() : undefined, + }, + }) + + // Update the order status based on job result + if (isCompleted) { + console.log(`[Job ${jobId}] Job completed. Result keys:`, result ? Object.keys(result) : 'null') + console.log(`[Job ${jobId}] Full result:`, JSON.stringify(result, null, 2)) + + // Extract URLs from result if provided + const portainerUrl = result?.portainer_url || result?.portainerUrl + const dashboardUrl = result?.dashboard_url || result?.dashboardUrl + + // Extract Portainer credentials from result + const portainerUsername = result?.portainer_username || result?.portainerUsername + const portainerPassword = result?.portainer_password || result?.portainerPassword + + console.log(`[Job ${jobId}] Extracted - username: ${portainerUsername ? 'present' : 'missing'}, password: ${portainerPassword ? 'present' : 'missing'}`) + + // Build update data + const orderUpdateData: Record = { + status: OrderStatus.FULFILLED, + completedAt: new Date(), + portainerUrl: portainerUrl || null, + dashboardUrl: dashboardUrl || null, + // Clear the password after successful provisioning + serverPasswordEncrypted: null, + } + + // Add Portainer credentials if provided + if (portainerUsername && portainerPassword) { + try { + orderUpdateData.portainerUsername = portainerUsername + orderUpdateData.portainerPasswordEnc = credentialService.encrypt(portainerPassword) + orderUpdateData.credentialsSyncedAt = new Date() + console.log(`[Job ${jobId}] Stored Portainer credentials for order ${job.orderId}`) + } catch (encryptError) { + console.error(`[Job ${jobId}] Failed to encrypt Portainer password:`, encryptError) + // Continue without credentials rather than failing the whole job + } + } else { + console.log(`[Job ${jobId}] No Portainer credentials in result for order ${job.orderId}`) + console.log(`[Job ${jobId}] username value: "${portainerUsername}", password value: "${portainerPassword ? '[PRESENT]' : '[MISSING]'}"`) + } + + await prisma.order.update({ + where: { id: job.orderId }, + data: orderUpdateData, + }) + } else if (isFailed) { + await prisma.order.update({ + where: { id: job.orderId }, + data: { + status: OrderStatus.FAILED, + failureReason: error || 'Provisioning failed', + }, + }) + } + + return NextResponse.json({ ok: true }) } catch (error) { - console.error('Error updating job status:', error) + console.error('Error updating job:', error) return NextResponse.json( - { error: 'Failed to update job status' }, + { error: 'Failed to update job' }, { status: 500 } ) } } + +/** + * Map status string to JobStatus enum + */ +function mapJobStatus(status: string): JobStatus { + switch (status.toLowerCase()) { + case 'completed': + return JobStatus.COMPLETED + case 'failed': + return JobStatus.FAILED + case 'running': + return JobStatus.RUNNING + case 'pending': + default: + return JobStatus.PENDING + } +} diff --git a/src/app/api/v1/orchestrator/commands/route.ts b/src/app/api/v1/orchestrator/commands/route.ts new file mode 100644 index 0000000..348428b --- /dev/null +++ b/src/app/api/v1/orchestrator/commands/route.ts @@ -0,0 +1,166 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' + +interface CommandResultRequest { + commandId: string + status: 'EXECUTING' | 'COMPLETED' | 'FAILED' + result?: Record + error?: string +} + +/** + * Validate Hub API key from Authorization header + */ +async function validateHubApiKey(request: NextRequest) { + const authHeader = request.headers.get('authorization') + + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return null + } + + const hubApiKey = authHeader.replace('Bearer ', '') + + const serverConnection = await prisma.serverConnection.findUnique({ + where: { hubApiKey }, + }) + + return serverConnection +} + +/** + * GET /api/v1/orchestrator/commands + * Get pending commands for this orchestrator + */ +export async function GET(request: NextRequest) { + try { + const serverConnection = await validateHubApiKey(request) + + if (!serverConnection) { + return NextResponse.json( + { error: 'Invalid or missing API key' }, + { status: 401 } + ) + } + + // Get pending or sent commands + const commands = await prisma.remoteCommand.findMany({ + where: { + serverConnectionId: serverConnection.id, + status: { in: ['PENDING', 'SENT'] }, + }, + orderBy: { queuedAt: 'asc' }, + }) + + // Mark pending as sent + const pendingIds = commands + .filter((c) => c.status === 'PENDING') + .map((c) => c.id) + + if (pendingIds.length > 0) { + await prisma.remoteCommand.updateMany({ + where: { id: { in: pendingIds } }, + data: { + status: 'SENT', + sentAt: new Date(), + }, + }) + } + + return NextResponse.json({ + commands: commands.map((cmd) => ({ + id: cmd.id, + type: cmd.type, + payload: cmd.payload, + queuedAt: cmd.queuedAt, + })), + }) + } catch (error) { + console.error('Get commands error:', error) + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ) + } +} + +/** + * POST /api/v1/orchestrator/commands + * Report command execution results + */ +export async function POST(request: NextRequest) { + try { + const serverConnection = await validateHubApiKey(request) + + if (!serverConnection) { + return NextResponse.json( + { error: 'Invalid or missing API key' }, + { status: 401 } + ) + } + + const body: CommandResultRequest = await request.json() + + // Validate required fields + if (!body.commandId || !body.status) { + return NextResponse.json( + { error: 'Missing required fields: commandId, status' }, + { status: 400 } + ) + } + + // Find the command + const command = await prisma.remoteCommand.findFirst({ + where: { + id: body.commandId, + serverConnectionId: serverConnection.id, + }, + }) + + if (!command) { + return NextResponse.json( + { error: 'Command not found' }, + { status: 404 } + ) + } + + // Update command status + const updateData: { + status: 'EXECUTING' | 'COMPLETED' | 'FAILED' + result?: object + errorMessage?: string + executedAt?: Date + completedAt?: Date + } = { + status: body.status, + } + + if (body.status === 'EXECUTING') { + updateData.executedAt = new Date() + } else if (body.status === 'COMPLETED' || body.status === 'FAILED') { + updateData.completedAt = new Date() + if (body.result) { + updateData.result = body.result as object + } + if (body.error) { + updateData.errorMessage = body.error + } + } + + await prisma.remoteCommand.update({ + where: { id: body.commandId }, + data: updateData, + }) + + return NextResponse.json({ + success: true, + commandId: body.commandId, + status: body.status, + }) + } catch (error) { + console.error('Command result error:', error) + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ) + } +} diff --git a/src/app/api/v1/orchestrator/heartbeat/route.ts b/src/app/api/v1/orchestrator/heartbeat/route.ts new file mode 100644 index 0000000..14ebb64 --- /dev/null +++ b/src/app/api/v1/orchestrator/heartbeat/route.ts @@ -0,0 +1,138 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { credentialService } from '@/lib/services/credential-service' + +interface HeartbeatRequest { + agentVersion?: string + status?: { + containers?: number + cpuUsage?: number + memoryUsage?: number + diskUsage?: number + } + credentials?: { + portainer?: { + username: string + password: string + } + // Add other tool credentials here as needed + } +} + +/** + * Validate Hub API key from Authorization header + */ +async function validateHubApiKey(request: NextRequest) { + const authHeader = request.headers.get('authorization') + + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return null + } + + const hubApiKey = authHeader.replace('Bearer ', '') + + const serverConnection = await prisma.serverConnection.findUnique({ + where: { hubApiKey }, + include: { + order: { + select: { + id: true, + domain: true, + serverIp: true, + }, + }, + }, + }) + + return serverConnection +} + +/** + * POST /api/v1/orchestrator/heartbeat + * Called periodically by Orchestrators to report health and check for commands + */ +export async function POST(request: NextRequest) { + try { + const serverConnection = await validateHubApiKey(request) + + if (!serverConnection) { + return NextResponse.json( + { error: 'Invalid or missing API key' }, + { status: 401 } + ) + } + + const body: HeartbeatRequest = await request.json().catch(() => ({})) + + // Update last heartbeat and status + await prisma.serverConnection.update({ + where: { id: serverConnection.id }, + data: { + lastHeartbeat: new Date(), + status: 'ONLINE', + agentVersion: body.agentVersion || serverConnection.agentVersion, + }, + }) + + // Sync Portainer credentials if provided + if (body.credentials?.portainer && credentialService.isConfigured()) { + const { username, password } = body.credentials.portainer + + // Build Portainer URL from server IP (Portainer is accessed directly, not via subdomain) + const portainerUrl = serverConnection.order.serverIp + ? `https://${serverConnection.order.serverIp}:9443` + : null + + // Encrypt and store credentials on the order + await prisma.order.update({ + where: { id: serverConnection.order.id }, + data: { + portainerUrl, + portainerUsername: username, + portainerPasswordEnc: credentialService.encrypt(password), + credentialsSyncedAt: new Date(), + }, + }) + } + + // Check for pending commands + const pendingCommands = await prisma.remoteCommand.findMany({ + where: { + serverConnectionId: serverConnection.id, + status: 'PENDING', + }, + orderBy: { queuedAt: 'asc' }, + take: 10, // Max 10 commands per heartbeat + }) + + // Mark commands as sent + if (pendingCommands.length > 0) { + await prisma.remoteCommand.updateMany({ + where: { + id: { in: pendingCommands.map((c) => c.id) }, + }, + data: { + status: 'SENT', + sentAt: new Date(), + }, + }) + } + + return NextResponse.json({ + success: true, + serverId: serverConnection.id, + domain: serverConnection.order.domain, + commands: pendingCommands.map((cmd) => ({ + id: cmd.id, + type: cmd.type, + payload: cmd.payload, + })), + }) + } catch (error) { + console.error('Heartbeat error:', error) + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ) + } +} diff --git a/src/app/api/v1/orchestrator/register/route.ts b/src/app/api/v1/orchestrator/register/route.ts new file mode 100644 index 0000000..a260534 --- /dev/null +++ b/src/app/api/v1/orchestrator/register/route.ts @@ -0,0 +1,110 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { randomBytes } from 'crypto' + +interface RegisterRequest { + registrationToken: string + orchestratorUrl?: string + agentVersion?: string +} + +/** + * POST /api/v1/orchestrator/register + * Called by Orchestrators to register with the Hub after deployment + */ +export async function POST(request: NextRequest) { + try { + const body: RegisterRequest = await request.json() + + // Validate required fields + if (!body.registrationToken) { + return NextResponse.json( + { error: 'Missing required field: registrationToken' }, + { status: 400 } + ) + } + + // Find the server connection by registration token + const serverConnection = await prisma.serverConnection.findUnique({ + where: { registrationToken: body.registrationToken }, + include: { + order: { + include: { + user: { + select: { email: true, name: true }, + }, + }, + }, + }, + }) + + if (!serverConnection) { + return NextResponse.json( + { error: 'Invalid registration token' }, + { status: 401 } + ) + } + + // Check if already registered + if (serverConnection.status !== 'PENDING') { + return NextResponse.json( + { + error: 'Server already registered', + status: serverConnection.status, + }, + { status: 409 } + ) + } + + // Generate Hub API key for this server + const hubApiKey = `hk_${randomBytes(32).toString('hex')}` + + // Update server connection with registration info + const updatedConnection = await prisma.serverConnection.update({ + where: { id: serverConnection.id }, + data: { + hubApiKey, + orchestratorUrl: body.orchestratorUrl || null, + agentVersion: body.agentVersion || null, + status: 'REGISTERED', + registeredAt: new Date(), + lastHeartbeat: new Date(), + }, + }) + + // Update the order status if needed + if (serverConnection.order.status === 'PROVISIONING') { + await prisma.order.update({ + where: { id: serverConnection.orderId }, + data: { + status: 'FULFILLED', + completedAt: new Date(), + }, + }) + + // Log the successful registration + await prisma.provisioningLog.create({ + data: { + orderId: serverConnection.orderId, + level: 'INFO', + message: 'Orchestrator registered successfully with Hub', + step: 'register', + }, + }) + } + + return NextResponse.json({ + success: true, + hubApiKey, + message: 'Registration successful', + serverId: updatedConnection.id, + domain: serverConnection.order.domain, + }) + } catch (error) { + console.error('Registration error:', error) + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ) + } +} diff --git a/src/app/api/v1/profile/password/route.ts b/src/app/api/v1/profile/password/route.ts new file mode 100644 index 0000000..d895e19 --- /dev/null +++ b/src/app/api/v1/profile/password/route.ts @@ -0,0 +1,89 @@ +import { NextRequest, NextResponse } from 'next/server' +import { requireStaffSession } from '@/lib/auth-helpers' +import { prisma } from '@/lib/prisma' +import bcrypt from 'bcryptjs' + +/** + * POST /api/v1/profile/password + * Change current user's password + * Requires: currentPassword, newPassword, confirmPassword + */ +export async function POST(request: NextRequest) { + try { + const session = await requireStaffSession() + + const body = await request.json() + const { currentPassword, newPassword, confirmPassword } = body + + // Validate required fields + if (!currentPassword || !newPassword || !confirmPassword) { + return NextResponse.json( + { error: 'Current password, new password, and confirmation are required' }, + { status: 400 } + ) + } + + // Validate new password length + if (newPassword.length < 8) { + return NextResponse.json( + { error: 'New password must be at least 8 characters long' }, + { status: 400 } + ) + } + + // Validate passwords match + if (newPassword !== confirmPassword) { + return NextResponse.json( + { error: 'New password and confirmation do not match' }, + { status: 400 } + ) + } + + // Get current user with password hash + const staff = await prisma.staff.findUnique({ + where: { id: session.user.id }, + select: { + id: true, + passwordHash: true, + }, + }) + + if (!staff || !staff.passwordHash) { + return NextResponse.json({ error: 'Staff not found' }, { status: 404 }) + } + + // Verify current password + const isCurrentPasswordValid = await bcrypt.compare(currentPassword, staff.passwordHash) + if (!isCurrentPasswordValid) { + return NextResponse.json({ error: 'Current password is incorrect' }, { status: 400 }) + } + + // Check that new password is different from current + const isSamePassword = await bcrypt.compare(newPassword, staff.passwordHash) + if (isSamePassword) { + return NextResponse.json( + { error: 'New password must be different from current password' }, + { status: 400 } + ) + } + + // Hash new password and update + const newPasswordHash = await bcrypt.hash(newPassword, 10) + await prisma.staff.update({ + where: { id: session.user.id }, + data: { passwordHash: newPasswordHash }, + }) + + return NextResponse.json({ + success: true, + message: 'Password changed successfully', + }) + } catch (error) { + if (typeof error === 'object' && error !== null && 'status' in error) { + const err = error as { status: number; message: string } + return NextResponse.json({ error: err.message }, { status: err.status }) + } + console.error('Error changing password:', error) + return NextResponse.json({ error: 'Failed to change password' }, { status: 500 }) + } +} diff --git a/src/app/api/v1/profile/photo/route.ts b/src/app/api/v1/profile/photo/route.ts new file mode 100644 index 0000000..9cb43c8 --- /dev/null +++ b/src/app/api/v1/profile/photo/route.ts @@ -0,0 +1,141 @@ +import { NextRequest, NextResponse } from 'next/server' +import { requireStaffSession } from '@/lib/auth-helpers' +import { prisma } from '@/lib/prisma' +import { storageService } from '@/lib/services/storage-service' + +const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB +const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp'] + +/** + * POST /api/v1/profile/photo + * Upload profile photo + */ +export async function POST(request: NextRequest) { + try { + const session = await requireStaffSession() + + // Check if storage is configured + const isConfigured = await storageService.isConfigured() + if (!isConfigured) { + return NextResponse.json( + { error: 'Storage not configured. Please configure S3/MinIO settings in Admin Settings.' }, + { status: 400 } + ) + } + + // Parse multipart form data + const formData = await request.formData() + const file = formData.get('photo') as File | null + + if (!file) { + return NextResponse.json({ error: 'No photo file provided' }, { status: 400 }) + } + + // Validate file type + if (!ALLOWED_TYPES.includes(file.type)) { + return NextResponse.json( + { error: 'Invalid file type. Allowed: JPEG, PNG, WebP' }, + { status: 400 } + ) + } + + // Validate file size + if (file.size > MAX_FILE_SIZE) { + return NextResponse.json( + { error: 'File too large. Maximum size is 5MB' }, + { status: 400 } + ) + } + + // Get current staff to check for existing photo + const staff = await prisma.staff.findUnique({ + where: { id: session.user.id }, + select: { profilePhotoKey: true }, + }) + + // Delete old photo if exists + if (staff?.profilePhotoKey) { + await storageService.deleteFile(staff.profilePhotoKey) + } + + // Generate key and upload + const key = storageService.generateKey(`staff-photos/${session.user.id}`, file.name) + const buffer = Buffer.from(await file.arrayBuffer()) + + const result = await storageService.uploadFile(key, buffer, file.type) + + if (!result.success) { + return NextResponse.json( + { error: result.error || 'Failed to upload photo' }, + { status: 500 } + ) + } + + // Update staff record + await prisma.staff.update({ + where: { id: session.user.id }, + data: { profilePhotoKey: key }, + }) + + // Get public URL + const photoUrl = await storageService.getPublicUrl(key) + + return NextResponse.json({ + success: true, + message: 'Profile photo uploaded successfully', + profilePhotoKey: key, + profilePhotoUrl: photoUrl, + }) + } catch (error) { + if (typeof error === 'object' && error !== null && 'status' in error) { + const err = error as { status: number; message: string } + return NextResponse.json({ error: err.message }, { status: err.status }) + } + console.error('Error uploading profile photo:', error) + return NextResponse.json({ error: 'Failed to upload profile photo' }, { status: 500 }) + } +} + +/** + * DELETE /api/v1/profile/photo + * Delete profile photo + */ +export async function DELETE() { + try { + const session = await requireStaffSession() + + // Get current staff + const staff = await prisma.staff.findUnique({ + where: { id: session.user.id }, + select: { profilePhotoKey: true }, + }) + + if (!staff?.profilePhotoKey) { + return NextResponse.json({ error: 'No profile photo to delete' }, { status: 400 }) + } + + // Delete from storage + const isConfigured = await storageService.isConfigured() + if (isConfigured) { + await storageService.deleteFile(staff.profilePhotoKey) + } + + // Clear from database + await prisma.staff.update({ + where: { id: session.user.id }, + data: { profilePhotoKey: null }, + }) + + return NextResponse.json({ + success: true, + message: 'Profile photo deleted successfully', + }) + } catch (error) { + if (typeof error === 'object' && error !== null && 'status' in error) { + const err = error as { status: number; message: string } + return NextResponse.json({ error: err.message }, { status: err.status }) + } + console.error('Error deleting profile photo:', error) + return NextResponse.json({ error: 'Failed to delete profile photo' }, { status: 500 }) + } +} diff --git a/src/app/api/v1/profile/route.ts b/src/app/api/v1/profile/route.ts new file mode 100644 index 0000000..62624cd --- /dev/null +++ b/src/app/api/v1/profile/route.ts @@ -0,0 +1,106 @@ +import { NextRequest, NextResponse } from 'next/server' +import { requireStaffSession } from '@/lib/auth-helpers' +import { prisma } from '@/lib/prisma' +import { storageService } from '@/lib/services/storage-service' + +/** + * GET /api/v1/profile + * Get current user's profile information + */ +export async function GET() { + try { + const session = await requireStaffSession() + + const staff = await prisma.staff.findUnique({ + where: { id: session.user.id }, + select: { + id: true, + email: true, + name: true, + role: true, + profilePhotoKey: true, + twoFactorEnabled: true, + createdAt: true, + updatedAt: true, + }, + }) + + if (!staff) { + return NextResponse.json({ error: 'Staff not found' }, { status: 404 }) + } + + // Get profile photo URL if exists + let profilePhotoUrl: string | null = null + if (staff.profilePhotoKey) { + profilePhotoUrl = await storageService.getPublicUrl(staff.profilePhotoKey) + } + + return NextResponse.json({ + ...staff, + profilePhotoUrl, + }) + } catch (error) { + if (typeof error === 'object' && error !== null && 'status' in error) { + const err = error as { status: number; message: string } + return NextResponse.json({ error: err.message }, { status: err.status }) + } + console.error('Error fetching profile:', error) + return NextResponse.json({ error: 'Failed to fetch profile' }, { status: 500 }) + } +} + +/** + * PATCH /api/v1/profile + * Update current user's profile information + */ +export async function PATCH(request: NextRequest) { + try { + const session = await requireStaffSession() + + const body = await request.json() + const { name } = body + + // Validate name if provided + if (name !== undefined && typeof name !== 'string') { + return NextResponse.json({ error: 'Invalid name' }, { status: 400 }) + } + + const updateData: { name?: string } = {} + if (name !== undefined) { + updateData.name = name.trim() || null + } + + const staff = await prisma.staff.update({ + where: { id: session.user.id }, + data: updateData, + select: { + id: true, + email: true, + name: true, + role: true, + profilePhotoKey: true, + twoFactorEnabled: true, + createdAt: true, + updatedAt: true, + }, + }) + + // Get profile photo URL if exists + let profilePhotoUrl: string | null = null + if (staff.profilePhotoKey) { + profilePhotoUrl = await storageService.getPublicUrl(staff.profilePhotoKey) + } + + return NextResponse.json({ + ...staff, + profilePhotoUrl, + }) + } catch (error) { + if (typeof error === 'object' && error !== null && 'status' in error) { + const err = error as { status: number; message: string } + return NextResponse.json({ error: err.message }, { status: err.status }) + } + console.error('Error updating profile:', error) + return NextResponse.json({ error: 'Failed to update profile' }, { status: 500 }) + } +} diff --git a/src/app/api/v1/public/orders/route.ts b/src/app/api/v1/public/orders/route.ts new file mode 100644 index 0000000..9c4a645 --- /dev/null +++ b/src/app/api/v1/public/orders/route.ts @@ -0,0 +1,281 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { OrderStatus, SubscriptionTier, AutomationMode } from '@prisma/client' +import { z } from 'zod' +import { randomBytes } from 'crypto' +import bcrypt from 'bcryptjs' + +/** + * Generate a customer ID slug from company/user name + * Only lowercase letters allowed (env_setup.sh requires ^[a-z]+$) + */ +function slugifyCustomer(name: string): string { + return name + .toLowerCase() + .replace(/[^a-z]/g, '') + .substring(0, 32) +} + +/** + * Generate a unique license key for the order + */ +function generateLicenseKey(): string { + const hex = randomBytes(16).toString('hex') + return `lb_inst_${hex}` +} + +/** + * Public API Key Authentication + * Uses X-API-Key header for external service authentication + */ +function validateApiKey(request: NextRequest): boolean { + const apiKey = request.headers.get('x-api-key') + const validApiKey = process.env.PUBLIC_API_KEY + + if (!validApiKey) { + console.error('PUBLIC_API_KEY not configured') + return false + } + + return apiKey === validApiKey +} + +// Validation schema for order creation +const createOrderSchema = z.object({ + // User info - either userId or user details for creation + userId: z.string().optional(), + userEmail: z.string().email().optional(), + userName: z.string().optional(), + userCompany: z.string().optional(), + + // Order details + domain: z.string().min(1), + tier: z.enum(['HUB_DASHBOARD', 'CONTROL_PANEL']), + tools: z.array(z.string()).min(1), + + // Optional external reference + externalOrderId: z.string().optional(), + paymentReference: z.string().optional(), +}).refine( + (data) => data.userId || data.userEmail, + { message: 'Either userId or userEmail must be provided' } +) + +/** + * POST /api/v1/public/orders + * Create a new order from external source (website) + * Orders created via this endpoint are set to AUTO automation mode + */ +export async function POST(request: NextRequest) { + try { + // Validate API key + if (!validateApiKey(request)) { + return NextResponse.json( + { error: 'Unauthorized - Invalid API key' }, + { status: 401 } + ) + } + + const body = await request.json() + + // Validate request body + const parseResult = createOrderSchema.safeParse(body) + if (!parseResult.success) { + return NextResponse.json( + { + error: 'Validation failed', + details: parseResult.error.flatten().fieldErrors + }, + { status: 400 } + ) + } + + const data = parseResult.data + + // Find or create user + let userId = data.userId + + if (!userId && data.userEmail) { + // Try to find existing user by email + let user = await prisma.user.findUnique({ + where: { email: data.userEmail }, + }) + + if (!user) { + // Create new customer user with a random password + // (user can reset via email later if they want to access the Hub directly) + const randomPassword = randomBytes(16).toString('hex') + const passwordHash = await bcrypt.hash(randomPassword, 10) + + user = await prisma.user.create({ + data: { + email: data.userEmail, + name: data.userName, + company: data.userCompany, + passwordHash, + }, + }) + } + + userId = user.id + } + + if (!userId) { + return NextResponse.json( + { error: 'User not found and could not be created' }, + { status: 400 } + ) + } + + // Verify user exists + const user = await prisma.user.findUnique({ where: { id: userId } }) + if (!user) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }) + } + + // Auto-generate provisioning config from user's company/name + const displayName = user.company || user.name || 'customer' + const customer = slugifyCustomer(displayName) || 'customer' + const companyName = displayName + const licenseKey = generateLicenseKey() + + // Create order with AUTO automation mode (website orders) + const order = await prisma.order.create({ + data: { + userId, + domain: data.domain, + tier: data.tier as SubscriptionTier, + tools: data.tools, + status: OrderStatus.PAYMENT_CONFIRMED, + configJson: { + tools: data.tools, + tier: data.tier, + domain: data.domain, + externalOrderId: data.externalOrderId, + paymentReference: data.paymentReference, + }, + automationMode: AutomationMode.AUTO, // Website orders run automatically + source: 'website', + // Auto-generated provisioning config + customer, + companyName, + licenseKey, + }, + include: { + user: { + select: { + id: true, + name: true, + email: true, + company: true, + }, + }, + }, + }) + + return NextResponse.json( + { + success: true, + order: { + id: order.id, + domain: order.domain, + status: order.status, + tier: order.tier, + automationMode: order.automationMode, + createdAt: order.createdAt, + user: order.user, + }, + }, + { status: 201 } + ) + } catch (error) { + console.error('Error creating public order:', error) + return NextResponse.json( + { error: 'Failed to create order' }, + { status: 500 } + ) + } +} + +/** + * GET /api/v1/public/orders/[id] + * Get order status (for external tracking) + */ +export async function GET(request: NextRequest) { + try { + // Validate API key + if (!validateApiKey(request)) { + return NextResponse.json( + { error: 'Unauthorized - Invalid API key' }, + { status: 401 } + ) + } + + const searchParams = request.nextUrl.searchParams + const orderId = searchParams.get('id') + const externalOrderId = searchParams.get('externalOrderId') + + if (!orderId && !externalOrderId) { + return NextResponse.json( + { error: 'Either id or externalOrderId query parameter required' }, + { status: 400 } + ) + } + + let order = null + + if (orderId) { + order = await prisma.order.findUnique({ + where: { id: orderId }, + select: { + id: true, + domain: true, + status: true, + tier: true, + automationMode: true, + createdAt: true, + provisioningStartedAt: true, + completedAt: true, + failureReason: true, + dashboardUrl: true, + portainerUrl: true, + }, + }) + } else if (externalOrderId) { + // Search by external order ID in configJson + order = await prisma.order.findFirst({ + where: { + configJson: { + path: ['externalOrderId'], + equals: externalOrderId, + }, + }, + select: { + id: true, + domain: true, + status: true, + tier: true, + automationMode: true, + createdAt: true, + provisioningStartedAt: true, + completedAt: true, + failureReason: true, + dashboardUrl: true, + portainerUrl: true, + }, + }) + } + + if (!order) { + return NextResponse.json({ error: 'Order not found' }, { status: 404 }) + } + + return NextResponse.json({ order }) + } catch (error) { + console.error('Error fetching public order:', error) + return NextResponse.json( + { error: 'Failed to fetch order' }, + { status: 500 } + ) + } +} diff --git a/src/app/globals.css b/src/app/globals.css index 00b08e3..274d03f 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -57,3 +57,43 @@ @apply bg-background text-foreground; } } + +/* Staggered animation delay utilities for timeline */ +@layer utilities { + .animate-stagger-1 { + animation-delay: 50ms; + } + .animate-stagger-2 { + animation-delay: 100ms; + } + .animate-stagger-3 { + animation-delay: 150ms; + } + .animate-stagger-4 { + animation-delay: 200ms; + } + .animate-stagger-5 { + animation-delay: 250ms; + } + .animate-stagger-6 { + animation-delay: 300ms; + } + .animate-stagger-7 { + animation-delay: 350ms; + } + .animate-stagger-8 { + animation-delay: 400ms; + } + + /* For fade-in-up animation with stagger */ + .animate-stagger-1.animate-fade-in-up, + .animate-stagger-2.animate-fade-in-up, + .animate-stagger-3.animate-fade-in-up, + .animate-stagger-4.animate-fade-in-up, + .animate-stagger-5.animate-fade-in-up, + .animate-stagger-6.animate-fade-in-up, + .animate-stagger-7.animate-fade-in-up, + .animate-stagger-8.animate-fade-in-up { + opacity: 0; + } +} diff --git a/src/components/admin/AddCustomerDialog.tsx b/src/components/admin/AddCustomerDialog.tsx new file mode 100644 index 0000000..5371cc0 --- /dev/null +++ b/src/components/admin/AddCustomerDialog.tsx @@ -0,0 +1,257 @@ +'use client' + +import { useState } from 'react' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { useCreateCustomer } from '@/hooks/use-customers' +import { Loader2, CheckCircle, AlertCircle, User, Mail, Building2 } from 'lucide-react' +import type { UserStatus } from '@/types/api' + +interface AddCustomerDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + onSuccess?: (customerId: string) => void +} + +type FormStep = 'form' | 'success' | 'error' + +export function AddCustomerDialog({ open, onOpenChange, onSuccess }: AddCustomerDialogProps) { + const [step, setStep] = useState('form') + const [email, setEmail] = useState('') + const [name, setName] = useState('') + const [company, setCompany] = useState('') + const [status, setStatus] = useState('ACTIVE') + const [errorMessage, setErrorMessage] = useState('') + const [createdCustomerId, setCreatedCustomerId] = useState(null) + + const createCustomer = useCreateCustomer() + + const resetForm = () => { + setEmail('') + setName('') + setCompany('') + setStatus('ACTIVE') + setErrorMessage('') + setCreatedCustomerId(null) + setStep('form') + } + + const handleClose = () => { + resetForm() + onOpenChange(false) + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + + if (!email) { + setErrorMessage('Email is required') + return + } + + // Basic email validation + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + if (!emailRegex.test(email)) { + setErrorMessage('Please enter a valid email address') + return + } + + try { + const result = await createCustomer.mutateAsync({ + email, + name: name || undefined, + company: company || undefined, + status, + }) + + setCreatedCustomerId(result.id) + setStep('success') + + if (onSuccess) { + onSuccess(result.id) + } + } catch (error) { + setErrorMessage( + error instanceof Error + ? error.message + : 'Failed to create customer. Please try again.' + ) + setStep('error') + } + } + + const renderForm = () => ( +
+ + Add New Customer + + Create a new customer account. They will receive an email to set their password. + + + +
+ {/* Email field */} +
+ + setEmail(e.target.value)} + placeholder="customer@example.com" + required + /> +
+ + {/* Name field */} +
+ + setName(e.target.value)} + placeholder="John Doe" + /> +
+ + {/* Company field */} +
+ + setCompany(e.target.value)} + placeholder="Acme Inc." + /> +
+ + {/* Status field */} +
+ + +
+
+ + + + + +
+ ) + + const renderSuccess = () => ( + <> + + + + Customer Created + + + The customer account has been created successfully. + + + +
+
+ +
+
+

{name || email}

+

{email}

+ {company && ( +

{company}

+ )} +
+
+ + + + + + + ) + + const renderError = () => ( + <> + + + + Error Creating Customer + + + There was a problem creating the customer account. + + + +
+
+ +
+

{errorMessage}

+
+ + + + + + + ) + + return ( + + + {step === 'form' && renderForm()} + {step === 'success' && renderSuccess()} + {step === 'error' && renderError()} + + + ) +} diff --git a/src/components/admin/ToolsEditor.tsx b/src/components/admin/ToolsEditor.tsx new file mode 100644 index 0000000..8ddec78 --- /dev/null +++ b/src/components/admin/ToolsEditor.tsx @@ -0,0 +1,302 @@ +'use client' + +import { useState, useMemo } from 'react' +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { + Loader2, + Pencil, + X, + Check, + Package, + CheckSquare, + Square, +} from 'lucide-react' + +// All available tools from the stacks folder +export const ALL_TOOLS: Record = { + // Core Infrastructure + orchestrator: { name: 'Orchestrator', description: 'LetsBe control plane API', category: 'Core' }, + sysadmin: { name: 'SysAdmin Agent', description: 'Remote automation worker', category: 'Core' }, + portainer: { name: 'Portainer', description: 'Container management UI', category: 'Core' }, + + // Communication + poste: { name: 'Poste.io', description: 'Email server with webmail', category: 'Communication' }, + chatwoot: { name: 'Chatwoot', description: 'Customer engagement platform', category: 'Communication' }, + listmonk: { name: 'Listmonk', description: 'Newsletter & mailing list manager', category: 'Communication' }, + + // File Storage & Collaboration + nextcloud: { name: 'Nextcloud', description: 'File sync & collaboration', category: 'Files' }, + minio: { name: 'MinIO', description: 'S3-compatible object storage', category: 'Files' }, + documenso: { name: 'Documenso', description: 'Document signing platform', category: 'Files' }, + + // Identity & Security + keycloak: { name: 'Keycloak', description: 'Identity & access management', category: 'Security' }, + vaultwarden: { name: 'Vaultwarden', description: 'Password manager (Bitwarden)', category: 'Security' }, + + // Automation & Workflows + n8n: { name: 'n8n', description: 'Workflow automation', category: 'Automation' }, + activepieces: { name: 'Activepieces', description: 'No-code automation platform', category: 'Automation' }, + windmill: { name: 'Windmill', description: 'Developer-first workflows', category: 'Automation' }, + typebot: { name: 'Typebot', description: 'Conversational app builder', category: 'Automation' }, + + // Development + gitea: { name: 'Gitea', description: 'Git server with web UI', category: 'Development' }, + 'gitea-drone': { name: 'Drone CI', description: 'Continuous integration with Gitea', category: 'Development' }, + + // Databases & Analytics + nocodb: { name: 'NocoDB', description: 'Airtable alternative database UI', category: 'Data' }, + redash: { name: 'Redash', description: 'Data visualization & dashboards', category: 'Data' }, + umami: { name: 'Umami', description: 'Privacy-focused web analytics', category: 'Data' }, + + // AI & Chat + librechat: { name: 'LibreChat', description: 'ChatGPT-style AI interface', category: 'AI' }, + + // CMS & Content + ghost: { name: 'Ghost', description: 'Publishing platform & blog', category: 'Content' }, + wordpress: { name: 'WordPress', description: 'Content management system', category: 'Content' }, + squidex: { name: 'Squidex', description: 'Headless CMS', category: 'Content' }, + + // Business Tools + calcom: { name: 'Cal.com', description: 'Scheduling & calendar booking', category: 'Business' }, + odoo: { name: 'Odoo', description: 'ERP & business apps suite', category: 'Business' }, + penpot: { name: 'Penpot', description: 'Design & prototyping platform', category: 'Business' }, + + // Monitoring & Maintenance + glitchtip: { name: 'GlitchTip', description: 'Error tracking (Sentry alt)', category: 'Monitoring' }, + 'uptime-kuma': { name: 'Uptime Kuma', description: 'Uptime monitoring', category: 'Monitoring' }, + 'diun-watchtower': { name: 'Diun/Watchtower', description: 'Container update notifications', category: 'Monitoring' }, + + // Other + html: { name: 'Static HTML', description: 'Simple static website hosting', category: 'Other' }, +} + +// Default tools that are always recommended +export const DEFAULT_TOOLS = ['html', 'portainer', 'diun-watchtower'] + +// Tool dependencies: selecting a tool automatically selects its dependencies +const TOOL_DEPENDENCIES: Record = { + 'gitea': ['gitea-drone'], // Gitea requires Drone CI + 'ghost': ['html'], // CMS tools require HTML + 'wordpress': ['html'], + 'squidex': ['html'], +} + +// Category order for display +const CATEGORY_ORDER = [ + 'Core', + 'Communication', + 'Files', + 'Security', + 'Automation', + 'Development', + 'Data', + 'AI', + 'Content', + 'Business', + 'Monitoring', + 'Other', +] + +interface ToolsEditorProps { + tools: string[] + onSave: (tools: string[]) => Promise + isEditable: boolean + isSaving?: boolean +} + +export function ToolsEditor({ tools, onSave, isEditable, isSaving = false }: ToolsEditorProps) { + const [isEditing, setIsEditing] = useState(false) + const [selectedTools, setSelectedTools] = useState(tools) + const [saving, setSaving] = useState(false) + + // Group tools by category + const toolsByCategory = useMemo(() => { + const grouped: Record = {} + for (const [key, info] of Object.entries(ALL_TOOLS)) { + if (!grouped[info.category]) { + grouped[info.category] = [] + } + grouped[info.category].push(key) + } + return grouped + }, []) + + const handleToggleTool = (tool: string) => { + setSelectedTools((prev) => { + if (prev.includes(tool)) { + // Removing a tool - also remove tools that depend on it + let newTools = prev.filter((t) => t !== tool) + // If removing gitea, also remove gitea-drone + if (tool === 'gitea') { + newTools = newTools.filter((t) => t !== 'gitea-drone') + } + return newTools + } else { + // Adding a tool - also add its dependencies + let newTools = [...prev, tool] + const deps = TOOL_DEPENDENCIES[tool] + if (deps) { + deps.forEach((dep) => { + if (!newTools.includes(dep)) { + newTools.push(dep) + } + }) + } + return newTools + } + }) + } + + const handleSave = async () => { + setSaving(true) + try { + await onSave(selectedTools) + setIsEditing(false) + } catch (error) { + console.error('Failed to save tools:', error) + } finally { + setSaving(false) + } + } + + const handleCancel = () => { + setSelectedTools(tools) + setIsEditing(false) + } + + // Display mode - just show selected tools + if (!isEditing) { + return ( + + +
+ + + Selected Tools + + Tools to be deployed on this server +
+ {isEditable && ( + + )} +
+ + {tools.length === 0 ? ( +

No tools selected

+ ) : ( +
+ {tools.map((tool) => { + const toolInfo = ALL_TOOLS[tool] + return ( + + {toolInfo?.name || tool} + + ) + })} +
+ )} +
+
+ ) + } + + // Edit mode - show all tools grouped by category with checkboxes + return ( + + +
+ + + Edit Tools + + + Select which tools to deploy ({selectedTools.length} selected) + +
+
+ + + +
+
+ +
+ {CATEGORY_ORDER.map((category) => { + const categoryTools = toolsByCategory[category] + if (!categoryTools) return null + + return ( +
+

+ {category} +

+
+ {categoryTools.map((toolKey) => { + const tool = ALL_TOOLS[toolKey] + const isSelected = selectedTools.includes(toolKey) + + return ( + + ) + })} +
+
+ ) + })} +
+
+
+ ) +} diff --git a/src/components/admin/automation-controls.tsx b/src/components/admin/automation-controls.tsx new file mode 100644 index 0000000..700a7ec --- /dev/null +++ b/src/components/admin/automation-controls.tsx @@ -0,0 +1,229 @@ +'use client' + +import { useState } from 'react' +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { + Loader2, + Play, + Pause, + Hand, + Zap, + RotateCcw, + AlertCircle, +} from 'lucide-react' +import type { AutomationMode, OrderStatus } from '@prisma/client' + +interface AutomationControlsProps { + orderId: string + mode: AutomationMode + pausedAt?: Date | null + pausedReason?: string | null + source?: string | null + status: OrderStatus + onModeChange: (action: 'auto' | 'manual' | 'pause' | 'resume') => Promise +} + +const modeConfig: Record< + AutomationMode, + { label: string; color: string; bgColor: string } +> = { + AUTO: { + label: 'Automatic', + color: 'text-green-700', + bgColor: 'bg-green-100', + }, + MANUAL: { + label: 'Manual', + color: 'text-blue-700', + bgColor: 'bg-blue-100', + }, + PAUSED: { + label: 'Paused', + color: 'text-amber-700', + bgColor: 'bg-amber-100', + }, +} + +export function AutomationControls({ + orderId, + mode, + pausedAt, + pausedReason, + source, + status, + onModeChange, +}: AutomationControlsProps) { + const [isChanging, setIsChanging] = useState(false) + const [changingAction, setChangingAction] = useState(null) + + const handleAction = async (action: 'auto' | 'manual' | 'pause' | 'resume') => { + setIsChanging(true) + setChangingAction(action) + try { + await onModeChange(action) + } finally { + setIsChanging(false) + setChangingAction(null) + } + } + + const config = modeConfig[mode] + + // Determine which actions are available based on current mode + const canEnableAuto = mode === 'MANUAL' || mode === 'PAUSED' + const canTakeManual = mode === 'AUTO' + const canPause = mode === 'AUTO' + const canResume = mode === 'PAUSED' + + // Check if order is in a terminal state + const isTerminal = status === 'FULFILLED' || status === 'EMAIL_CONFIGURED' + + return ( + + +
+
+ + + Automation Mode + + + {source === 'website' + ? 'Website order - auto mode by default' + : source === 'staff' + ? 'Staff-created order' + : 'Order automation control'} + +
+ + {config.label} + +
+
+ + {/* Pause reason if paused */} + {mode === 'PAUSED' && pausedReason && ( +
+ +
+

Automation Paused

+

{pausedReason}

+ {pausedAt && ( +

+ Paused at {new Date(pausedAt).toLocaleString()} +

+ )} +
+
+ )} + + {/* Terminal state notice */} + {isTerminal && ( +
+ Order is complete. Automation controls are informational only. +
+ )} + + {/* Action buttons */} + {!isTerminal && ( +
+ {canEnableAuto && ( + + )} + + {canTakeManual && ( + + )} + + {canPause && ( + + )} + + {canResume && ( + + )} +
+ )} + + {/* Mode explanation */} +
+ {mode === 'AUTO' && ( +

+ Auto: Steps execute automatically when ready. Staff + can take manual control at any time. +

+ )} + {mode === 'MANUAL' && ( +

+ Manual: Each step requires a button click. Enable + auto mode to let it run automatically. +

+ )} + {mode === 'PAUSED' && ( +

+ Paused: Automation stopped for intervention. Resume + to continue automatic processing. +

+ )} +
+
+
+ ) +} diff --git a/src/components/admin/container-list.tsx b/src/components/admin/container-list.tsx new file mode 100644 index 0000000..ac8f9d0 --- /dev/null +++ b/src/components/admin/container-list.tsx @@ -0,0 +1,406 @@ +'use client' + +import { useState } from 'react' +import { useRouter, usePathname } from 'next/navigation' +import { + Play, + Square, + RefreshCw, + Trash2, + FileText, + Loader2, + Box, + AlertCircle, + ExternalLink, +} from 'lucide-react' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { + useContainers, + useContainerAction, + useRemoveContainer, + usePortainerCredentials, + useContainerStats, + type Container, + type ContainerStats, +} from '@/hooks/use-portainer' +import { ContainerLogsDialog } from './container-logs-dialog' + +interface ContainerListProps { + orderId: string +} + +function getStateColor(state: string): string { + switch (state.toLowerCase()) { + case 'running': + return 'bg-green-100 text-green-800 border-green-200' + case 'exited': + return 'bg-red-100 text-red-800 border-red-200' + case 'paused': + return 'bg-yellow-100 text-yellow-800 border-yellow-200' + case 'restarting': + return 'bg-blue-100 text-blue-800 border-blue-200' + case 'created': + return 'bg-gray-100 text-gray-800 border-gray-200' + default: + return 'bg-gray-100 text-gray-800 border-gray-200' + } +} + +function formatRelativeTime(timestamp: number): string { + const now = Date.now() + const diff = now - timestamp * 1000 + const seconds = Math.floor(diff / 1000) + const minutes = Math.floor(seconds / 60) + const hours = Math.floor(minutes / 60) + const days = Math.floor(hours / 24) + + if (days > 0) return `${days}d ago` + if (hours > 0) return `${hours}h ago` + if (minutes > 0) return `${minutes}m ago` + return 'just now' +} + +function formatBytes(bytes: number): string { + if (bytes === 0) return '0 B' + const k = 1024 + const sizes = ['B', 'KB', 'MB', 'GB', 'TB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}` +} + +export function ContainerList({ orderId }: ContainerListProps) { + const router = useRouter() + const pathname = usePathname() + const { data: credentials } = usePortainerCredentials(orderId) + const { + data: containers, + isLoading, + error, + refetch, + } = useContainers(orderId, credentials?.isConfigured) + const { data: stats } = useContainerStats(orderId, credentials?.isConfigured) + const containerAction = useContainerAction() + const removeContainer = useRemoveContainer() + + const [selectedContainer, setSelectedContainer] = useState(null) + const [showLogsFor, setShowLogsFor] = useState(null) + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) + const [actionInProgress, setActionInProgress] = useState(null) + + // Determine the base path for container detail page + const basePath = pathname.includes('/admin/servers/') + ? `/admin/servers/${orderId}` + : `/admin/orders/${orderId}` + + const navigateToContainer = (containerId: string) => { + router.push(`${basePath}/containers/${containerId}`) + } + + const handleAction = async (container: Container, action: 'start' | 'stop' | 'restart') => { + setActionInProgress(container.id) + try { + await containerAction.mutateAsync({ + orderId, + containerId: container.id, + action, + }) + } catch (err) { + console.error(`Failed to ${action} container:`, err) + } finally { + setActionInProgress(null) + } + } + + const handleDelete = async () => { + if (!selectedContainer) return + setActionInProgress(selectedContainer.id) + try { + await removeContainer.mutateAsync({ + orderId, + containerId: selectedContainer.id, + force: true, + }) + setShowDeleteConfirm(false) + setSelectedContainer(null) + } catch (err) { + console.error('Failed to remove container:', err) + } finally { + setActionInProgress(null) + } + } + + if (!credentials?.isConfigured) { + return ( + + + + + Containers + + + Configure Portainer credentials to view containers + + + + ) + } + + if (isLoading) { + return ( + + + + + Containers + + + +
+ +
+
+
+ ) + } + + if (error) { + return ( + + + + + Containers + + + +
+ + Failed to load containers: {error.message} +
+
+
+ ) + } + + return ( + <> + + +
+
+ + + Containers + + + {containers?.length || 0} containers found + +
+ +
+
+ + {containers && containers.length > 0 ? ( +
+ + + + + + + + + + + + + + {containers.map((container) => ( + + + + + + + + + + ))} + +
NameImageStateCPUMemoryCreatedActions
+ + + + {container.image.length > 40 + ? container.image.substring(0, 40) + '...' + : container.image} + + + + {container.state} + + + {container.state === 'running' && stats?.[container.id] ? ( + + {stats[container.id].cpuPercent.toFixed(1)}% + + ) : ( + + )} + + {container.state === 'running' && stats?.[container.id] ? ( +
+ + {formatBytes(stats[container.id].memoryUsage)} + + + {' '}/ {formatBytes(stats[container.id].memoryLimit)} + + + ({stats[container.id].memoryPercent.toFixed(1)}%) + +
+ ) : ( + + )} +
+ + {formatRelativeTime(container.created)} + + +
+ {actionInProgress === container.id ? ( + + ) : ( + <> + {container.state === 'running' ? ( + + ) : ( + + )} + + + + + )} +
+
+
+ ) : ( +
+ No containers found +
+ )} +
+
+ + {/* Logs Dialog */} + {showLogsFor && ( + !open && setShowLogsFor(null)} + /> + )} + + {/* Delete Confirmation Dialog */} + + + + Remove Container + + Are you sure you want to remove container "{selectedContainer?.name}"? This action + cannot be undone. + + + + + + + + + + ) +} diff --git a/src/components/admin/container-logs-dialog.tsx b/src/components/admin/container-logs-dialog.tsx new file mode 100644 index 0000000..9579475 --- /dev/null +++ b/src/components/admin/container-logs-dialog.tsx @@ -0,0 +1,106 @@ +'use client' + +import { useState } from 'react' +import { RefreshCw, Download, Loader2 } from 'lucide-react' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { useContainerLogs, type Container } from '@/hooks/use-portainer' + +interface ContainerLogsDialogProps { + orderId: string + container: Container + open: boolean + onOpenChange: (open: boolean) => void +} + +const TAIL_OPTIONS = [ + { value: 100, label: 'Last 100 lines' }, + { value: 500, label: 'Last 500 lines' }, + { value: 1000, label: 'Last 1000 lines' }, + { value: 2000, label: 'Last 2000 lines' }, +] + +export function ContainerLogsDialog({ + orderId, + container, + open, + onOpenChange, +}: ContainerLogsDialogProps) { + const [tail, setTail] = useState(100) + const { data: logs, isLoading, refetch } = useContainerLogs(orderId, container.id, tail) + + const handleDownload = () => { + if (!logs) return + const blob = new Blob([logs], { type: 'text/plain' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `${container.name}-logs.txt` + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(url) + } + + return ( + + + + Container Logs: {container.name} + + ID: {container.shortId} + + + +
+ + +
+ + +
+
+ +
+ {isLoading ? ( +
+ +
+ ) : logs ? ( +
+
+                {logs || 'No logs available'}
+              
+
+ ) : ( +
+ No logs available +
+ )} +
+
+
+ ) +} diff --git a/src/components/admin/create-order-dialog.tsx b/src/components/admin/create-order-dialog.tsx index 9f250c8..79dd95e 100644 --- a/src/components/admin/create-order-dialog.tsx +++ b/src/components/admin/create-order-dialog.tsx @@ -33,39 +33,111 @@ interface CreateOrderDialogProps { open: boolean onOpenChange: (open: boolean) => void onSuccess?: () => void + preselectedCustomer?: { + id: string + name: string | null + email: string + company: string | null + } } type Step = 'customer' | 'domain' | 'tier' | 'tools' | 'review' -const TOOLS_BY_TIER: Record = { - HUB_DASHBOARD: ['nextcloud', 'keycloak', 'minio', 'poste'], - ADVANCED: ['nextcloud', 'keycloak', 'minio', 'poste', 'n8n', 'filebrowser', 'portainer', 'grafana'], +// All available tools from the stacks folder +const ALL_TOOLS: Record = { + // Core Infrastructure + orchestrator: { name: 'Orchestrator', description: 'LetsBe control plane API', category: 'Core' }, + sysadmin: { name: 'SysAdmin Agent', description: 'Remote automation worker', category: 'Core' }, + portainer: { name: 'Portainer', description: 'Container management UI', category: 'Core' }, + + // Communication + poste: { name: 'Poste.io', description: 'Email server with webmail', category: 'Communication' }, + chatwoot: { name: 'Chatwoot', description: 'Customer engagement platform', category: 'Communication' }, + listmonk: { name: 'Listmonk', description: 'Newsletter & mailing list manager', category: 'Communication' }, + + // File Storage & Collaboration + nextcloud: { name: 'Nextcloud', description: 'File sync & collaboration', category: 'Files' }, + minio: { name: 'MinIO', description: 'S3-compatible object storage', category: 'Files' }, + documenso: { name: 'Documenso', description: 'Document signing platform', category: 'Files' }, + + // Identity & Security + keycloak: { name: 'Keycloak', description: 'Identity & access management', category: 'Security' }, + vaultwarden: { name: 'Vaultwarden', description: 'Password manager (Bitwarden)', category: 'Security' }, + + // Automation & Workflows + n8n: { name: 'n8n', description: 'Workflow automation', category: 'Automation' }, + activepieces: { name: 'Activepieces', description: 'No-code automation platform', category: 'Automation' }, + windmill: { name: 'Windmill', description: 'Developer-first workflows', category: 'Automation' }, + typebot: { name: 'Typebot', description: 'Conversational app builder', category: 'Automation' }, + + // Development + gitea: { name: 'Gitea', description: 'Git server with web UI', category: 'Development' }, + 'gitea-drone': { name: 'Drone CI', description: 'Continuous integration with Gitea', category: 'Development' }, + + // Databases & Analytics + nocodb: { name: 'NocoDB', description: 'Airtable alternative database UI', category: 'Data' }, + redash: { name: 'Redash', description: 'Data visualization & dashboards', category: 'Data' }, + umami: { name: 'Umami', description: 'Privacy-focused web analytics', category: 'Data' }, + + // AI & Chat + librechat: { name: 'LibreChat', description: 'ChatGPT-style AI interface', category: 'AI' }, + + // CMS & Content + ghost: { name: 'Ghost', description: 'Publishing platform & blog', category: 'Content' }, + wordpress: { name: 'WordPress', description: 'Content management system', category: 'Content' }, + squidex: { name: 'Squidex', description: 'Headless CMS', category: 'Content' }, + + // Business Tools + calcom: { name: 'Cal.com', description: 'Scheduling & calendar booking', category: 'Business' }, + odoo: { name: 'Odoo', description: 'ERP & business apps suite', category: 'Business' }, + penpot: { name: 'Penpot', description: 'Design & prototyping platform', category: 'Business' }, + + // Monitoring & Maintenance + glitchtip: { name: 'GlitchTip', description: 'Error tracking (Sentry alt)', category: 'Monitoring' }, + 'uptime-kuma': { name: 'Uptime Kuma', description: 'Uptime monitoring', category: 'Monitoring' }, + 'diun-watchtower': { name: 'Diun/Watchtower', description: 'Container update notifications', category: 'Monitoring' }, + + // Other + html: { name: 'Static HTML', description: 'Simple static website hosting', category: 'Other' }, } -const TOOL_LABELS: Record = { - nextcloud: { name: 'Nextcloud', description: 'File sync & collaboration' }, - keycloak: { name: 'Keycloak', description: 'Identity & access management' }, - minio: { name: 'MinIO', description: 'S3-compatible object storage' }, - poste: { name: 'Poste.io', description: 'Email server' }, - n8n: { name: 'n8n', description: 'Workflow automation' }, - filebrowser: { name: 'File Browser', description: 'Web-based file manager' }, - portainer: { name: 'Portainer', description: 'Container management' }, - grafana: { name: 'Grafana', description: 'Monitoring dashboards' }, +// Default tools that are always included (core infrastructure) +const DEFAULT_TOOLS = ['html', 'portainer', 'diun-watchtower'] + +// Tool dependencies: selecting a tool automatically selects its dependencies +const TOOL_DEPENDENCIES: Record = { + 'gitea': ['gitea-drone'], // Gitea requires Drone CI + 'ghost': ['html'], // CMS tools require HTML + 'wordpress': ['html'], + 'squidex': ['html'], } +// Tools that require HTML (website builders/CMS) +const WEBSITE_TOOLS = ['ghost', 'wordpress', 'squidex'] + +// Default tools by tier +const TOOLS_BY_TIER: Record = { + HUB_DASHBOARD: [...DEFAULT_TOOLS, 'orchestrator', 'sysadmin', 'nextcloud', 'keycloak', 'minio', 'poste'], + ADVANCED: Object.keys(ALL_TOOLS), +} + +// Legacy compatibility +const TOOL_LABELS = ALL_TOOLS + export function CreateOrderDialog({ open, onOpenChange, onSuccess, + preselectedCustomer, }: CreateOrderDialogProps) { - const [step, setStep] = useState('customer') + const [step, setStep] = useState(preselectedCustomer ? 'domain' : 'customer') const [customerSearch, setCustomerSearch] = useState('') const [selectedCustomer, setSelectedCustomer] = useState<{ id: string name: string | null email: string company: string | null - } | null>(null) + } | null>(preselectedCustomer || null) const [domain, setDomain] = useState('') const [tier, setTier] = useState(SubscriptionTier.HUB_DASHBOARD) const [selectedTools, setSelectedTools] = useState([]) @@ -78,20 +150,28 @@ export function CreateOrderDialog({ const createOrder = useCreateOrder() - // Reset state when dialog opens/closes + // Initialize with preselected customer when dialog opens + useEffect(() => { + if (open && preselectedCustomer) { + setSelectedCustomer(preselectedCustomer) + setStep('domain') + } + }, [open, preselectedCustomer]) + + // Reset state when dialog closes useEffect(() => { if (!open) { setTimeout(() => { - setStep('customer') + setStep(preselectedCustomer ? 'domain' : 'customer') setCustomerSearch('') - setSelectedCustomer(null) + setSelectedCustomer(preselectedCustomer || null) setDomain('') setTier(SubscriptionTier.HUB_DASHBOARD) setSelectedTools([]) setError(null) }, 300) } - }, [open]) + }, [open, preselectedCustomer]) // Auto-select default tools when tier changes useEffect(() => { @@ -159,9 +239,29 @@ export function CreateOrderDialog({ } const toggleTool = (tool: string) => { - setSelectedTools((prev) => - prev.includes(tool) ? prev.filter((t) => t !== tool) : [...prev, tool] - ) + setSelectedTools((prev) => { + if (prev.includes(tool)) { + // Removing a tool - also remove tools that depend on it + let newTools = prev.filter((t) => t !== tool) + // If removing gitea, also remove gitea-drone + if (tool === 'gitea') { + newTools = newTools.filter((t) => t !== 'gitea-drone') + } + return newTools + } else { + // Adding a tool - also add its dependencies + let newTools = [...prev, tool] + const deps = TOOL_DEPENDENCIES[tool] + if (deps) { + deps.forEach((dep) => { + if (!newTools.includes(dep)) { + newTools.push(dep) + } + }) + } + return newTools + } + }) } const steps: { key: Step; label: string; icon: React.ReactNode }[] = [ @@ -353,38 +453,82 @@ export function CreateOrderDialog({ {step === 'tools' && (
- -

- Choose which tools to deploy. All tools are pre-selected based on your tier. -

-
- {TOOLS_BY_TIER[tier].map((tool) => { - const toolInfo = TOOL_LABELS[tool] - const isSelected = selectedTools.includes(tool) - return ( - - ) - })} +
+
+ +

+ Choose which tools to deploy ({selectedTools.length} selected) +

+
+
+ + + +
+
+
+ {/* Group tools by category */} + {Object.entries( + Object.entries(ALL_TOOLS).reduce((acc, [tool, info]) => { + if (!acc[info.category]) acc[info.category] = [] + acc[info.category].push({ tool, ...info }) + return acc + }, {} as Record) + ).map(([category, tools]) => ( +
+

{category}

+
+ {tools.map(({ tool, name, description }) => { + const isSelected = selectedTools.includes(tool) + return ( + + ) + })} +
+
+ ))}
)} @@ -440,7 +584,7 @@ export function CreateOrderDialog({ + + ) : ( + + Not configured yet + + )} +
+
+ + {/* Wildcard indicator */} + {verification?.wildcardPassed && ( +
+ +
+

Wildcard DNS Configured

+

+ *.{domain} → {serverIp}. All subdomains verified automatically. +

+
+
+ )} + + {/* Manual override warning */} + {isManualOverride && !isVerificationStale && ( +
+ +
+

Manual Override Active

+

+ DNS verification was skipped. Ensure records are configured + correctly to avoid SSL certificate issues. +

+
+
+ )} + + {/* Toolset changed warning */} + {isVerificationStale && toolsetChange && ( +
+ +
+

Toolset Changed - Re-verification Required

+

+ The selected tools have changed since DNS was last verified. + Please re-verify DNS to ensure all required subdomains are configured. +

+ {toolsetChange.added.length > 0 && ( +
+

New subdomains needed:

+
+ {toolsetChange.added.map((sub) => ( + + {sub}.{domain} + + ))} +
+
+ )} + {toolsetChange.removed.length > 0 && ( +
+

+ No longer needed: +

+
+ {toolsetChange.removed.map((sub) => ( + + {sub}.{domain} + + ))} +
+
+ )} +
+
+ )} + + {/* DNS Records Table */} + {verification && verification.records.length > 0 && !verification.wildcardPassed && ( +
+ +
+ + + + + + + + + + {verification.records.map((record) => { + const config = statusConfig[record.status] + const StatusIcon = config.icon + return ( + + + + + + ) + })} + +
SubdomainResolved IPStatus
+ {record.subdomain}.{domain} + + {record.resolvedIp || ( + + — + + )} + + + + {config.label} + +
+
+
+ )} + + {/* Required subdomains preview (before verification OR when stale) */} + {(!verification || isVerificationStale) && requiredSubdomains.length > 0 && ( +
+ +
+ {requiredSubdomains.map((sub) => { + // When stale, highlight new subdomains + const isNew = isVerificationStale && toolsetChange?.added.includes(sub) + return ( + + {sub}.{domain} + + ) + })} +
+
+ )} + + {/* Last checked */} + {verification?.lastCheckedAt && ( +

+ Last checked: {new Date(verification.lastCheckedAt).toLocaleString()} +

+ )} + + {/* Action buttons - show when not verified OR show refresh when verified */} + {serverIp && ( +
+ {/* Always show verify/refresh button */} + + + {/* Only show skip button when not yet verified */} + {!allPassed && ( + + )} +
+ )} + + {/* Instructions */} + {!allPassed && serverIp && ( +
+

Configure DNS A records pointing to {serverIp}:

+
    +
  • + Recommended: Add wildcard record{' '} + *.{domain} →{' '} + {serverIp} +
  • +
  • + Alternative: Add individual A records for each + subdomain listed above +
  • +
+
+ )} + + + ) +} diff --git a/src/components/admin/edit-hostname-dialog.tsx b/src/components/admin/edit-hostname-dialog.tsx new file mode 100644 index 0000000..39c2732 --- /dev/null +++ b/src/components/admin/edit-hostname-dialog.tsx @@ -0,0 +1,134 @@ +'use client' + +import { useState, useEffect } from 'react' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Loader2, Globe } from 'lucide-react' +import { useNetcupUpdateHostname } from '@/hooks/use-netcup' + +interface EditHostnameDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + serverId: string + currentHostname?: string + serverName?: string +} + +export function EditHostnameDialog({ + open, + onOpenChange, + serverId, + currentHostname, + serverName, +}: EditHostnameDialogProps) { + const [hostname, setHostname] = useState(currentHostname || '') + const [error, setError] = useState(null) + const updateMutation = useNetcupUpdateHostname() + + // Reset form when dialog opens + useEffect(() => { + if (open) { + setHostname(currentHostname || '') + setError(null) + } + }, [open, currentHostname]) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setError(null) + + const trimmedHostname = hostname.trim() + + if (!trimmedHostname) { + setError('Hostname is required') + return + } + + // Basic hostname validation (allows FQDN format) + const hostnameRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*$/ + if (!hostnameRegex.test(trimmedHostname)) { + setError('Invalid hostname format. Use letters, numbers, and hyphens. Cannot start or end with a hyphen.') + return + } + + try { + await updateMutation.mutateAsync({ serverId, hostname: trimmedHostname }) + onOpenChange(false) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to update hostname') + } + } + + return ( + + + + + + Edit Hostname + + + Change the hostname for {serverName || `server ${serverId}`}. This will update the server configuration in Netcup. + + + +
+
+ + { + setHostname(e.target.value) + setError(null) + }} + disabled={updateMutation.isPending} + className="font-mono" + /> + {error && ( +

{error}

+ )} +

+ Enter a valid hostname or FQDN (e.g., server1 or server.example.com) +

+
+ + + + + +
+
+
+ ) +} diff --git a/src/components/admin/edit-nickname-dialog.tsx b/src/components/admin/edit-nickname-dialog.tsx new file mode 100644 index 0000000..f5fa266 --- /dev/null +++ b/src/components/admin/edit-nickname-dialog.tsx @@ -0,0 +1,127 @@ +'use client' + +import { useState, useEffect } from 'react' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Loader2, Tag } from 'lucide-react' +import { useNetcupUpdateNickname } from '@/hooks/use-netcup' + +interface EditNicknameDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + serverId: string + currentNickname?: string + serverName?: string +} + +export function EditNicknameDialog({ + open, + onOpenChange, + serverId, + currentNickname, + serverName, +}: EditNicknameDialogProps) { + const [nickname, setNickname] = useState(currentNickname || '') + const [error, setError] = useState(null) + const updateMutation = useNetcupUpdateNickname() + + // Reset form when dialog opens + useEffect(() => { + if (open) { + setNickname(currentNickname || '') + setError(null) + } + }, [open, currentNickname]) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setError(null) + + const trimmedNickname = nickname.trim() + + // Nickname can be empty to clear it, but if set, validate length + if (trimmedNickname.length > 50) { + setError('Nickname must be 50 characters or less') + return + } + + try { + await updateMutation.mutateAsync({ serverId, nickname: trimmedNickname }) + onOpenChange(false) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to update nickname') + } + } + + return ( + + + + + + Edit Nickname + + + Change the nickname for {serverName || `server ${serverId}`}. This is a friendly label visible only in Netcup. + + + +
+
+ + { + setNickname(e.target.value) + setError(null) + }} + disabled={updateMutation.isPending} + /> + {error && ( +

{error}

+ )} +

+ Enter a friendly name for this server. Leave empty to clear the nickname. +

+
+ + + + + +
+
+
+ ) +} diff --git a/src/components/admin/enterprise-container-events-panel.tsx b/src/components/admin/enterprise-container-events-panel.tsx new file mode 100644 index 0000000..363641d --- /dev/null +++ b/src/components/admin/enterprise-container-events-panel.tsx @@ -0,0 +1,349 @@ +'use client' + +import { useState } from 'react' +import { + AlertCircle, + AlertTriangle, + Server as ServerIcon, + RefreshCw, + Loader2, + Check, + CheckCheck, + Clock, + Zap, + MemoryStick, + RotateCcw, + Square, + Filter, +} from 'lucide-react' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { Skeleton } from '@/components/ui/skeleton' +import { Checkbox } from '@/components/ui/checkbox' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { + useContainerEvents, + useAcknowledgeContainerEvents, +} from '@/hooks/use-enterprise-clients' +import type { ContainerEventType } from '@/lib/api/admin' +import { formatDistanceToNow, format } from 'date-fns' + +interface EnterpriseContainerEventsPanelProps { + clientId: string +} + +const EVENT_TYPE_CONFIG: Record< + ContainerEventType, + { + label: string + icon: typeof AlertCircle + color: string + bgColor: string + } +> = { + CRASH: { + label: 'Crash', + icon: Zap, + color: 'text-red-600 dark:text-red-400', + bgColor: 'bg-red-100 dark:bg-red-900/30', + }, + OOM_KILLED: { + label: 'OOM Killed', + icon: MemoryStick, + color: 'text-orange-600 dark:text-orange-400', + bgColor: 'bg-orange-100 dark:bg-orange-900/30', + }, + RESTART: { + label: 'Restart', + icon: RotateCcw, + color: 'text-yellow-600 dark:text-yellow-400', + bgColor: 'bg-yellow-100 dark:bg-yellow-900/30', + }, + STOPPED: { + label: 'Stopped', + icon: Square, + color: 'text-slate-600 dark:text-slate-400', + bgColor: 'bg-slate-100 dark:bg-slate-900/30', + }, +} + +export function EnterpriseContainerEventsPanel({ + clientId, +}: EnterpriseContainerEventsPanelProps) { + const [selectedIds, setSelectedIds] = useState>(new Set()) + const [eventTypeFilter, setEventTypeFilter] = useState('all') + + const { + data: eventsData, + isLoading, + isError, + refetch, + isFetching, + } = useContainerEvents(clientId, { + type: eventTypeFilter === 'all' ? undefined : eventTypeFilter, + limit: 50, + }) + + const acknowledgeEvents = useAcknowledgeContainerEvents() + + const handleToggleSelect = (eventId: string) => { + const newSelected = new Set(selectedIds) + if (newSelected.has(eventId)) { + newSelected.delete(eventId) + } else { + newSelected.add(eventId) + } + setSelectedIds(newSelected) + } + + const handleSelectAll = () => { + if (!eventsData?.events) return + + const unacknowledgedIds = eventsData.events + .filter((e) => !e.acknowledgedAt) + .map((e) => e.id) + + if (selectedIds.size === unacknowledgedIds.length) { + setSelectedIds(new Set()) + } else { + setSelectedIds(new Set(unacknowledgedIds)) + } + } + + const handleAcknowledge = async () => { + if (selectedIds.size === 0) return + + await acknowledgeEvents.mutateAsync({ + clientId, + eventIds: Array.from(selectedIds), + }) + + setSelectedIds(new Set()) + } + + if (isLoading) { + return ( + + + + + Container Events + + + + {[1, 2, 3].map((i) => ( + + ))} + + + ) + } + + if (isError || !eventsData) { + return ( + + + + + Container Events + + + + +

Failed to load container events

+ +
+
+ ) + } + + const { events, total } = eventsData + const unacknowledgedCount = events.filter((e) => !e.acknowledgedAt).length + const allUnacknowledgedSelected = + unacknowledgedCount > 0 && selectedIds.size === unacknowledgedCount + + return ( + + +
+
+ + + Container Events + {unacknowledgedCount > 0 && ( + + {unacknowledgedCount} unacknowledged + + )} + + + Container crashes, OOM kills, and restarts + +
+
+ + +
+
+
+ + {/* Bulk Actions */} + {unacknowledgedCount > 0 && ( +
+
+ + + {selectedIds.size > 0 + ? `${selectedIds.size} selected` + : 'Select all unacknowledged'} + +
+ {selectedIds.size > 0 && ( + + )} +
+ )} + + {/* Events List */} + {events.length === 0 ? ( +
+ +

+ No container events found +

+
+ ) : ( +
+ {events.map((event) => { + const config = EVENT_TYPE_CONFIG[event.eventType] + const EventIcon = config.icon + const isSelected = selectedIds.has(event.id) + const isAcknowledged = !!event.acknowledgedAt + + return ( +
+
+ {!isAcknowledged && ( + handleToggleSelect(event.id)} + className="mt-1" + /> + )} +
+ +
+
+
+
+ + {event.containerName} + + + {config.label} + + {event.exitCode !== null && ( + + Exit: {event.exitCode} + + )} +
+ {isAcknowledged && ( + + + Acknowledged + + )} +
+
+ + {event.server.nickname || 'Unknown server'} + + + + {formatDistanceToNow(new Date(event.timestamp), { + addSuffix: true, + })} + +
+ {event.details && ( +

+ {event.details} +

+ )} +
+
+
+ ) + })} +
+ )} + + {/* Pagination Info */} + {total > events.length && ( +
+ Showing {events.length} of {total} events +
+ )} +
+
+ ) +} diff --git a/src/components/admin/enterprise-container-list.tsx b/src/components/admin/enterprise-container-list.tsx new file mode 100644 index 0000000..5c1c3da --- /dev/null +++ b/src/components/admin/enterprise-container-list.tsx @@ -0,0 +1,294 @@ +'use client' + +import { useState } from 'react' +import Link from 'next/link' +import { + Play, + Square, + RefreshCw, + Trash2, + FileText, + Loader2, + Box, + AlertCircle, + ExternalLink +} from 'lucide-react' +import { + useServerContainers, + useContainerAction, + useRemoveContainer, +} from '@/hooks/use-enterprise-clients' +import { EnterpriseContainerLogsDialog } from './enterprise-container-logs-dialog' +import type { ContainerSummary } from '@/lib/api/admin' + +interface EnterpriseContainerListProps { + clientId: string + serverId: string +} + +function formatBytes(bytes: number): string { + if (bytes === 0) return '0 B' + const k = 1024 + const sizes = ['B', 'KB', 'MB', 'GB', 'TB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i] +} + +function formatDate(timestamp: number): string { + return new Date(timestamp * 1000).toLocaleString() +} + +export function EnterpriseContainerList({ clientId, serverId }: EnterpriseContainerListProps) { + const [selectedContainer, setSelectedContainer] = useState(null) + const [showLogs, setShowLogs] = useState(false) + const [actionLoading, setActionLoading] = useState(null) + + const { data, isLoading, error, refetch } = useServerContainers(clientId, serverId) + const containerAction = useContainerAction() + const removeContainer = useRemoveContainer() + + const handleAction = async (containerId: string, action: 'start' | 'stop' | 'restart') => { + setActionLoading(`${containerId}-${action}`) + try { + await containerAction.mutateAsync({ + clientId, + serverId, + containerId, + action, + }) + } catch (err) { + console.error(`Failed to ${action} container:`, err) + } finally { + setActionLoading(null) + } + } + + const handleRemove = async (containerId: string) => { + if (!confirm('Are you sure you want to remove this container?')) return + + setActionLoading(`${containerId}-remove`) + try { + await removeContainer.mutateAsync({ + clientId, + serverId, + containerId, + force: true, + }) + } catch (err) { + console.error('Failed to remove container:', err) + } finally { + setActionLoading(null) + } + } + + const handleViewLogs = (container: ContainerSummary) => { + setSelectedContainer(container) + setShowLogs(true) + } + + if (isLoading) { + return ( +
+ +
+ ) + } + + if (error) { + return ( +
+ + {error instanceof Error ? error.message : 'Failed to load containers'} +
+ ) + } + + if (!data || data.containers.length === 0) { + return ( +
+ +

No containers found

+
+ ) + } + + return ( +
+ {/* Summary */} +
+ + {data.running} running, {data.stopped} stopped ({data.total} total) + + +
+ + {/* Container Table */} +
+ + + + + + + + + + + + + {data.containers.map((container) => { + const isRunning = container.state === 'running' + const name = container.names[0] || container.id.slice(0, 12) + + return ( + + + + + + + + + ) + })} + +
+ Name + + Image + + Status + + CPU / Memory + + Created + + Actions +
+ + + {name} + + + + + {container.image.split('/').pop()?.split(':')[0] || container.image} + + + + {container.state} + + + {container.stats ? ( + + {container.stats.cpuPercent.toFixed(1)}% / {formatBytes(container.stats.memoryUsage)} + + ) : ( + - + )} + + {formatDate(container.created)} + +
+ {/* View Logs */} + + + {/* Start/Stop */} + {isRunning ? ( + + ) : ( + + )} + + {/* Restart */} + + + {/* Remove */} + +
+
+
+ + {/* Logs Dialog */} + {selectedContainer && ( + { + setShowLogs(false) + setSelectedContainer(null) + }} + /> + )} +
+ ) +} diff --git a/src/components/admin/enterprise-container-logs-dialog.tsx b/src/components/admin/enterprise-container-logs-dialog.tsx new file mode 100644 index 0000000..dcd1dbb --- /dev/null +++ b/src/components/admin/enterprise-container-logs-dialog.tsx @@ -0,0 +1,179 @@ +'use client' + +import { useState, useEffect, useRef } from 'react' +import { + X, + RefreshCw, + Download, + Loader2, + AlertCircle +} from 'lucide-react' +import { useContainerLogs } from '@/hooks/use-enterprise-clients' +import type { ContainerSummary } from '@/lib/api/admin' + +interface EnterpriseContainerLogsDialogProps { + clientId: string + serverId: string + container: ContainerSummary + open: boolean + onClose: () => void +} + +const TAIL_OPTIONS = [100, 500, 1000, 5000] + +export function EnterpriseContainerLogsDialog({ + clientId, + serverId, + container, + open, + onClose, +}: EnterpriseContainerLogsDialogProps) { + const [tail, setTail] = useState(500) + const [autoScroll, setAutoScroll] = useState(true) + const logsContainerRef = useRef(null) + + const { data, isLoading, error, refetch } = useContainerLogs( + clientId, + serverId, + container.id, + tail + ) + + // Auto-scroll to bottom when logs update + useEffect(() => { + if (autoScroll && logsContainerRef.current) { + logsContainerRef.current.scrollTop = logsContainerRef.current.scrollHeight + } + }, [data?.logs, autoScroll]) + + const handleDownload = () => { + if (!data?.logs) return + + const blob = new Blob([data.logs], { type: 'text/plain' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `${container.names[0] || container.id.slice(0, 12)}-logs.txt` + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(url) + } + + if (!open) return null + + return ( +
+ {/* Backdrop */} +
+ + {/* Dialog */} +
+ {/* Header */} +
+
+

+ Container Logs +

+

+ {container.names[0] || container.id.slice(0, 12)} +

+
+ +
+ + {/* Toolbar */} +
+
+ {/* Tail selector */} +
+ + +
+ + {/* Auto-scroll toggle */} + +
+ +
+ + +
+
+ + {/* Logs Content */} +
+ {error ? ( +
+ + {error instanceof Error ? error.message : 'Failed to load logs'} +
+ ) : isLoading && !data ? ( +
+ +
+ ) : ( +
+              {data?.logs || 'No logs available'}
+            
+ )} +
+ + {/* Footer */} + {data && ( +
+ Last updated: {new Date(data.timestamp).toLocaleString()} +
+ )} +
+
+ ) +} diff --git a/src/components/admin/enterprise-detected-errors-panel.tsx b/src/components/admin/enterprise-detected-errors-panel.tsx new file mode 100644 index 0000000..8567cdd --- /dev/null +++ b/src/components/admin/enterprise-detected-errors-panel.tsx @@ -0,0 +1,295 @@ +'use client' + +import { useState } from 'react' +import { + AlertCircle, + AlertTriangle, + Info, + Zap, + CheckCircle, + Loader2, + Filter, + Server, + Clock, + ChevronDown, + ChevronUp +} from 'lucide-react' +import { useDetectedErrors, useAcknowledgeError, useClientServers } from '@/hooks/use-enterprise-clients' +import type { ErrorSeverity } from '@/types/api' + +interface EnterpriseDetectedErrorsPanelProps { + clientId: string + serverId?: string // Optional: filter to a specific server + limit?: number // Optional: limit number of errors shown +} + +const SEVERITY_CONFIG: Record = { + INFO: { icon: Info, color: 'text-blue-600', bg: 'bg-blue-50', border: 'border-blue-200' }, + WARNING: { icon: AlertTriangle, color: 'text-yellow-600', bg: 'bg-yellow-50', border: 'border-yellow-200' }, + ERROR: { icon: AlertCircle, color: 'text-red-600', bg: 'bg-red-50', border: 'border-red-200' }, + CRITICAL: { icon: Zap, color: 'text-purple-600', bg: 'bg-purple-50', border: 'border-purple-200' }, +} + +export function EnterpriseDetectedErrorsPanel({ + clientId, + serverId, + limit = 50, +}: EnterpriseDetectedErrorsPanelProps) { + const [severityFilter, setSeverityFilter] = useState('ALL') + const [showAcknowledged, setShowAcknowledged] = useState(false) + const [serverFilter, setServerFilter] = useState(serverId || 'ALL') + const [expandedErrors, setExpandedErrors] = useState>(new Set()) + + const { data: servers } = useClientServers(clientId) + const { data: errors, isLoading, refetch } = useDetectedErrors(clientId, { + serverId: serverFilter !== 'ALL' ? serverFilter : undefined, + severity: severityFilter !== 'ALL' ? severityFilter : undefined, + acknowledged: showAcknowledged ? undefined : false, + limit, + }) + const acknowledgeError = useAcknowledgeError() + + const handleAcknowledge = async (errorId: string) => { + try { + await acknowledgeError.mutateAsync({ clientId, errorId }) + } catch (error) { + console.error('Failed to acknowledge error:', error) + } + } + + const toggleExpanded = (errorId: string) => { + const newExpanded = new Set(expandedErrors) + if (newExpanded.has(errorId)) { + newExpanded.delete(errorId) + } else { + newExpanded.add(errorId) + } + setExpandedErrors(newExpanded) + } + + const formatTimestamp = (timestamp: Date | string) => { + const date = timestamp instanceof Date ? timestamp : new Date(timestamp) + const now = new Date() + const diff = now.getTime() - date.getTime() + const minutes = Math.floor(diff / 60000) + const hours = Math.floor(diff / 3600000) + const days = Math.floor(diff / 86400000) + + if (minutes < 1) return 'Just now' + if (minutes < 60) return `${minutes}m ago` + if (hours < 24) return `${hours}h ago` + if (days < 7) return `${days}d ago` + return date.toLocaleDateString() + } + + const unacknowledgedCount = errors?.filter(e => !e.acknowledgedAt).length || 0 + const criticalCount = errors?.filter(e => e.rule?.severity === 'CRITICAL' && !e.acknowledgedAt).length || 0 + + if (isLoading) { + return ( +
+ +
+ ) + } + + return ( +
+ {/* Header with Stats */} +
+
+

+ {unacknowledgedCount} unacknowledged error{unacknowledgedCount !== 1 ? 's' : ''} + {criticalCount > 0 && ( + + ({criticalCount} critical) + + )} +

+
+ +
+ + {/* Filters */} +
+
+ + Filters: +
+ + {/* Server Filter (only if not pre-filtered) */} + {!serverId && servers && servers.length > 0 && ( + + )} + + {/* Severity Filter */} + + + {/* Show Acknowledged Toggle */} + +
+ + {/* Errors List */} + {errors && errors.length > 0 ? ( +
+ {errors.map((error) => { + const severity = error.rule?.severity || 'WARNING' + const config = SEVERITY_CONFIG[severity] + const Icon = config.icon + const isExpanded = expandedErrors.has(error.id) + + return ( +
+
+
+
+ +
+
+
+ + {severity} + + + {error.rule?.name || 'Unknown Rule'} + + {error.acknowledgedAt && ( + + + Acknowledged + + )} +
+ +
+ + + {formatTimestamp(error.timestamp)} + + {error.server && ( + + + {error.server.nickname || error.server.netcupServerId} + + )} + {error.containerName && ( + + Container: {error.containerName} + + )} +
+ + {/* Log Line Preview */} +
+ + + {isExpanded && ( +
+
+

Log Line:

+
+                                {error.logLine}
+                              
+
+ {error.context && ( +
+

Context:

+
+                                  {error.context}
+                                
+
+ )} +
+ )} +
+
+
+ + {/* Actions */} +
+ {!error.acknowledgedAt && ( + + )} +
+
+
+ ) + })} +
+ ) : ( +
+ +

No detected errors

+

+ {showAcknowledged + ? 'No errors match the current filters' + : 'All errors have been acknowledged'} +

+
+ )} +
+ ) +} diff --git a/src/components/admin/enterprise-error-rules-manager.tsx b/src/components/admin/enterprise-error-rules-manager.tsx new file mode 100644 index 0000000..468ba8b --- /dev/null +++ b/src/components/admin/enterprise-error-rules-manager.tsx @@ -0,0 +1,381 @@ +'use client' + +import { useState } from 'react' +import { + Plus, + Edit2, + Trash2, + Play, + Pause, + AlertCircle, + AlertTriangle, + Info, + Zap, + Loader2, + Wand2 +} from 'lucide-react' +import { + useErrorRules, + useCreateErrorRule, + useUpdateErrorRule, + useDeleteErrorRule +} from '@/hooks/use-enterprise-clients' +import { seedDefaultErrorRules } from '@/lib/api/admin' +import type { ErrorSeverity, ErrorDetectionRule } from '@/types/api' + +interface EnterpriseErrorRulesManagerProps { + clientId: string +} + +interface RuleFormData { + name: string + pattern: string + severity: ErrorSeverity + description: string +} + +const SEVERITY_CONFIG: Record = { + INFO: { icon: Info, color: 'text-blue-600', bg: 'bg-blue-100' }, + WARNING: { icon: AlertTriangle, color: 'text-yellow-600', bg: 'bg-yellow-100' }, + ERROR: { icon: AlertCircle, color: 'text-red-600', bg: 'bg-red-100' }, + CRITICAL: { icon: Zap, color: 'text-purple-600', bg: 'bg-purple-100' }, +} + +export function EnterpriseErrorRulesManager({ clientId }: EnterpriseErrorRulesManagerProps) { + const [showCreateForm, setShowCreateForm] = useState(false) + const [editingRule, setEditingRule] = useState(null) + const [formData, setFormData] = useState({ + name: '', + pattern: '', + severity: 'WARNING', + description: '', + }) + const [patternError, setPatternError] = useState(null) + const [seeding, setSeeding] = useState(false) + + const { data: rules, isLoading, refetch } = useErrorRules(clientId) + const createRule = useCreateErrorRule() + const updateRule = useUpdateErrorRule() + const deleteRule = useDeleteErrorRule() + + const validatePattern = (pattern: string): boolean => { + try { + new RegExp(pattern) + setPatternError(null) + return true + } catch (e) { + setPatternError((e as Error).message) + return false + } + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + + if (!validatePattern(formData.pattern)) { + return + } + + try { + if (editingRule) { + await updateRule.mutateAsync({ + clientId, + ruleId: editingRule, + data: formData, + }) + } else { + await createRule.mutateAsync({ + clientId, + data: formData, + }) + } + resetForm() + } catch (error) { + console.error('Failed to save rule:', error) + } + } + + const resetForm = () => { + setFormData({ name: '', pattern: '', severity: 'WARNING', description: '' }) + setShowCreateForm(false) + setEditingRule(null) + setPatternError(null) + } + + const handleEdit = (rule: ErrorDetectionRule) => { + setFormData({ + name: rule.name, + pattern: rule.pattern, + severity: rule.severity, + description: rule.description || '', + }) + setEditingRule(rule.id) + setShowCreateForm(true) + } + + const handleToggleActive = async (ruleId: string, currentActive: boolean) => { + try { + await updateRule.mutateAsync({ + clientId, + ruleId, + data: { isActive: !currentActive }, + }) + } catch (error) { + console.error('Failed to toggle rule:', error) + } + } + + const handleDelete = async (ruleId: string) => { + if (!confirm('Are you sure you want to delete this rule? All detected errors for this rule will also be deleted.')) { + return + } + + try { + await deleteRule.mutateAsync({ clientId, ruleId }) + } catch (error) { + console.error('Failed to delete rule:', error) + } + } + + const handleSeedDefaults = async () => { + setSeeding(true) + try { + const result = await seedDefaultErrorRules(clientId) + if (result.count > 0) { + refetch() + } + } catch (error) { + console.error('Failed to seed default rules:', error) + } finally { + setSeeding(false) + } + } + + if (isLoading) { + return ( +
+ +
+ ) + } + + return ( +
+ {/* Header */} +
+

+ {rules?.length || 0} rule{rules?.length !== 1 ? 's' : ''} configured +

+
+ {(!rules || rules.length === 0) && ( + + )} + +
+
+ + {/* Create/Edit Form */} + {showCreateForm && ( +
+

+ {editingRule ? 'Edit Rule' : 'Create New Rule'} +

+
+
+
+ + setFormData({ ...formData, name: e.target.value })} + required + placeholder="e.g., Database Error" + className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm" + /> +
+
+ + +
+
+
+ + { + setFormData({ ...formData, pattern: e.target.value }) + validatePattern(e.target.value) + }} + required + placeholder="e.g., \b(error|ERROR)\b" + className={`w-full border rounded-md px-3 py-2 text-sm font-mono ${ + patternError ? 'border-red-300 focus:ring-red-500' : 'border-gray-300' + }`} + /> + {patternError && ( +

{patternError}

+ )} +
+
+ + setFormData({ ...formData, description: e.target.value })} + placeholder="What does this rule detect?" + className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm" + /> +
+
+ + +
+
+
+ )} + + {/* Rules List */} + {rules && rules.length > 0 ? ( +
+ {rules.map((rule) => { + const config = SEVERITY_CONFIG[rule.severity] + const Icon = config.icon + + return ( +
+
+
+
+ +
+
+
+

{rule.name}

+ + {rule.severity} + + {!rule.isActive && ( + + Disabled + + )} +
+

+ {rule.pattern} +

+ {rule.description && ( +

+ {rule.description} +

+ )} +

+ {rule._count?.detectedErrors || 0} errors detected +

+
+
+
+ + + +
+
+
+ ) + })} +
+ ) : !showCreateForm ? ( +
+ +

No error detection rules configured

+

+ Add rules to detect errors in container logs +

+
+ ) : null} +
+ ) +} diff --git a/src/components/admin/enterprise-error-summary-widget.tsx b/src/components/admin/enterprise-error-summary-widget.tsx new file mode 100644 index 0000000..3f71279 --- /dev/null +++ b/src/components/admin/enterprise-error-summary-widget.tsx @@ -0,0 +1,193 @@ +'use client' + +import Link from 'next/link' +import { + AlertCircle, + AlertTriangle, + Zap, + TrendingUp, + TrendingDown, + Minus, + RefreshCw, + Loader2, + Building2, + Server as ServerIcon, + ArrowRight, +} from 'lucide-react' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { useAllClientsErrorSummary } from '@/hooks/use-enterprise-clients' + +const TREND_CONFIG = { + increasing: { + icon: TrendingUp, + color: 'text-red-600', + bg: 'bg-red-50', + label: 'increasing', + }, + decreasing: { + icon: TrendingDown, + color: 'text-green-600', + bg: 'bg-green-50', + label: 'decreasing', + }, + stable: { + icon: Minus, + color: 'text-slate-600', + bg: 'bg-slate-50', + label: 'stable', + }, +} + +export function EnterpriseErrorSummaryWidget() { + const { data, isLoading, isError, refetch, isFetching } = useAllClientsErrorSummary() + + if (isLoading) { + return ( + + + + + + ) + } + + if (isError || !data) { + return ( + + + +

Failed to load error summary

+ +
+
+ ) + } + + const { totals, clients } = data + const hasCriticalIssues = totals.criticalErrors24h > 0 || totals.crashes24h > 0 + const clientsWithIssues = clients.filter( + (c) => c.criticalErrors24h > 0 || c.crashes24h > 0 + ).slice(0, 3) + + const trendConfig = TREND_CONFIG[totals.overallTrend] + const TrendIcon = trendConfig.icon + + return ( + + +
+
+ +
+ System Health + {hasCriticalIssues && ( + + + + + )} +
+ +
+ + {/* Main Stats */} +
+
+

Critical Errors (24h)

+
+ 0 ? 'text-red-600' : 'text-muted-foreground'}`} /> + 0 ? 'text-red-600' : ''}`}> + {totals.criticalErrors24h} + +
+
+
+

Crashed Containers (24h)

+
+ 0 ? 'text-orange-600' : 'text-muted-foreground'}`} /> + 0 ? 'text-orange-600' : ''}`}> + {totals.crashes24h} + +
+
+
+ + {/* Trend */} +
+ + + Error trend: {trendConfig.label} + + + ({totals.totalErrors24h} total errors today) + +
+ + {/* Clients with Issues */} + {clientsWithIssues.length > 0 && ( +
+

+ Clients with Issues ({totals.clientsWithIssues} total) +

+
+ {clientsWithIssues.map((client) => ( + +
+ + {client.clientName} +
+
+ {client.criticalErrors24h > 0 && ( + + + {client.criticalErrors24h} critical + + )} + {client.crashes24h > 0 && ( + + + {client.crashes24h} crashed + + )} + +
+ + ))} +
+
+ )} + + {/* No Issues State */} + {clientsWithIssues.length === 0 && totals.totalClients > 0 && ( +
+ + All {totals.totalClients} clients are healthy + +
+ )} + + {/* No Clients State */} + {totals.totalClients === 0 && ( +
+ No enterprise clients configured +
+ )} +
+
+ ) +} diff --git a/src/components/admin/enterprise-stats-charts.tsx b/src/components/admin/enterprise-stats-charts.tsx new file mode 100644 index 0000000..ba3a592 --- /dev/null +++ b/src/components/admin/enterprise-stats-charts.tsx @@ -0,0 +1,568 @@ +'use client' + +import { useMemo } from 'react' +import { + AreaChart, + Area, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + RadialBarChart, + RadialBar, + Legend +} from 'recharts' +import type { StatsDataPoint, StatsRange } from '@/lib/services/stats-collection-service' + +// ============================================================================ +// Types +// ============================================================================ + +interface ChartProps { + data: StatsDataPoint[] + height?: number +} + +interface GaugeProps { + value: number | null + max?: number + label: string + unit?: string + color?: string +} + +interface RangeSelectorProps { + value: StatsRange + onChange: (range: StatsRange) => void +} + +// ============================================================================ +// Helpers +// ============================================================================ + +function formatTime(timestamp: Date | string): string { + const date = new Date(timestamp) + return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) +} + +function formatDate(timestamp: Date | string): string { + const date = new Date(timestamp) + return date.toLocaleDateString([], { month: 'short', day: 'numeric' }) +} + +function formatDateTime(timestamp: Date | string, range: StatsRange): string { + const date = new Date(timestamp) + if (range === '24h') { + return formatTime(date) + } + return `${formatDate(date)} ${formatTime(date)}` +} + +// ============================================================================ +// Range Selector +// ============================================================================ + +export function RangeSelector({ value, onChange }: RangeSelectorProps) { + const ranges: { value: StatsRange; label: string }[] = [ + { value: '24h', label: '24h' }, + { value: '7d', label: '7d' }, + { value: '30d', label: '30d' }, + { value: '90d', label: '90d' } + ] + + return ( +
+ {ranges.map(({ value: rangeValue, label }) => ( + + ))} +
+ ) +} + +// ============================================================================ +// Stats Gauge +// ============================================================================ + +export function StatsGauge({ value, max = 100, label, unit = '%', color = '#3b82f6' }: GaugeProps) { + const displayValue = value ?? 0 + const percentage = Math.min((displayValue / max) * 100, 100) + + const data = [ + { name: label, value: percentage, fill: color } + ] + + return ( +
+
+ + + + + +
+
+
+ {value !== null ? `${displayValue.toFixed(1)}${unit}` : 'N/A'} +
+
{label}
+
+
+ ) +} + +// ============================================================================ +// CPU Usage Chart +// ============================================================================ + +export function CpuUsageChart({ data, height = 200 }: ChartProps) { + const chartData = useMemo(() => + data.map(d => ({ + time: new Date(d.timestamp).getTime(), + cpu: d.cpuPercent ?? 0 + })), + [data] + ) + + if (chartData.length === 0) { + return + } + + return ( +
+ + + + + + + + + + formatTime(value)} + tick={{ fontSize: 11 }} + stroke="#9ca3af" + /> + `${value}%`} + tick={{ fontSize: 11 }} + stroke="#9ca3af" + width={45} + /> + new Date(value as number).toLocaleString()} + formatter={(value) => [`${(value as number).toFixed(1)}%`, 'CPU']} + contentStyle={{ fontSize: 12 }} + /> + + + +
+ ) +} + +// ============================================================================ +// Memory Usage Chart +// ============================================================================ + +export function MemoryUsageChart({ data, height = 200 }: ChartProps) { + const chartData = useMemo(() => + data.map(d => ({ + time: new Date(d.timestamp).getTime(), + used: d.memoryUsedMb ?? 0, + total: d.memoryTotalMb ?? 0, + percent: d.memoryPercent ?? 0 + })), + [data] + ) + + if (chartData.length === 0) { + return + } + + return ( +
+ + + + + + + + + + formatTime(value)} + tick={{ fontSize: 11 }} + stroke="#9ca3af" + /> + `${value}%`} + tick={{ fontSize: 11 }} + stroke="#9ca3af" + width={45} + /> + new Date(value as number).toLocaleString()} + formatter={(value, name) => { + const v = value as number + if (name === 'percent') return [`${v.toFixed(1)}%`, 'Memory'] + return [`${v.toFixed(0)} MB`, name === 'used' ? 'Used' : 'Total'] + }} + contentStyle={{ fontSize: 12 }} + /> + + + +
+ ) +} + +// ============================================================================ +// Disk I/O Chart +// ============================================================================ + +export function DiskIOChart({ data, height = 200 }: ChartProps) { + const chartData = useMemo(() => + data.map(d => ({ + time: new Date(d.timestamp).getTime(), + read: d.diskReadMbps ?? 0, + write: d.diskWriteMbps ?? 0 + })), + [data] + ) + + if (chartData.length === 0) { + return + } + + return ( +
+ + + + + + + + + + + + + + formatTime(value)} + tick={{ fontSize: 11 }} + stroke="#9ca3af" + /> + `${value.toFixed(1)}`} + tick={{ fontSize: 11 }} + stroke="#9ca3af" + width={45} + /> + new Date(value as number).toLocaleString()} + formatter={(value, name) => [ + `${(value as number).toFixed(2)} MB/s`, + name === 'read' ? 'Read' : 'Write' + ]} + contentStyle={{ fontSize: 12 }} + /> + value === 'read' ? 'Read' : 'Write'} + wrapperStyle={{ fontSize: 12 }} + /> + + + + +
+ ) +} + +// ============================================================================ +// Network Chart +// ============================================================================ + +export function NetworkChart({ data, height = 200 }: ChartProps) { + const chartData = useMemo(() => + data.map(d => ({ + time: new Date(d.timestamp).getTime(), + inbound: d.networkInMbps ?? 0, + outbound: d.networkOutMbps ?? 0 + })), + [data] + ) + + if (chartData.length === 0) { + return + } + + return ( +
+ + + + + + + + + + + + + + formatTime(value)} + tick={{ fontSize: 11 }} + stroke="#9ca3af" + /> + `${value.toFixed(1)}`} + tick={{ fontSize: 11 }} + stroke="#9ca3af" + width={45} + /> + new Date(value as number).toLocaleString()} + formatter={(value, name) => [ + `${(value as number).toFixed(2)} Mbps`, + name === 'inbound' ? 'Inbound' : 'Outbound' + ]} + contentStyle={{ fontSize: 12 }} + /> + value === 'inbound' ? 'Inbound' : 'Outbound'} + wrapperStyle={{ fontSize: 12 }} + /> + + + + +
+ ) +} + +// ============================================================================ +// Container Stats Chart +// ============================================================================ + +export function ContainerStatsChart({ data, height = 200 }: ChartProps) { + const chartData = useMemo(() => + data.map(d => ({ + time: new Date(d.timestamp).getTime(), + running: d.containersRunning ?? 0, + stopped: d.containersStopped ?? 0 + })), + [data] + ) + + if (chartData.length === 0 || chartData.every(d => d.running === 0 && d.stopped === 0)) { + return + } + + return ( +
+ + + + formatTime(value)} + tick={{ fontSize: 11 }} + stroke="#9ca3af" + /> + + new Date(value as number).toLocaleString()} + formatter={(value, name) => [ + value as number, + name === 'running' ? 'Running' : 'Stopped' + ]} + contentStyle={{ fontSize: 12 }} + /> + value === 'running' ? 'Running' : 'Stopped'} + wrapperStyle={{ fontSize: 12 }} + /> + + + + +
+ ) +} + +// ============================================================================ +// Empty Chart Placeholder +// ============================================================================ + +function EmptyChart({ height, message }: { height: number; message: string }) { + return ( +
+

{message}

+
+ ) +} + +// ============================================================================ +// Stats Card +// ============================================================================ + +interface StatsCardProps { + title: string + value: string | number | null + unit?: string + icon?: React.ReactNode + trend?: 'up' | 'down' | 'neutral' + trendValue?: string +} + +export function StatsCard({ title, value, unit, icon, trend, trendValue }: StatsCardProps) { + const trendColors = { + up: 'text-green-600', + down: 'text-red-600', + neutral: 'text-gray-500' + } + + const trendIcons = { + up: '↑', + down: '↓', + neutral: '→' + } + + return ( +
+
+ {title} + {icon && {icon}} +
+
+ + {value !== null ? value : 'N/A'} + + {unit && value !== null && ( + {unit} + )} +
+ {trend && trendValue && ( +
+ {trendIcons[trend]} + {trendValue} +
+ )} +
+ ) +} diff --git a/src/components/admin/error-trend-sparkline.tsx b/src/components/admin/error-trend-sparkline.tsx new file mode 100644 index 0000000..92f7c60 --- /dev/null +++ b/src/components/admin/error-trend-sparkline.tsx @@ -0,0 +1,151 @@ +'use client' + +import { useMemo } from 'react' +import { + ResponsiveContainer, + LineChart, + Line, + Tooltip, + XAxis, +} from 'recharts' +import { format, subDays } from 'date-fns' + +interface ErrorTrendSparklineProps { + data: Array<{ + date: string + count: number + }> + trend: 'increasing' | 'decreasing' | 'stable' + height?: number + width?: number | string + showTooltip?: boolean +} + +export function ErrorTrendSparkline({ + data, + trend, + height = 32, + width = 80, + showTooltip = true, +}: ErrorTrendSparklineProps) { + // Determine color based on trend + const strokeColor = useMemo(() => { + switch (trend) { + case 'increasing': + return '#dc2626' // red-600 + case 'decreasing': + return '#16a34a' // green-600 + case 'stable': + default: + return '#64748b' // slate-500 + } + }, [trend]) + + // If no data, generate placeholder + const chartData = useMemo(() => { + if (!data || data.length === 0) { + // Generate 7 days of zeros + return Array.from({ length: 7 }, (_, i) => ({ + date: format(subDays(new Date(), 6 - i), 'yyyy-MM-dd'), + count: 0, + })) + } + return data + }, [data]) + + // Calculate min/max for domain + const maxValue = Math.max(...chartData.map((d) => d.count), 1) + + return ( +
+ + + + {showTooltip && ( + { + if (!active || !payload?.length) return null + const point = payload[0].payload as { date: string; count: number } + return ( +
+

+ {format(new Date(point.date), 'MMM d')} +

+

+ {point.count} error{point.count !== 1 ? 's' : ''} +

+
+ ) + }} + /> + )} + +
+
+
+ ) +} + +// Simple version without data - just shows the trend direction +interface SimpleTrendIndicatorProps { + trend: 'increasing' | 'decreasing' | 'stable' + percent?: number +} + +export function SimpleTrendIndicator({ trend, percent }: SimpleTrendIndicatorProps) { + const config = useMemo(() => { + switch (trend) { + case 'increasing': + return { + color: 'text-red-600', + bgColor: 'bg-red-50 dark:bg-red-900/20', + arrow: '↑', + label: 'up', + } + case 'decreasing': + return { + color: 'text-green-600', + bgColor: 'bg-green-50 dark:bg-green-900/20', + arrow: '↓', + label: 'down', + } + case 'stable': + default: + return { + color: 'text-slate-600', + bgColor: 'bg-slate-50 dark:bg-slate-900/20', + arrow: '→', + label: 'stable', + } + } + }, [trend]) + + return ( + + {config.arrow} + {percent !== undefined && percent !== 0 ? ( + {Math.abs(percent)}% + ) : ( + {config.label} + )} + + ) +} diff --git a/src/components/admin/invite-staff-dialog.tsx b/src/components/admin/invite-staff-dialog.tsx new file mode 100644 index 0000000..12d2b7a --- /dev/null +++ b/src/components/admin/invite-staff-dialog.tsx @@ -0,0 +1,204 @@ +'use client' + +import { useState } from 'react' +import { StaffRole } from '@prisma/client' +import { useInviteStaff } from '@/hooks/use-staff' +import { getAssignableRoles } from '@/lib/services/permission-service' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { Copy, Check, Mail } from 'lucide-react' + +interface InviteStaffDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + currentRole: StaffRole +} + +const roleDescriptions: Record = { + OWNER: 'Full access including deleting staff members', + ADMIN: 'Full access to all features except deleting staff', + MANAGER: 'Manage orders, customers, and servers', + SUPPORT: 'View-only access to orders and customers', +} + +export function InviteStaffDialog({ + open, + onOpenChange, + currentRole, +}: InviteStaffDialogProps) { + const [email, setEmail] = useState('') + const [role, setRole] = useState('SUPPORT') + const [inviteUrl, setInviteUrl] = useState(null) + const [emailSent, setEmailSent] = useState(null) + const [copied, setCopied] = useState(false) + + const inviteStaff = useInviteStaff() + const assignableRoles = getAssignableRoles(currentRole) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + try { + const result = await inviteStaff.mutateAsync({ email, role }) + setInviteUrl(result.inviteUrl) + setEmailSent(result.emailSent ?? false) + } catch { + // Error handled by mutation + } + } + + const handleCopyUrl = async () => { + if (!inviteUrl) return + await navigator.clipboard.writeText(inviteUrl) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + + const handleClose = () => { + setEmail('') + setRole('SUPPORT') + setInviteUrl(null) + setEmailSent(null) + setCopied(false) + inviteStaff.reset() + onOpenChange(false) + } + + return ( + + + + + {inviteUrl ? 'Invitation Sent' : 'Invite Staff Member'} + + + {inviteUrl + ? 'Share this link with the new team member to complete registration.' + : 'Send an invitation to add a new team member.'} + + + + {inviteUrl ? ( +
+
+ + + {emailSent + ? `Invitation email sent to ${email}` + : `Invitation created for ${email}`} + +
+ + {!emailSent && ( +

+ Email could not be sent. Please share the link below manually. +

+ )} + +
+ +
+ + +
+

+ This link expires in 7 days +

+
+ + + + +
+ ) : ( +
+
+ + setEmail(e.target.value)} + required + /> +
+ +
+ + +

+ {roleDescriptions[role]} +

+
+ + {inviteStaff.error && ( +

+ {inviteStaff.error.message} +

+ )} + + + + + +
+ )} +
+
+ ) +} diff --git a/src/components/admin/live-stats-panel.tsx b/src/components/admin/live-stats-panel.tsx new file mode 100644 index 0000000..bc252f6 --- /dev/null +++ b/src/components/admin/live-stats-panel.tsx @@ -0,0 +1,394 @@ +'use client' + +import { useEffect, useState, useCallback, useRef } from 'react' +import { RefreshCw, Cpu, MemoryStick, HardDrive, Network, Activity, CheckCircle2, AlertTriangle, XCircle } from 'lucide-react' +import { cn } from '@/lib/utils' + +// ============================================================================ +// Types +// ============================================================================ + +interface LiveStatsData { + cpuPercent: number | null + memoryPercent: number | null + memoryUsedMb: number | null + memoryTotalMb: number | null + diskReadMbps: number | null + diskWriteMbps: number | null + networkInMbps: number | null + networkOutMbps: number | null + containersRunning: number | null + containersStopped: number | null + timestamp?: Date | string +} + +interface LiveStatsPanelProps { + data: LiveStatsData | null | undefined + isRefreshing: boolean + onRefresh: () => void + autoRefreshInterval?: number // in milliseconds, default 15000 (15 seconds) +} + +interface GaugeConfig { + label: string + value: number | null + max: number + unit: string + icon: React.ReactNode + thresholds: { + warning: number // percentage at which to show warning (yellow) + critical: number // percentage at which to show critical (red) + } + description?: string + secondaryValue?: string +} + +// ============================================================================ +// Enhanced Circular Gauge Component +// ============================================================================ + +function CircularGauge({ + label, + value, + max, + unit, + icon, + thresholds, + description, + secondaryValue, + isRefreshing +}: GaugeConfig & { isRefreshing: boolean }) { + const displayValue = value ?? 0 + const percentage = Math.min((displayValue / max) * 100, 100) + + // Determine status color based on thresholds + const getStatusColor = () => { + if (value === null) return { stroke: '#9ca3af', bg: '#f3f4f6', text: 'text-gray-500' } + if (percentage >= thresholds.critical) return { stroke: '#ef4444', bg: '#fef2f2', text: 'text-red-600' } + if (percentage >= thresholds.warning) return { stroke: '#f59e0b', bg: '#fffbeb', text: 'text-amber-600' } + return { stroke: '#22c55e', bg: '#f0fdf4', text: 'text-green-600' } + } + + const colors = getStatusColor() + + // SVG circle parameters + const size = 120 + const strokeWidth = 8 + const radius = (size - strokeWidth) / 2 + const circumference = 2 * Math.PI * radius + const strokeDashoffset = circumference - (percentage / 100) * circumference + + // Get status icon + const getStatusIcon = () => { + if (value === null) return null + if (percentage >= thresholds.critical) return + if (percentage >= thresholds.warning) return + return + } + + return ( +
+ {/* Status indicator */} +
+ {getStatusIcon()} +
+ + {/* Circular gauge */} +
+ + {/* Background circle */} + + {/* Progress circle */} + + + + {/* Center content */} +
+
+ {icon} +
+
+ {value !== null ? displayValue.toFixed(1) : '--'} +
+
+ {unit} +
+
+
+ + {/* Label and description */} +
+
{label}
+ {description && ( +
{description}
+ )} + {secondaryValue && ( +
+ {secondaryValue} +
+ )} +
+
+ ) +} + +// ============================================================================ +// Compact Stats Bar Component +// ============================================================================ + +function CompactStatBar({ + label, + value, + max, + unit, + color +}: { + label: string + value: number | null + max: number + unit: string + color: string +}) { + const displayValue = value ?? 0 + const percentage = Math.min((displayValue / max) * 100, 100) + + return ( +
+
{label}
+
+
+
+
+ {value !== null ? `${displayValue.toFixed(1)} ${unit}` : '--'} +
+
+ ) +} + +// ============================================================================ +// Main Live Stats Panel +// ============================================================================ + +export function LiveStatsPanel({ + data, + isRefreshing, + onRefresh, + autoRefreshInterval = 15000 +}: LiveStatsPanelProps) { + const [lastUpdated, setLastUpdated] = useState(null) + const [countdown, setCountdown] = useState(autoRefreshInterval / 1000) + const intervalRef = useRef(null) + const countdownRef = useRef(null) + + // Update last updated time when data changes + useEffect(() => { + if (data?.timestamp) { + setLastUpdated(new Date(data.timestamp)) + } + }, [data?.timestamp]) + + // Auto-refresh interval + useEffect(() => { + // Initial refresh on mount + onRefresh() + + // Set up auto-refresh interval + intervalRef.current = setInterval(() => { + onRefresh() + setCountdown(autoRefreshInterval / 1000) + }, autoRefreshInterval) + + // Countdown timer + countdownRef.current = setInterval(() => { + setCountdown(prev => Math.max(0, prev - 1)) + }, 1000) + + return () => { + if (intervalRef.current) clearInterval(intervalRef.current) + if (countdownRef.current) clearInterval(countdownRef.current) + } + }, [autoRefreshInterval, onRefresh]) + + // Reset countdown when manually refreshing + const handleManualRefresh = useCallback(() => { + onRefresh() + setCountdown(autoRefreshInterval / 1000) + }, [onRefresh, autoRefreshInterval]) + + // Format time ago + const formatTimeAgo = (date: Date | null) => { + if (!date) return 'Never' + const seconds = Math.floor((new Date().getTime() - date.getTime()) / 1000) + if (seconds < 60) return `${seconds}s ago` + const minutes = Math.floor(seconds / 60) + if (minutes < 60) return `${minutes}m ago` + return `${Math.floor(minutes / 60)}h ago` + } + + // Gauge configurations + const gauges: GaugeConfig[] = [ + { + label: 'CPU Usage', + value: data?.cpuPercent ?? null, + max: 100, + unit: '%', + icon: , + thresholds: { warning: 70, critical: 90 }, + description: 'Processor utilization' + }, + { + label: 'Memory', + value: data?.memoryPercent ?? null, + max: 100, + unit: '%', + icon: , + thresholds: { warning: 75, critical: 90 }, + description: 'RAM utilization', + secondaryValue: data?.memoryUsedMb && data?.memoryTotalMb + ? `${(data.memoryUsedMb / 1024).toFixed(1)} / ${(data.memoryTotalMb / 1024).toFixed(1)} GB` + : undefined + }, + { + label: 'Disk Read', + value: data?.diskReadMbps ?? null, + max: 100, + unit: 'MB/s', + icon: , + thresholds: { warning: 50, critical: 80 }, + description: 'Storage throughput' + }, + { + label: 'Network In', + value: data?.networkInMbps ?? null, + max: 100, + unit: 'Mbps', + icon: , + thresholds: { warning: 50, critical: 80 }, + description: 'Inbound traffic' + } + ] + + return ( +
+ {/* Header */} +
+
+
+
+ +
+
+

Live Stats

+

+ Last updated: {formatTimeAgo(lastUpdated)} • Next refresh in {countdown}s +

+
+
+ + +
+
+ + {/* Main Gauges */} +
+
+ {gauges.map((gauge) => ( + + ))} +
+
+ + {/* Additional Stats */} +
+
+

Additional Metrics

+ + + + + + {/* Container counts */} + {(data?.containersRunning !== null || data?.containersStopped !== null) && ( +
+
+
+
+ + {data?.containersRunning ?? 0} running + +
+
+
+ + {data?.containersStopped ?? 0} stopped + +
+
+ + {(data?.containersRunning ?? 0) + (data?.containersStopped ?? 0)} total containers + +
+ )} +
+
+
+ ) +} + +export default LiveStatsPanel diff --git a/src/components/admin/netcup-auth-setup.tsx b/src/components/admin/netcup-auth-setup.tsx new file mode 100644 index 0000000..ec5ab1e --- /dev/null +++ b/src/components/admin/netcup-auth-setup.tsx @@ -0,0 +1,375 @@ +'use client' + +import { useState, useEffect, useCallback } from 'react' +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { + Loader2, + CheckCircle, + ExternalLink, + Server, + XCircle, + Copy, + RefreshCw, + Link2, + Link2Off, + Clock, + Shield, + Check, +} from 'lucide-react' +import { + useNetcupAuth, + useInitiateNetcupAuth, + usePollNetcupAuth, + useDisconnectNetcup, +} from '@/hooks/use-netcup' + +export function NetcupAuthSetup() { + const { data: authStatus, isLoading: isLoadingAuth } = useNetcupAuth() + const initiateMutation = useInitiateNetcupAuth() + const pollMutation = usePollNetcupAuth() + const disconnectMutation = useDisconnectNetcup() + + const [authSession, setAuthSession] = useState<{ + sessionId: string + userCode: string + verificationUri: string + verificationUriComplete: string + expiresAt: number + interval: number + } | null>(null) + + const [isPollling, setIsPolling] = useState(false) + const [copiedCode, setCopiedCode] = useState(false) + + // Start device auth flow + const handleConnect = async () => { + try { + const result = await initiateMutation.mutateAsync() + + setAuthSession({ + sessionId: result.sessionId, + userCode: result.userCode, + verificationUri: result.verificationUri, + verificationUriComplete: result.verificationUriComplete, + expiresAt: Date.now() + result.expiresIn * 1000, + interval: result.interval, + }) + + setIsPolling(true) + } catch (error) { + console.error('Failed to initiate auth:', error) + } + } + + // Poll for auth completion + const pollForAuth = useCallback(async () => { + if (!authSession || !isPollling) return + + // Check if session expired + if (Date.now() > authSession.expiresAt) { + setIsPolling(false) + setAuthSession(null) + return + } + + try { + const result = await pollMutation.mutateAsync(authSession.sessionId) + + if (result.status === 'authenticated') { + setIsPolling(false) + setAuthSession(null) + } + } catch (error) { + // Stop polling on error + console.error('Poll error:', error) + setIsPolling(false) + setAuthSession(null) + } + }, [authSession, isPollling, pollMutation]) + + // Poll timer + useEffect(() => { + if (!isPollling || !authSession) return + + const intervalId = setInterval(() => { + pollForAuth() + }, authSession.interval * 1000) + + return () => clearInterval(intervalId) + }, [isPollling, authSession, pollForAuth]) + + const handleDisconnect = async () => { + try { + await disconnectMutation.mutateAsync() + } catch (error) { + console.error('Failed to disconnect:', error) + } + } + + const copyUserCode = async () => { + if (!authSession) return + try { + await navigator.clipboard.writeText(authSession.userCode) + setCopiedCode(true) + setTimeout(() => setCopiedCode(false), 2000) + } catch { + console.error('Failed to copy code') + } + } + + const handleCancelAuth = () => { + setIsPolling(false) + setAuthSession(null) + } + + if (isLoadingAuth) { + return ( + +
+
+ +
+
+
+ +
+
+

Checking connection status...

+ +
+ + ) + } + + // Connected state + if (authStatus?.authenticated && !authSession) { + return ( + +
+ {/* Gradient background */} +
+ + {/* Decorative blob */} +
+ + +
+
+
+ +
+
+ Netcup SCP Connection + + Connected to Netcup Server Control Panel + +
+
+
+ + Connected +
+
+
+ + {authStatus.expiresAt && ( +
+ + Token expires: {new Date(authStatus.expiresAt).toLocaleString()} +
+ )} + +
+
+ + ) + } + + // Auth in progress + if (authSession) { + const remainingSeconds = Math.max( + 0, + Math.floor((authSession.expiresAt - Date.now()) / 1000) + ) + const remainingMinutes = Math.floor(remainingSeconds / 60) + + return ( + +
+ {/* Gradient background */} +
+ + {/* Decorative blob */} +
+ + +
+
+ +
+
+ Connect to Netcup + + Complete authorization in your browser + +
+
+
+ +
+
+ + Enter this code at Netcup: + + + + Expires in {remainingMinutes}m + +
+ +
+
+ {authSession.userCode} +
+ +
+ + +
+ +
+ {isPollling ? ( + <> + + Waiting for authorization... + + ) : ( + <> + + Session expired + + )} +
+ + +
+
+ + ) + } + + // Not connected + return ( + +
+ {/* Gradient background */} +
+ + {/* Decorative blob */} +
+ + +
+
+
+ +
+
+ Netcup SCP Connection + + Connect to manage servers via Netcup API + +
+
+
+ + Not Connected +
+
+
+ +

+ Connect your Netcup account to manage server power, reinstall OS, and use rescue mode directly from the Hub. +

+ +
+
+ + ) +} diff --git a/src/components/admin/netcup-server-link.tsx b/src/components/admin/netcup-server-link.tsx new file mode 100644 index 0000000..083f640 --- /dev/null +++ b/src/components/admin/netcup-server-link.tsx @@ -0,0 +1,234 @@ +'use client' + +import { useState } from 'react' +import { Button } from '@/components/ui/button' +import { useNetcupServers, useNetcupAuth, NetcupServer } from '@/hooks/use-netcup' +import { useUpdateOrder } from '@/hooks/use-orders' +import { + Loader2, + Link2, + Unlink, + Server, + AlertCircle, + ExternalLink, + Wifi, + WifiOff, + ChevronDown, +} from 'lucide-react' +import Link from 'next/link' + +interface NetcupServerLinkProps { + orderId: string + currentNetcupServerId: string | null + serverIp: string | null + onLinked?: () => void +} + +export function NetcupServerLink({ + orderId, + currentNetcupServerId, + serverIp, + onLinked, +}: NetcupServerLinkProps) { + const [selectedServerId, setSelectedServerId] = useState(currentNetcupServerId || '') + const [isLinking, setIsLinking] = useState(false) + + const { data: authStatus } = useNetcupAuth() + const { data: serversData, isLoading: serversLoading } = useNetcupServers() + const updateOrder = useUpdateOrder() + + const isAuthenticated = authStatus?.authenticated + const servers = serversData?.servers || [] + + // Find the current linked server info + const linkedServer = servers.find((s: NetcupServer) => s.id === currentNetcupServerId) + + // Find servers matching the order's IP (for suggestions) + const matchingServers = serverIp + ? servers.filter((s: NetcupServer) => s.primaryIpv4 === serverIp || s.primaryIpv6 === serverIp) + : [] + + const handleLink = async () => { + if (!selectedServerId) return + setIsLinking(true) + try { + await updateOrder.mutateAsync({ + id: orderId, + data: { netcupServerId: selectedServerId }, + }) + onLinked?.() + } finally { + setIsLinking(false) + } + } + + const handleUnlink = async () => { + setIsLinking(true) + try { + await updateOrder.mutateAsync({ + id: orderId, + data: { netcupServerId: null }, + }) + setSelectedServerId('') + onLinked?.() + } finally { + setIsLinking(false) + } + } + + // Not authenticated with Netcup + if (!isAuthenticated) { + return ( +
+
+ +
+

Netcup Not Connected

+

+ Connect to Netcup to link servers to orders. +

+ + + +
+
+
+ ) + } + + // Currently linked server display + if (linkedServer) { + return ( +
+
+
+
+ +
+
+
+ {linkedServer.nickname || linkedServer.name} + + {linkedServer.state === 'ON' ? ( + + ) : ( + + )} + {linkedServer.state} + +
+

+ {linkedServer.primaryIpv4 || linkedServer.primaryIpv6 || 'No IP'} +

+
+
+
+ + + + +
+
+
+ ) + } + + // No linked server - show selector + const otherServers = servers.filter((s: NetcupServer) => !matchingServers.some((m: NetcupServer) => m.id === s.id)) + + return ( +
+
+
+ +
+
+

Link Netcup Server

+

+ Connect this order to a Netcup server for management +

+
+
+ + {/* IP match suggestion */} + {matchingServers.length > 0 && ( +
+

+ Suggested: Found {matchingServers.length} server(s) matching IP {serverIp} +

+
+ )} + +
+
+ + +
+ +
+
+ ) +} diff --git a/src/components/admin/notification-settings-panel.tsx b/src/components/admin/notification-settings-panel.tsx new file mode 100644 index 0000000..676a607 --- /dev/null +++ b/src/components/admin/notification-settings-panel.tsx @@ -0,0 +1,344 @@ +'use client' + +import { useState } from 'react' +import { + Bell, + BellOff, + Mail, + Trash2, + Plus, + Save, + Loader2, + AlertCircle, + Clock, + Zap, + Server as ServerIcon, +} from 'lucide-react' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Switch } from '@/components/ui/switch' +import { Badge } from '@/components/ui/badge' +import { Skeleton } from '@/components/ui/skeleton' +import { + useNotificationSettings, + useUpdateNotificationSettings, +} from '@/hooks/use-enterprise-clients' +import { formatDistanceToNow } from 'date-fns' + +interface NotificationSettingsPanelProps { + clientId: string +} + +export function NotificationSettingsPanel({ clientId }: NotificationSettingsPanelProps) { + const { data: settings, isLoading, isError, refetch } = useNotificationSettings(clientId) + const updateSettings = useUpdateNotificationSettings() + + const [newEmail, setNewEmail] = useState('') + const [emailError, setEmailError] = useState(null) + const [localSettings, setLocalSettings] = useState<{ + enabled: boolean + criticalErrorsOnly: boolean + containerCrashes: boolean + recipients: string[] + cooldownMinutes: number + } | null>(null) + + // Initialize local settings from fetched data + if (settings && !localSettings) { + setLocalSettings({ + enabled: settings.enabled, + criticalErrorsOnly: settings.criticalErrorsOnly, + containerCrashes: settings.containerCrashes, + recipients: settings.recipients, + cooldownMinutes: settings.cooldownMinutes, + }) + } + + const hasChanges = localSettings && settings && ( + localSettings.enabled !== settings.enabled || + localSettings.criticalErrorsOnly !== settings.criticalErrorsOnly || + localSettings.containerCrashes !== settings.containerCrashes || + localSettings.cooldownMinutes !== settings.cooldownMinutes || + JSON.stringify(localSettings.recipients) !== JSON.stringify(settings.recipients) + ) + + const handleAddRecipient = () => { + if (!localSettings) return + + const email = newEmail.trim().toLowerCase() + if (!email) return + + // Validate email format + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + if (!emailRegex.test(email)) { + setEmailError('Invalid email format') + return + } + + // Check for duplicates + if (localSettings.recipients.includes(email)) { + setEmailError('Email already added') + return + } + + setLocalSettings({ + ...localSettings, + recipients: [...localSettings.recipients, email], + }) + setNewEmail('') + setEmailError(null) + } + + const handleRemoveRecipient = (email: string) => { + if (!localSettings) return + + setLocalSettings({ + ...localSettings, + recipients: localSettings.recipients.filter((r) => r !== email), + }) + } + + const handleSave = async () => { + if (!localSettings) return + + await updateSettings.mutateAsync({ + clientId, + data: localSettings, + }) + } + + if (isLoading) { + return ( + + + + + Email Notifications + + + + + + + + + ) + } + + if (isError || !settings) { + return ( + + + + + Email Notifications + + + + +

Failed to load notification settings

+ +
+
+ ) + } + + if (!localSettings) return null + + return ( + + +
+
+ {localSettings.enabled ? ( + + ) : ( + + )} + Email Notifications +
+ + setLocalSettings({ ...localSettings, enabled: checked }) + } + /> +
+ + Receive email alerts when critical errors or container crashes are detected + +
+ + {/* Notification Types */} +
+ +
+
+
+ +
+

Critical Errors Only

+

+ Only notify for CRITICAL severity errors +

+
+
+ + setLocalSettings({ ...localSettings, criticalErrorsOnly: checked }) + } + disabled={!localSettings.enabled} + /> +
+
+
+ +
+

Container Crashes

+

+ Notify when containers crash or are OOM killed +

+
+
+ + setLocalSettings({ ...localSettings, containerCrashes: checked }) + } + disabled={!localSettings.enabled} + /> +
+
+
+ + {/* Recipients */} +
+ + {localSettings.recipients.length > 0 ? ( +
+ {localSettings.recipients.map((email) => ( +
+
+ + {email} +
+ +
+ ))} +
+ ) : ( +

No recipients configured

+ )} +
+
+ { + setNewEmail(e.target.value) + setEmailError(null) + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + handleAddRecipient() + } + }} + className={emailError ? 'border-destructive' : ''} + /> + {emailError && ( +

{emailError}

+ )} +
+ +
+
+ + {/* Cooldown */} +
+
+ + +
+
+ + setLocalSettings({ + ...localSettings, + cooldownMinutes: parseInt(e.target.value) || 30, + }) + } + className="w-24" + disabled={!localSettings.enabled} + /> + + minutes between similar alerts + +
+

+ Prevents notification spam by waiting at least this long between alerts (5-1440 min) +

+
+ + {/* Last Notification */} + {settings.lastNotifiedAt && ( +
+

+ Last notification sent{' '} + {formatDistanceToNow(new Date(settings.lastNotifiedAt), { addSuffix: true })} +

+
+ )} + + {/* Save Button */} +
+
+ {hasChanges && ( + + Unsaved changes + + )} +
+ +
+
+
+ ) +} diff --git a/src/components/admin/order-timeline.tsx b/src/components/admin/order-timeline.tsx new file mode 100644 index 0000000..1b0b51d --- /dev/null +++ b/src/components/admin/order-timeline.tsx @@ -0,0 +1,151 @@ +'use client' + +import { useMemo, useEffect, useState, useRef } from 'react' +import { CheckCircle, Circle, Loader2 } from 'lucide-react' +import { OrderStatus } from '@/types/api' +import { cn } from '@/lib/utils' + +interface OrderTimelineProps { + status: OrderStatus + timestamps: { + createdAt?: Date | null + serverReadyAt?: Date | null + dnsVerifiedAt?: Date | null + provisioningStartedAt?: Date | null + completedAt?: Date | null + } + className?: string +} + +interface TimelineStage { + key: string + label: string + shortLabel: string + status: OrderStatus +} + +const STAGES: TimelineStage[] = [ + { key: 'payment_confirmed', label: 'Payment Confirmed', shortLabel: 'Payment', status: OrderStatus.PAYMENT_CONFIRMED }, + { key: 'awaiting_server', label: 'Server Ordered', shortLabel: 'Server', status: OrderStatus.AWAITING_SERVER }, + { key: 'dns_pending', label: 'DNS Pending', shortLabel: 'DNS', status: OrderStatus.DNS_PENDING }, + { key: 'dns_ready', label: 'DNS Ready', shortLabel: 'DNS Ready', status: OrderStatus.DNS_READY }, + { key: 'provisioning', label: 'Provisioning', shortLabel: 'Provision', status: OrderStatus.PROVISIONING }, + { key: 'fulfilled', label: 'Fulfilled', shortLabel: 'Complete', status: OrderStatus.FULFILLED }, +] + +const STATUS_ORDER = STAGES.map((s) => s.status) + +export function OrderTimeline({ status, timestamps, className }: OrderTimelineProps) { + const [mounted, setMounted] = useState(false) + + // Trigger mount animation + useEffect(() => { + const timer = setTimeout(() => setMounted(true), 50) + return () => clearTimeout(timer) + }, []) + + // Get current index, handling special statuses + const getCurrentIndex = () => { + // Handle SERVER_READY - treat as between AWAITING_SERVER and DNS_PENDING + if (status === OrderStatus.SERVER_READY) { + return STATUS_ORDER.indexOf(OrderStatus.AWAITING_SERVER) + } + // Handle EMAIL_CONFIGURED - treat as completed/fulfilled + if (status === OrderStatus.EMAIL_CONFIGURED) { + return STATUS_ORDER.indexOf(OrderStatus.FULFILLED) + } + // Handle FAILED - show progress up to where it failed + if (status === OrderStatus.FAILED) { + return STATUS_ORDER.indexOf(OrderStatus.PROVISIONING) + } + return STATUS_ORDER.indexOf(status) + } + + const currentIndex = getCurrentIndex() + + return ( +
+ {/* Horizontal timeline */} +
+ {/* Progress bar background - inset by half circle width (20px = w-5) on each side */} +
+ + {/* Progress bar filled portion - starts at same inset, width is percentage of line length */} +
+ + {/* Stage nodes */} +
+ {STAGES.map((stage, index) => { + const isComplete = index < currentIndex || + (index === currentIndex && status !== OrderStatus.FAILED && status !== OrderStatus.PROVISIONING) + const isCurrent = index === currentIndex + const isPending = index > currentIndex + const isProvisioning = isCurrent && status === OrderStatus.PROVISIONING + const isFailed = status === OrderStatus.FAILED && isCurrent + + return ( +
+ {/* Circle node */} +
+ {isComplete ? ( + + ) : isProvisioning ? ( + + ) : isFailed ? ( + ! + ) : isCurrent ? ( + + ) : ( + {index + 1} + )} +
+ + {/* Label */} +

+ {stage.shortLabel} +

+
+ ) + })} +
+
+
+ ) +} + +export default OrderTimeline diff --git a/src/components/admin/portainer-credentials-panel.tsx b/src/components/admin/portainer-credentials-panel.tsx new file mode 100644 index 0000000..69888ef --- /dev/null +++ b/src/components/admin/portainer-credentials-panel.tsx @@ -0,0 +1,298 @@ +'use client' + +import { useState } from 'react' +import { ExternalLink, Copy, Check, Eye, EyeOff, Edit2, Loader2, AlertCircle } from 'lucide-react' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Badge } from '@/components/ui/badge' +import { + usePortainerCredentials, + useUpdatePortainerCredentials, + useTestPortainerConnection, +} from '@/hooks/use-portainer' + +function formatRelativeTime(date: Date): string { + const now = Date.now() + const diff = now - date.getTime() + const seconds = Math.floor(diff / 1000) + const minutes = Math.floor(seconds / 60) + const hours = Math.floor(minutes / 60) + const days = Math.floor(hours / 24) + + if (days > 0) return `${days} day${days > 1 ? 's' : ''} ago` + if (hours > 0) return `${hours} hour${hours > 1 ? 's' : ''} ago` + if (minutes > 0) return `${minutes} minute${minutes > 1 ? 's' : ''} ago` + return 'just now' +} + +interface PortainerCredentialsPanelProps { + orderId: string + /** If true, poll every 5 seconds until credentials are configured (useful after provisioning) */ + pollUntilConfigured?: boolean +} + +export function PortainerCredentialsPanel({ orderId, pollUntilConfigured = true }: PortainerCredentialsPanelProps) { + const { data: credentials, isLoading, error } = usePortainerCredentials(orderId, pollUntilConfigured) + const updateCredentials = useUpdatePortainerCredentials() + const testConnection = useTestPortainerConnection() + + const [isEditing, setIsEditing] = useState(false) + const [showPassword, setShowPassword] = useState(false) + const [copiedField, setCopiedField] = useState(null) + const [editForm, setEditForm] = useState({ username: '', password: '' }) + + const copyToClipboard = async (text: string, field: string) => { + await navigator.clipboard.writeText(text) + setCopiedField(field) + setTimeout(() => setCopiedField(null), 2000) + } + + const handleStartEdit = () => { + setEditForm({ + username: credentials?.username || '', + password: credentials?.password || '', + }) + setIsEditing(true) + } + + const handleSaveCredentials = async () => { + try { + await updateCredentials.mutateAsync({ + orderId, + username: editForm.username, + password: editForm.password, + }) + setIsEditing(false) + } catch (error) { + console.error('Failed to save credentials:', error) + } + } + + const handleTestConnection = async () => { + await testConnection.mutateAsync(orderId) + } + + if (isLoading) { + return ( + + + +
+ Portainer + + + +
+ +
+
+ + ) + } + + if (error) { + return ( + + + +
+ Portainer + + + +
+ + Failed to load credentials +
+
+ + ) + } + + return ( + + +
+
+ +
+ Portainer + + Container management credentials +
+
+ {credentials?.isConfigured ? ( + + Configured + + ) : ( + + Not Configured + + )} + {!isEditing && ( + + )} +
+
+ + + {isEditing ? ( +
+
+ + setEditForm((prev) => ({ ...prev, username: e.target.value }))} + placeholder="admin-xxxxxxxx" + /> +
+
+ + setEditForm((prev) => ({ ...prev, password: e.target.value }))} + placeholder="Enter password" + /> +
+
+ + +
+
+ ) : ( + <> + {/* URL */} +
+ +
+ {credentials?.url ? ( + <> + + {credentials.url} + + + + ) : ( + Not available + )} +
+
+ + {/* Username */} +
+ +
+ + {credentials?.username || '-'} + + {credentials?.username && ( + + )} +
+
+ + {/* Password */} +
+ +
+ + {credentials?.password + ? showPassword + ? credentials.password + : '••••••••••••••••' + : '-'} + + {credentials?.password && ( + <> + + + + )} +
+
+ + {/* Sync status */} + {credentials?.syncedAt && ( +
+ Last synced: {formatRelativeTime(new Date(credentials.syncedAt))} +
+ )} + + {/* Test connection button */} + {credentials?.isConfigured && ( +
+ + {testConnection.data && ( + + {testConnection.data.success + ? 'Connection successful' + : testConnection.data.error || 'Connection failed'} + + )} +
+ )} + + )} +
+ + ) +} diff --git a/src/components/admin/provisioning-config-form.tsx b/src/components/admin/provisioning-config-form.tsx new file mode 100644 index 0000000..dad7164 --- /dev/null +++ b/src/components/admin/provisioning-config-form.tsx @@ -0,0 +1,316 @@ +'use client' + +import { useState, useEffect } from 'react' +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { + Loader2, + Pencil, + X, + Check, + Settings2, + RefreshCw, + Copy, + CheckCircle, +} from 'lucide-react' +import type { Order, UserSummary } from '@/types/api' + +interface ProvisioningConfigFormProps { + order: Order + onSave: (config: ProvisioningConfig) => Promise + isEditable: boolean + isSaving?: boolean +} + +export interface ProvisioningConfig { + customer: string + companyName: string + licenseKey: string +} + +// Client-side slugify function - only lowercase letters (env_setup.sh requires ^[a-z]+$) +function slugifyCustomer(name: string): string { + return name + .toLowerCase() + .replace(/[^a-z]/g, '') // Only keep lowercase letters + .substring(0, 32) +} + +// Client-side license key generator (for display purposes) +function generateClientLicenseKey(): string { + const array = new Uint8Array(16) + crypto.getRandomValues(array) + const hex = Array.from(array) + .map((b) => b.toString(16).padStart(2, '0')) + .join('') + return `lb_inst_${hex}` +} + +export function ProvisioningConfigForm({ + order, + onSave, + isEditable, + isSaving = false, +}: ProvisioningConfigFormProps) { + const [isEditing, setIsEditing] = useState(false) + const [saving, setSaving] = useState(false) + const [copiedField, setCopiedField] = useState(null) + + // Form state + const [customer, setCustomer] = useState(order.customer || '') + const [companyName, setCompanyName] = useState(order.companyName || '') + const [licenseKey, setLicenseKey] = useState(order.licenseKey || '') + + // Auto-populate defaults when entering edit mode if fields are empty + useEffect(() => { + if (isEditing) { + const userCompany = order.user.company || order.user.name || '' + + if (!customer && userCompany) { + setCustomer(slugifyCustomer(userCompany)) + } + if (!companyName && userCompany) { + setCompanyName(userCompany) + } + if (!licenseKey) { + setLicenseKey(generateClientLicenseKey()) + } + } + }, [isEditing, order.user.company, order.user.name]) + + const handleRegenerateLicenseKey = () => { + setLicenseKey(generateClientLicenseKey()) + } + + const handleSave = async () => { + if (!customer.trim() || !companyName.trim() || !licenseKey.trim()) { + return + } + + setSaving(true) + try { + await onSave({ + customer: customer.trim(), + companyName: companyName.trim(), + licenseKey: licenseKey.trim(), + }) + setIsEditing(false) + } catch (error) { + console.error('Failed to save provisioning config:', error) + } finally { + setSaving(false) + } + } + + const handleCancel = () => { + setCustomer(order.customer || '') + setCompanyName(order.companyName || '') + setLicenseKey(order.licenseKey || '') + setIsEditing(false) + } + + const copyToClipboard = async (value: string, field: string) => { + try { + await navigator.clipboard.writeText(value) + setCopiedField(field) + setTimeout(() => setCopiedField(null), 2000) + } catch { + console.error('Failed to copy to clipboard') + } + } + + const hasConfig = order.customer && order.companyName && order.licenseKey + + // Display mode + if (!isEditing) { + return ( + + +
+ + + Provisioning Config + + + Customer and license information for provisioning + +
+ {isEditable && ( + + )} +
+ + {!hasConfig ? ( +

+ No provisioning config set. Click "Configure" to set up customer details and generate a license key. +

+ ) : ( +
+
+
+ +
+ + {order.customer} + + +
+
+
+ +

{order.companyName}

+
+
+
+ +
+ + {order.licenseKey} + + +
+
+
+ )} +
+
+ ) + } + + // Edit mode + return ( + + +
+ + + Edit Provisioning Config + + + Configure customer details and license key for this order + +
+
+ + +
+
+ +
+
+
+ + setCustomer(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''))} + placeholder="acme-corp" + className="font-mono" + maxLength={32} + /> +

+ Used for container names (e.g., {customer || 'acme'}-portainer, {customer || 'acme'}-nextcloud) +

+
+
+ + setCompanyName(e.target.value)} + placeholder="Acme Corporation" + /> +

+ Display name shown in the dashboard +

+
+
+ +
+ +
+ setLicenseKey(e.target.value)} + placeholder="lb_inst_..." + className="font-mono flex-1" + readOnly + /> + +
+

+ Auto-generated unique identifier for this instance +

+
+ +
+ Note: These values will be used during provisioning to configure + the server. The customer ID is used for Docker container names, and the license key + identifies this instance in phone-home communications. +
+
+
+
+ ) +} diff --git a/src/components/admin/reinstall-dialog.tsx b/src/components/admin/reinstall-dialog.tsx new file mode 100644 index 0000000..958cce4 --- /dev/null +++ b/src/components/admin/reinstall-dialog.tsx @@ -0,0 +1,317 @@ +'use client' + +import { useState, useEffect, useMemo } from 'react' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Badge } from '@/components/ui/badge' +import { + Loader2, + HardDrive, + Search, + AlertTriangle, + Check, + ChevronDown, + ChevronRight, +} from 'lucide-react' +import { useNetcupImageFlavours, useNetcupReinstall, type ImageFlavour } from '@/hooks/use-netcup' +import { SliderConfirmDialog } from '@/components/ui/slider-confirm-dialog' + +interface ReinstallDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + serverId: string + serverName?: string + onReinstallStarted?: (taskId: string) => void +} + +export function ReinstallDialog({ + open, + onOpenChange, + serverId, + serverName, + onReinstallStarted, +}: ReinstallDialogProps) { + const [searchQuery, setSearchQuery] = useState('') + const [selectedFlavour, setSelectedFlavour] = useState(null) + const [expandedCategories, setExpandedCategories] = useState>(new Set()) + const [confirmDialogOpen, setConfirmDialogOpen] = useState(false) + + const { data: flavoursData, isLoading, error, refetch } = useNetcupImageFlavours(serverId) + const reinstallMutation = useNetcupReinstall() + + // Reset state when dialog opens + useEffect(() => { + if (open) { + setSearchQuery('') + setSelectedFlavour(null) + setExpandedCategories(new Set()) + refetch() + } + }, [open, refetch]) + + // Group flavours by category + const groupedFlavours = useMemo(() => { + if (!flavoursData?.flavours) return {} + + const filtered = flavoursData.flavours.filter((f) => { + const query = searchQuery.toLowerCase() + return ( + f.name.toLowerCase().includes(query) || + f.category.toLowerCase().includes(query) || + f.description?.toLowerCase().includes(query) + ) + }) + + return filtered.reduce( + (acc, flavour) => { + const category = flavour.category || 'Other' + if (!acc[category]) acc[category] = [] + acc[category].push(flavour) + return acc + }, + {} as Record + ) + }, [flavoursData?.flavours, searchQuery]) + + // Sort categories alphabetically, but put common ones first + const sortedCategories = useMemo(() => { + const categories = Object.keys(groupedFlavours) + const priority = ['Debian', 'Ubuntu', 'CentOS', 'Rocky', 'AlmaLinux', 'Windows'] + + return categories.sort((a, b) => { + const aIndex = priority.findIndex((p) => a.toLowerCase().includes(p.toLowerCase())) + const bIndex = priority.findIndex((p) => b.toLowerCase().includes(p.toLowerCase())) + + if (aIndex !== -1 && bIndex !== -1) return aIndex - bIndex + if (aIndex !== -1) return -1 + if (bIndex !== -1) return 1 + return a.localeCompare(b) + }) + }, [groupedFlavours]) + + const toggleCategory = (category: string) => { + const newExpanded = new Set(expandedCategories) + if (newExpanded.has(category)) { + newExpanded.delete(category) + } else { + newExpanded.add(category) + } + setExpandedCategories(newExpanded) + } + + const handleReinstall = async () => { + if (!selectedFlavour) return + + try { + const result = await reinstallMutation.mutateAsync({ + serverId, + imageFlavour: selectedFlavour.id, + }) + setConfirmDialogOpen(false) + onOpenChange(false) + + // Notify parent about the reinstall task so it can poll for status + if (result?.taskId && onReinstallStarted) { + onReinstallStarted(result.taskId) + } + } catch (err) { + // Error handled by mutation + } + } + + const totalFlavours = flavoursData?.flavours?.length || 0 + const filteredCount = Object.values(groupedFlavours).reduce((sum, arr) => sum + arr.length, 0) + + return ( + <> + + + + + + Reinstall Operating System + + + Select an operating system to install on {serverName || `server ${serverId}`}. This + will erase all data on the + server. + + + +
+ {/* Search */} +
+ + setSearchQuery(e.target.value)} + className="pl-9" + /> +
+ + {/* Image List */} + {isLoading ? ( +
+ +

Loading available images...

+
+ ) : error ? ( +
+

Failed to load available images

+ +
+ ) : totalFlavours === 0 ? ( +
+ +

No images available for this server

+
+ ) : ( + <> +
+ + {filteredCount === totalFlavours + ? `${totalFlavours} images available` + : `${filteredCount} of ${totalFlavours} images`} + + {selectedFlavour && ( + + + Selected: {selectedFlavour.name} + + )} +
+ +
+
+ {sortedCategories.map((category) => ( +
+ {/* Category Header */} + + + {/* Category Items */} + {expandedCategories.has(category) && ( +
+ {groupedFlavours[category].map((flavour) => ( + + ))} +
+ )} +
+ ))} +
+
+ + )} +
+ + + + + +
+
+ + {/* Confirmation Dialog */} + +
+ +
+

This action cannot be undone!

+

+ All data on the server will be permanently erased and the operating system will be + reinstalled. +

+
+
+
+

+ Server:{' '} + {serverName || serverId} +

+

+ New OS:{' '} + {selectedFlavour?.name} +

+
+ + } + confirmText="Reinstall" + variant="destructive" + onConfirm={handleReinstall} + isLoading={reinstallMutation.isPending} + /> + + ) +} diff --git a/src/components/admin/security-verification-dialog.tsx b/src/components/admin/security-verification-dialog.tsx new file mode 100644 index 0000000..f6e2ff7 --- /dev/null +++ b/src/components/admin/security-verification-dialog.tsx @@ -0,0 +1,381 @@ +'use client' + +import { useState, useEffect, useRef } from 'react' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { + AlertTriangle, + Loader2, + Mail, + ShieldAlert, + CheckCircle2, + XCircle, + RefreshCw +} from 'lucide-react' +import { useRequestVerificationCode, useServerAction } from '@/hooks/use-enterprise-clients' + +type VerificationAction = 'WIPE' | 'REINSTALL' + +interface SecurityVerificationDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + clientId: string + serverId: string + serverName: string + action: VerificationAction + imageId?: string // Required for reinstall + onSuccess?: () => void +} + +type Step = 'confirm' | 'code' | 'executing' | 'success' | 'error' + +export function SecurityVerificationDialog({ + open, + onOpenChange, + clientId, + serverId, + serverName, + action, + imageId, + onSuccess +}: SecurityVerificationDialogProps) { + const [step, setStep] = useState('confirm') + const [code, setCode] = useState('') + const [email, setEmail] = useState('') + const [expiresAt, setExpiresAt] = useState(null) + const [timeLeft, setTimeLeft] = useState(0) + const [error, setError] = useState(null) + const inputRefs = useRef<(HTMLInputElement | null)[]>([]) + + const requestCode = useRequestVerificationCode() + const serverAction = useServerAction() + + // Reset state when dialog opens + useEffect(() => { + if (open) { + setStep('confirm') + setCode('') + setEmail('') + setExpiresAt(null) + setError(null) + } + }, [open]) + + // Countdown timer + useEffect(() => { + if (!expiresAt) return + + const updateTimer = () => { + const now = new Date() + const diff = Math.max(0, Math.floor((expiresAt.getTime() - now.getTime()) / 1000)) + setTimeLeft(diff) + + if (diff === 0) { + setError('Verification code has expired. Please request a new code.') + } + } + + updateTimer() + const interval = setInterval(updateTimer, 1000) + return () => clearInterval(interval) + }, [expiresAt]) + + const handleRequestCode = async () => { + setError(null) + try { + const result = await requestCode.mutateAsync({ + clientId, + serverId, + action + }) + setEmail(result.email) + setExpiresAt(new Date(result.expiresAt)) + setStep('code') + // Focus first input after a brief delay + setTimeout(() => inputRefs.current[0]?.focus(), 100) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to send verification code') + } + } + + const handleCodeChange = (index: number, value: string) => { + // Only allow digits + const digit = value.replace(/\D/g, '').slice(-1) + + const newCode = code.split('') + newCode[index] = digit + const joined = newCode.join('') + setCode(joined.padEnd(6, '').slice(0, 6)) + + // Auto-focus next input + if (digit && index < 5) { + inputRefs.current[index + 1]?.focus() + } + } + + const handleKeyDown = (index: number, e: React.KeyboardEvent) => { + if (e.key === 'Backspace' && !code[index] && index > 0) { + inputRefs.current[index - 1]?.focus() + } + } + + const handlePaste = (e: React.ClipboardEvent) => { + e.preventDefault() + const pasted = e.clipboardData.getData('text').replace(/\D/g, '').slice(0, 6) + setCode(pasted) + // Focus the input after the last pasted digit + inputRefs.current[Math.min(pasted.length, 5)]?.focus() + } + + const handleVerifyAndExecute = async () => { + if (code.length !== 6) { + setError('Please enter the complete 6-digit code') + return + } + + setError(null) + setStep('executing') + + try { + await serverAction.mutateAsync({ + clientId, + serverId, + action: { + action: action.toLowerCase() as 'wipe' | 'reinstall', + verificationCode: code, + ...(action === 'REINSTALL' && imageId ? { imageId } : {}) + } + }) + setStep('success') + onSuccess?.() + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to execute action') + setStep('error') + } + } + + const formatTime = (seconds: number) => { + const mins = Math.floor(seconds / 60) + const secs = seconds % 60 + return `${mins}:${secs.toString().padStart(2, '0')}` + } + + const actionText = action === 'WIPE' ? 'wipe' : 'reinstall' + const actionTitle = action === 'WIPE' ? 'Wipe Server' : 'Reinstall Server' + + return ( + { + if (step !== 'executing') onOpenChange(o) + }}> + + {step === 'confirm' && ( + <> + + + + {actionTitle} + + + This is a destructive action that requires email verification. + + +
+
+
+ +
+

Warning: Data Loss

+

+ {action === 'WIPE' + ? 'All data on this server will be permanently erased and cannot be recovered.' + : 'The operating system will be reinstalled. All data will be lost unless backed up.'} +

+
+
+
+
+

Server to {actionText}:

+

{serverName}

+
+ {error && ( +

{error}

+ )} +
+ + + + + + )} + + {step === 'code' && ( + <> + + + + Enter Verification Code + + + A 6-digit code was sent to {email} + + +
+ +
+ {[0, 1, 2, 3, 4, 5].map((i) => ( + { inputRefs.current[i] = el }} + type="text" + inputMode="numeric" + maxLength={1} + value={code[i] || ''} + onChange={(e) => handleCodeChange(i, e.target.value)} + onKeyDown={(e) => handleKeyDown(i, e)} + className="w-12 h-14 text-center text-2xl font-mono" + disabled={timeLeft === 0} + /> + ))} +
+
+ {timeLeft > 0 ? ( +

+ Code expires in {formatTime(timeLeft)} +

+ ) : ( +

Code expired

+ )} +
+ {error && ( +

{error}

+ )} + +
+ + + + + + )} + + {step === 'executing' && ( + <> + + + + {action === 'WIPE' ? 'Wiping Server...' : 'Reinstalling Server...'} + + +
+ +

+ This may take several minutes. Please wait... +

+
+ + )} + + {step === 'success' && ( + <> + + + + Action Initiated + + +
+ +

+ The {actionText} operation has been initiated for {serverName}. +

+

+ The server will be unavailable during this process. +

+
+ + + + + )} + + {step === 'error' && ( + <> + + + + Action Failed + + +
+ +

+ {error || 'An error occurred while executing the action.'} +

+

+ Please try again or contact support if the problem persists. +

+
+ + + + + + )} +
+
+ ) +} diff --git a/src/components/admin/server-metrics-panel.tsx b/src/components/admin/server-metrics-panel.tsx new file mode 100644 index 0000000..02cf453 --- /dev/null +++ b/src/components/admin/server-metrics-panel.tsx @@ -0,0 +1,310 @@ +'use client' + +import { useState } from 'react' +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { + Loader2, + Activity, + Cpu, + HardDrive, + Network, + Download, + Upload, + RefreshCw, + ExternalLink, +} from 'lucide-react' +import Link from 'next/link' +import { useServerMetrics } from '@/hooks/use-netcup' + +interface ServerMetricsPanelProps { + netcupServerId: string + serverName?: string +} + +function formatBytes(bytes: number): string { + if (bytes === 0) return '0 B' + if (!Number.isFinite(bytes) || bytes < 0) return '0 B' + const k = 1024 + const sizes = ['B', 'KB', 'MB', 'GB', 'TB'] + const i = Math.max(0, Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1)) + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i] +} + +function formatBytesPerSec(bps: number): string { + return formatBytes(bps) + '/s' +} + +function MetricsBar({ value, max = 100, gradient }: { + value: number + max?: number + gradient?: string +}) { + const percentage = Math.min((value / max) * 100, 100) + + const getThresholdColor = () => { + if (percentage > 90) return 'bg-gradient-to-r from-red-500 to-red-600' + if (percentage > 75) return 'bg-gradient-to-r from-amber-500 to-orange-500' + return gradient || 'bg-gradient-to-r from-blue-500 to-blue-600' + } + + return ( +
+
+
+ ) +} + +export function ServerMetricsPanel({ netcupServerId, serverName }: ServerMetricsPanelProps) { + const [hours, setHours] = useState(24) + const { data: metrics, isLoading, isFetching, error, refetch } = useServerMetrics(netcupServerId, hours, !!netcupServerId) + + if (!netcupServerId) { + return null + } + + const isInitialLoading = isLoading && !metrics + + if (error && !metrics) { + return ( + + +
+
+
+ +
+
+ Server Metrics + Performance monitoring +
+
+
+
+ +
+

Failed to load metrics

+ +
+
+
+ ) + } + + return ( + + +
+
+
+ +
+
+ Server Metrics + + Performance monitoring + {serverName && ( + + View full details + + + )} + +
+
+
+ {/* Period selector */} +
+ {[1, 6, 24, 168].map((h) => ( + + ))} +
+ +
+
+
+ + {isInitialLoading ? ( +
+ {[ + { icon: Cpu, label: 'CPU', color: 'text-blue-500 bg-blue-100 dark:bg-blue-900/30' }, + { icon: HardDrive, label: 'Disk I/O', color: 'text-emerald-500 bg-emerald-100 dark:bg-emerald-900/30' }, + { icon: Network, label: 'Network', color: 'text-violet-500 bg-violet-100 dark:bg-violet-900/30' }, + ].map(({ icon: Icon, label, color }) => ( +
+
+
+ +
+ {label} +
+
+ +
+
+ ))} +
+ ) : !metrics ? ( +
+ +

No metrics data available

+

+ Metrics will appear once the server is running +

+
+ ) : ( +
+ {/* CPU metrics */} +
+
+
+
+ +
+ CPU +
+ + {metrics.cpu.average}% + +
+ +
+ Peak: {metrics.cpu.max}% + {metrics.cpu.dataPoints.length} samples +
+
+ + {/* Disk I/O metrics */} + {metrics.disk && (metrics.disk.readBps.length > 0 || metrics.disk.writeBps.length > 0) ? ( +
+
+
+ +
+ Disk I/O +
+
+
+
+ + Read +
+

+ {metrics.disk.readBps.length > 0 + ? formatBytesPerSec(metrics.disk.readBps[metrics.disk.readBps.length - 1]?.value || 0) + : 'N/A'} +

+
+
+
+ + Write +
+

+ {metrics.disk.writeBps.length > 0 + ? formatBytesPerSec(metrics.disk.writeBps[metrics.disk.writeBps.length - 1]?.value || 0) + : 'N/A'} +

+
+
+
+ ) : ( +
+
+
+ +
+ Disk I/O +
+

No data

+
+ )} + + {/* Network metrics */} + {metrics.network && (metrics.network.rxBps.length > 0 || metrics.network.txBps.length > 0) ? ( +
+
+
+ +
+ Network +
+
+
+
+ + RX +
+

+ {metrics.network.rxBps.length > 0 + ? formatBytesPerSec(metrics.network.rxBps[metrics.network.rxBps.length - 1]?.value || 0) + : 'N/A'} +

+
+
+
+ + TX +
+

+ {metrics.network.txBps.length > 0 + ? formatBytesPerSec(metrics.network.txBps[metrics.network.txBps.length - 1]?.value || 0) + : 'N/A'} +

+
+
+
+ ) : ( +
+
+
+ +
+ Network +
+

No data

+
+ )} +
+ )} +
+
+ ) +} diff --git a/src/components/admin/server-quick-actions.tsx b/src/components/admin/server-quick-actions.tsx new file mode 100644 index 0000000..32dee36 --- /dev/null +++ b/src/components/admin/server-quick-actions.tsx @@ -0,0 +1,375 @@ +'use client' + +import { useState } from 'react' +import Link from 'next/link' +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { + Loader2, + Power, + PowerOff, + RefreshCw, + AlertTriangle, + Server, + Wrench, + CheckCircle, + XCircle, + Pencil, + Copy, + Check, + ExternalLink, +} from 'lucide-react' +import { EditHostnameDialog } from '@/components/admin/edit-hostname-dialog' +import { + useNetcupServer, + useNetcupPowerAction, + useNetcupRescue, + PowerAction, +} from '@/hooks/use-netcup' + +interface ServerQuickActionsProps { + netcupServerId: string + serverIp: string | null +} + +const stateConfig: Record< + string, + { label: string; color: string; bgColor: string; icon: typeof Power } +> = { + ON: { + label: 'Online', + color: 'text-green-700', + bgColor: 'bg-green-100', + icon: CheckCircle, + }, + OFF: { + label: 'Offline', + color: 'text-gray-700', + bgColor: 'bg-gray-100', + icon: PowerOff, + }, + POWERCYCLE: { + label: 'Cycling', + color: 'text-amber-700', + bgColor: 'bg-amber-100', + icon: RefreshCw, + }, + RESET: { + label: 'Resetting', + color: 'text-amber-700', + bgColor: 'bg-amber-100', + icon: RefreshCw, + }, + POWEROFF: { + label: 'Shutting Down', + color: 'text-amber-700', + bgColor: 'bg-amber-100', + icon: PowerOff, + }, + UNKNOWN: { + label: 'Unknown', + color: 'text-gray-500', + bgColor: 'bg-gray-100', + icon: AlertTriangle, + }, +} + +export function ServerQuickActions({ + netcupServerId, + serverIp, +}: ServerQuickActionsProps) { + const { data: server, isLoading, refetch } = useNetcupServer(netcupServerId, true) + const powerMutation = useNetcupPowerAction() + const rescueMutation = useNetcupRescue() + + const [confirmAction, setConfirmAction] = useState(null) + const [hostnameDialogOpen, setHostnameDialogOpen] = useState(false) + const [copiedIp, setCopiedIp] = useState(false) + + const handleCopyIp = async () => { + if (server?.primaryIpv4) { + await navigator.clipboard.writeText(server.primaryIpv4) + setCopiedIp(true) + setTimeout(() => setCopiedIp(false), 2000) + } + } + + const handlePowerAction = async (action: PowerAction) => { + try { + await powerMutation.mutateAsync({ serverId: netcupServerId, action }) + setConfirmAction(null) + } catch (error) { + console.error('Power action failed:', error) + } + } + + const handleRescue = async (activate: boolean) => { + try { + await rescueMutation.mutateAsync({ serverId: netcupServerId, activate }) + setConfirmAction(null) + } catch (error) { + console.error('Rescue action failed:', error) + } + } + + if (!netcupServerId) { + return ( + + + + + Netcup Server Controls + + + Server not linked to Netcup + + + +

+ {serverIp + ? 'Server IP is configured but not linked to Netcup. Link will happen automatically when Netcup connection is active.' + : 'Add server credentials to enable Netcup integration.'} +

+
+
+ ) + } + + if (isLoading) { + return ( + + + +

Loading server...

+
+
+ ) + } + + if (!server) { + return ( + + + + + Netcup Server Controls + + + +
+ +

Server not found in Netcup. The server ID may be invalid.

+
+
+
+ ) + } + + const config = stateConfig[server.state] || stateConfig.UNKNOWN + const StateIcon = config.icon + const isOn = server.state === 'ON' + const isPending = powerMutation.isPending || rescueMutation.isPending + + return ( + + +
+
+ + + Netcup Server + + + + {server.name} {server.nickname ? `(${server.nickname})` : ''} + + + +
+
+ + + {config.label} + + +
+
+
+ + {/* Live stats if available */} + {server.cpuUsage !== undefined && ( +
+
+
CPU
+
{server.cpuUsage}%
+
+
+
RAM
+
{server.ramUsage}%
+
+
+
Disk
+
{server.diskUsage}%
+
+
+ )} + + {/* Confirm dialog */} + {confirmAction && ( +
+

+ {confirmAction === 'rescue' + ? 'Enable rescue mode?' + : `Confirm ${confirmAction.toLowerCase()} action?`} +

+

+ {confirmAction === 'POWEROFF' && 'Server will be forcefully powered off.'} + {confirmAction === 'RESET' && 'Server will be hard reset (may cause data loss).'} + {confirmAction === 'POWERCYCLE' && 'Server will be power cycled.'} + {confirmAction === 'rescue' && + 'Server will boot into rescue mode on next restart.'} +

+
+ + +
+
+ )} + + {/* Action buttons */} + {!confirmAction && ( +
+ {!isOn ? ( + + ) : ( + <> + + + + )} + +
+ )} + + {/* Server info */} +
+
+ Hostname: + + {server.hostname || Not set} + + +
+ {server.primaryIpv4 && ( +
+ IPv4: + {server.primaryIpv4} + +
+ )} +
+ + {/* Edit Hostname Dialog */} + +
+
+ ) +} diff --git a/src/components/admin/sidebar.tsx b/src/components/admin/sidebar.tsx index 787936c..a1088c2 100644 --- a/src/components/admin/sidebar.tsx +++ b/src/components/admin/sidebar.tsx @@ -2,7 +2,10 @@ import Link from 'next/link' import { usePathname } from 'next/navigation' +import { useSession } from 'next-auth/react' import { cn } from '@/lib/utils' +import { hasPermission, Permission } from '@/lib/services/permission-service' +import { StaffRole } from '@prisma/client' import { LayoutDashboard, Users, @@ -11,45 +14,88 @@ import { Settings, LogOut, Server, + Cloud, + Building2, + UserCog, + User, } from 'lucide-react' import { signOut } from 'next-auth/react' import { Button } from '@/components/ui/button' -const navigation = [ +interface NavItem { + name: string + href: string + icon: typeof LayoutDashboard + permission?: Permission +} + +const navigation: NavItem[] = [ { name: 'Dashboard', href: '/admin', icon: LayoutDashboard, + permission: 'dashboard:view', }, { name: 'Orders', href: '/admin/orders', icon: ShoppingCart, + permission: 'orders:view', }, { name: 'Customers', href: '/admin/customers', icon: Users, + permission: 'customers:view', }, { name: 'Servers', href: '/admin/servers', icon: Server, + permission: 'servers:view', + }, + { + name: 'Netcup', + href: '/admin/servers/netcup', + icon: Cloud, + permission: 'servers:view', + }, + { + name: 'Enterprise Clients', + href: '/admin/enterprise-clients', + icon: Building2, + permission: 'enterprise:view', + }, + { + name: 'Staff', + href: '/admin/staff', + icon: UserCog, + permission: 'staff:view', }, { name: 'Analytics', href: '/admin/analytics', icon: BarChart3, + permission: 'dashboard:view', }, { name: 'Settings', href: '/admin/settings', icon: Settings, + permission: 'settings:view', }, ] export function AdminSidebar() { const pathname = usePathname() + const { data: session } = useSession() + const userRole = (session?.user?.role as StaffRole) || 'SUPPORT' + + // Filter navigation items based on permissions + const visibleNavigation = navigation.filter((item) => { + if (!item.permission) return true + return hasPermission(userRole, item.permission) + }) return (
@@ -65,10 +111,19 @@ export function AdminSidebar() { {/* Navigation */}