From 7653e3411ca8db5f6b834b5c8922bab010b4f9b6 Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Sun, 21 Jun 2026 16:01:08 +0200 Subject: [PATCH] Merge NetBird cloud edition into the dashboard (#674) Brings the unified dashboard into the open-source repo. Premium features ship in the open code, gated at runtime via NETBIRD_CLOUD and NETBIRD_LICENSED, with upgrade prompts for unlicensed self-hosted deployments. Adds the cloud-only feature areas (billing, integrations, MSP, traffic events, notifications) and the Playwright e2e suite. --- .github/pull_request_template.md | 7 + .github/workflows/build_and_push.yml | 55 +- .github/workflows/e2e-test.yml | 162 +++ .gitignore | 9 +- AUTHORS | 2 +- config.json | 10 +- cypress.config.ts | 15 - cypress/e2e/test.cy.ts | 13 - cypress/fixtures/example.json | 5 - cypress/support/commands.ts | 37 - cypress/support/e2e.ts | 20 - cypress/tsconfig.json | 9 - docker/default.conf | 14 +- docker/init_react_envs.sh | 85 +- e2e/CLAUDE.md | 233 ++++ e2e/environment/.gitignore | 12 + e2e/environment/clean-test-env.sh | 25 + e2e/environment/create-test-env.sh | 927 +++++++++++++++ e2e/fixtures/auth/.gitkeep | 0 e2e/helpers/api.ts | 435 +++++++ e2e/helpers/auth.ts | 117 ++ e2e/helpers/fixtures.ts | 49 + e2e/helpers/navigation.ts | 8 + e2e/helpers/reverse-proxy-l4.ts | 232 ++++ e2e/helpers/utils.ts | 103 ++ e2e/playwright.config.ts | 54 + e2e/tests/access-control-groups.spec.ts | 116 ++ e2e/tests/access-control.spec.ts | 151 +++ e2e/tests/dns-nameservers.spec.ts | 168 +++ e2e/tests/dns-settings.spec.ts | 89 ++ e2e/tests/dns-zones.spec.ts | 197 +++ e2e/tests/edition-gating.spec.ts | 216 ++++ e2e/tests/login.spec.ts | 109 ++ e2e/tests/network-routes.spec.ts | 176 +++ e2e/tests/networks.spec.ts | 164 +++ e2e/tests/reverse-proxy-crowdsec.spec.ts | 167 +++ .../reverse-proxy-custom-domains.spec.ts | 87 ++ .../reverse-proxy-services-https.spec.ts | 275 +++++ e2e/tests/reverse-proxy-services-tcp.spec.ts | 115 ++ e2e/tests/reverse-proxy-services-tls.spec.ts | 117 ++ ...proxy-services-udp-no-custom-ports.spec.ts | 119 ++ e2e/tests/reverse-proxy-services-udp.spec.ts | 111 ++ e2e/tests/settings-authentication.spec.ts | 80 ++ e2e/tests/settings-clients.spec.ts | 124 ++ e2e/tests/settings-groups.spec.ts | 24 + e2e/tests/settings-networks.spec.ts | 240 ++++ .../settings-notifications-email.spec.ts | 75 ++ .../settings-notifications-slack.spec.ts | 55 + .../settings-notifications-webhook.spec.ts | 130 ++ e2e/tests/settings-permissions.spec.ts | 41 + e2e/tests/setup-keys.spec.ts | 161 +++ e2e/tests/team-service-users.spec.ts | 115 ++ .../team-users-approval-and-billing.spec.ts | 150 +++ e2e/tests/team-users.spec.ts | 105 ++ package-lock.json | 1052 ++++++++++------- package.json | 20 +- postbuild.js | 96 ++ .../(dashboard)/(cloud)/customers/layout.tsx | 8 + .../(dashboard)/(cloud)/customers/page.tsx | 69 ++ .../(cloud)/integrations/layout.tsx | 8 + .../(dashboard)/(cloud)/integrations/page.tsx | 73 ++ src/app/(dashboard)/(cloud)/msp/page.tsx | 150 +++ .../(cloud)/plans/cancel/layout.tsx | 8 + .../(dashboard)/(cloud)/plans/cancel/page.tsx | 9 + src/app/(dashboard)/(cloud)/plans/layout.tsx | 8 + src/app/(dashboard)/(cloud)/plans/page.tsx | 10 + .../(cloud)/plans/success/layout.tsx | 8 + .../(cloud)/plans/success/page.tsx | 10 + .../(dashboard)/(cloud)/tenants/layout.tsx | 8 + src/app/(dashboard)/(cloud)/tenants/page.tsx | 85 ++ src/app/(dashboard)/control-center/page.tsx | 19 +- src/app/(dashboard)/dns/settings/page.tsx | 4 +- src/app/(dashboard)/events/audit/page.tsx | 2 + src/app/(dashboard)/events/traffic/layout.tsx | 8 + src/app/(dashboard)/events/traffic/page.tsx | 414 +++++++ src/app/(dashboard)/group/page.tsx | 8 + src/app/(dashboard)/network/page.tsx | 12 +- src/app/(dashboard)/peer/page.tsx | 16 +- src/app/(dashboard)/peers/page.tsx | 2 +- src/app/(dashboard)/peers/servers/page.tsx | 9 +- src/app/(dashboard)/peers/users/page.tsx | 9 +- .../reverse-proxy/services/page.tsx | 4 +- src/app/(dashboard)/settings/page.tsx | 28 +- src/app/(dashboard)/team/user/page.tsx | 17 +- src/app/(dashboard)/team/users/page.tsx | 8 + src/app/(remote-access)/layout.tsx | 3 + src/app/(remote-access)/peer/rdp/page.tsx | 4 +- src/app/install/page.tsx | 42 +- src/app/not-found.tsx | 15 +- src/app/page.tsx | 15 +- src/assets/avatars/jack.jpeg | Bin 0 -> 90480 bytes src/assets/icons/CircleIcon.tsx | 3 +- src/assets/icons/MSPIcon.tsx | 19 + src/assets/integrations/aws-marketplace.svg | 106 ++ src/assets/integrations/crowdstrike.png | Bin 0 -> 7359 bytes src/assets/integrations/firehose.png | Bin 0 -> 17212 bytes src/assets/integrations/fleetdm.png | Bin 0 -> 5832 bytes src/assets/integrations/generic-http.png | Bin 0 -> 6363 bytes src/assets/integrations/generic-scim.png | Bin 0 -> 12310 bytes src/assets/integrations/huntress.png | Bin 0 -> 26344 bytes src/assets/integrations/intune.png | Bin 0 -> 4805 bytes src/assets/integrations/jumpcloud.png | Bin 0 -> 10852 bytes src/assets/integrations/keycloak.png | Bin 0 -> 11580 bytes src/assets/integrations/s3.svg | 1 + src/assets/integrations/sentinelone.png | Bin 0 -> 22676 bytes src/assets/integrations/slack.png | Bin 0 -> 5762 bytes src/auth/SecureProvider.tsx | 42 +- src/cloud/analytics/Hubspot.tsx | 208 ++++ src/cloud/aws/AWSChoosePlan.tsx | 232 ++++ src/cloud/aws/useAWSMarketplace.ts | 21 + src/cloud/cloud-hooks/useAuthService.ts | 22 + src/cloud/cloud-hooks/useCookies.ts | 121 ++ src/cloud/cloud-hooks/useDomainCategory.tsx | 35 + src/cloud/cloud-hooks/useExperiment.ts | 74 ++ src/cloud/cloud-hooks/useIsFeatureLocked.tsx | 75 ++ src/cloud/cloud-hooks/useTrial.tsx | 38 + src/cloud/contexts/NetBirdCloudProvider.tsx | 89 ++ .../DistributorAccountExistsModal.tsx | 81 ++ .../distributor/DistributorCustomerModal.tsx | 377 ++++++ src/cloud/distributor/DistributorDocsLink.tsx | 18 + .../distributor/DistributorNavigation.tsx | 92 ++ .../DistributorSubscriptionModal.tsx | 109 ++ .../DistributorTransferAccountModal.tsx | 127 ++ .../contexts/CustomersProvider.tsx | 213 ++++ .../contexts/DistributorProvider.tsx | 106 ++ .../distributor/hooks/useCustomerPlan.ts | 166 +++ .../distributor/interfaces/Distributor.ts | 27 + .../distributor/table/CustomerActionCell.tsx | 87 ++ .../distributor/table/CustomerNameCell.tsx | 77 ++ .../distributor/table/CustomerPlanCell.tsx | 98 ++ .../distributor/table/CustomerTenantsCell.tsx | 33 + .../table/DistributorCustomersTable.tsx | 203 ++++ .../distributor/useDistributorRedirect.tsx | 45 + src/cloud/edr/PeerDisapprovalReason.tsx | 16 + src/cloud/edr/useBypass.tsx | 60 + src/cloud/invoices/InvoicesTab.tsx | 113 ++ .../invoices/table/InvoicesActionCell.tsx | 109 ++ .../invoices/table/InvoicesPeriodCell.tsx | 20 + src/cloud/invoices/table/InvoicesTable.tsx | 119 ++ src/cloud/invoices/table/InvoicesTypeCell.tsx | 28 + src/cloud/mfa/AccountMFACard.tsx | 75 ++ src/cloud/mfa/AccountMFAInfoModal.tsx | 55 + src/cloud/mfa/AccountMFASettings.tsx | 208 ++++ src/cloud/mfa/UserMFAListItem.tsx | 205 ++++ src/cloud/msp/MSPAccountExistsModal.tsx | 81 ++ src/cloud/msp/MSPDomainVerificationModal.tsx | 140 +++ src/cloud/msp/MSPNavigationItem.tsx | 34 + src/cloud/msp/MSPSubscriptionModal.tsx | 98 ++ src/cloud/msp/MSPTenantDocsLink.tsx | 18 + src/cloud/msp/MSPTenantModal.tsx | 368 ++++++ src/cloud/msp/MSPTenantPermissionsTab.tsx | 178 +++ src/cloud/msp/MSPTenantPlanTab.tsx | 71 ++ src/cloud/msp/MSPTenantsSwitcher.tsx | 328 +++++ src/cloud/msp/MSPTenantsTable.tsx | 291 +++++ src/cloud/msp/MSPTransferAccountModal.tsx | 193 +++ src/cloud/msp/MSPTrialExpiredModal.tsx | 143 +++ src/cloud/msp/MSPUnlinkModal.tsx | 112 ++ src/cloud/msp/contexts/MSPProvider.tsx | 204 ++++ src/cloud/msp/contexts/TenantsProvider.tsx | 365 ++++++ src/cloud/msp/hooks/useTenantPlan.ts | 145 +++ src/cloud/msp/hooks/useTenantSubscription.ts | 51 + src/cloud/msp/interfaces/Invoice.ts | 10 + src/cloud/msp/interfaces/MSP.ts | 19 + src/cloud/msp/interfaces/Tenant.ts | 51 + src/cloud/msp/table/TenantActionCell.tsx | 126 ++ src/cloud/msp/table/TenantGroupsCell.tsx | 195 +++ src/cloud/msp/table/TenantNameCell.tsx | 77 ++ src/cloud/msp/table/TenantPeersCell.tsx | 36 + src/cloud/msp/table/TenantPlanCell.tsx | 99 ++ src/cloud/msp/table/TenantPlanCostCell.tsx | 45 + src/cloud/msp/table/TenantUsersCell.tsx | 38 + .../NotificationChannelListItem.tsx | 142 +++ .../notifications/NotificationEventTypes.tsx | 146 +++ .../notifications/NotificationProvider.tsx | 146 +++ src/cloud/notifications/NotificationTab.tsx | 191 +++ .../channels/NotificationEmailChannel.tsx | 200 ++++ .../channels/NotificationSlackChannel.tsx | 208 ++++ .../channels/NotificationSlackModal.tsx | 231 ++++ .../channels/NotificationWebhookChannel.tsx | 211 ++++ .../channels/NotificationWebhookModal.tsx | 203 ++++ .../TerminatedProxiesProvider.tsx | 183 +++ src/cloud/settings/CloudSettings.tsx | 30 + src/cloud/survey/HowDidYouHearAboutUs.tsx | 141 +++ .../traffic-events/TrafficEventSetting.tsx | 278 +++++ .../TrafficEventsConnectionTypeFilter.tsx | 35 + .../traffic-events/TrafficEventsFilter.tsx | 451 +++++++ .../TrafficEventsInboundOutboundFilter.tsx | 65 + .../TrafficEventsPeerTabContent.tsx | 480 ++++++++ .../traffic-events/TrafficEventsTable.tsx | 536 +++++++++ .../traffic-events/interfaces/TrafficEvent.ts | 81 ++ .../interfaces/TrafficEventICMP.ts | 344 ++++++ .../interfaces/TrafficEventICMPv6.ts | 95 ++ .../interfaces/TrafficEventProtocol.ts | 154 +++ .../traffic-events/misc/TrafficEventChart.tsx | 141 +++ .../misc/TrafficEventDetails.tsx | 155 +++ .../table/TrafficEventDescription.tsx | 302 +++++ .../table/TrafficEventsBytesCell.tsx | 44 + .../table/TrafficEventsDetailRow.tsx | 155 +++ .../table/TrafficEventsDirectionCell.tsx | 25 + .../table/TrafficEventsMachineCell.tsx | 350 ++++++ .../table/TrafficEventsPortCell.tsx | 123 ++ .../table/TrafficEventsReporterCell.tsx | 53 + .../table/TrafficEventsTextCell.tsx | 64 + .../table/TrafficEventsTimeCell.tsx | 45 + .../traffic-events/utils/parseAddress.ts | 34 + .../WebhookAuthenticationSettings.tsx | 128 ++ .../webhooks/WebhookGeneralTabContent.tsx | 46 + src/cloud/webhooks/WebhookHeadersInput.tsx | 154 +++ .../webhooks/WebhookHeadersTabContent.tsx | 88 ++ src/cloud/webhooks/useWebhookConfig.tsx | 167 +++ src/components/Accordion.tsx | 3 +- src/components/Breadcrumbs.tsx | 2 +- src/components/Code.tsx | 2 +- src/components/DeviceCard.tsx | 2 +- src/components/FancyToggleSwitch.tsx | 6 +- src/components/PeerGroupSelector.tsx | 28 +- src/components/PortSelector.tsx | 4 +- src/components/SegmentedTabs.tsx | 3 + src/components/SettingCard.tsx | 9 +- src/components/SidebarItem.tsx | 4 +- src/components/Tabs.tsx | 7 +- src/components/ToggleSwitch.tsx | 6 +- src/components/VersionInfo.tsx | 6 +- src/components/VerticalTabs.tsx | 4 + src/components/modal/Modal.tsx | 4 +- src/components/select/SelectDropdown.tsx | 3 + .../select/SelectDropdownSearchInput.tsx | 1 + .../SkeletonNotificationSettings.tsx | 34 + .../skeletons/SkeletonPricingTable.tsx | 42 + .../table/DataTableGlobalSearch.tsx | 1 + .../table/DataTableResetFilterButton.tsx | 1 + src/components/table/DataTableRowsPerPage.tsx | 4 +- src/components/table/TableFilters.tsx | 7 +- src/components/table/filters/RadioPicker.tsx | 3 + src/components/ui/AddGroupButton.tsx | 4 +- src/components/ui/AddPeerButton.tsx | 36 +- src/components/ui/AnnouncementBanner.tsx | 32 +- src/components/ui/FullScreenLoading.tsx | 2 +- src/components/ui/GroupBadge.tsx | 2 +- src/components/ui/HelpAndSupportButton.tsx | 4 +- src/components/ui/InputDomain.tsx | 3 +- src/components/ui/MultipleGroups.tsx | 2 +- src/components/ui/PeerCountBadge.tsx | 3 +- src/components/ui/PolicyDirection.tsx | 2 +- src/components/ui/ResourceBadge.tsx | 2 +- src/components/ui/UserDropdown.tsx | 124 +- src/contexts/AnnouncementProvider.tsx | 118 +- src/contexts/BillingProvider.tsx | 505 ++++++++ src/contexts/DialogProvider.tsx | 10 +- src/contexts/InstanceSetupProvider.tsx | 4 +- src/contexts/PermissionsProvider.tsx | 53 +- src/contexts/PoliciesProvider.tsx | 6 +- src/contexts/ReverseProxiesProvider.tsx | 2 + src/contexts/UsersProvider.tsx | 16 +- src/hooks/useIsLicensed.tsx | 46 + src/hooks/useLocalStorage.tsx | 8 +- src/hooks/useUrlTab.ts | 3 +- src/interfaces/Account.ts | 3 + src/interfaces/AccountUsageStats.ts | 6 + src/interfaces/EDR.ts | 99 ++ src/interfaces/EventStream.ts | 10 + src/interfaces/FirewallGPT.ts | 33 + src/interfaces/IdentityProvider.ts | 48 + src/interfaces/NotificationChannel.ts | 48 + src/interfaces/Peer.ts | 1 + src/interfaces/Plan.ts | 19 + src/interfaces/Subscription.ts | 31 + src/layouts/AppLayout.tsx | 3 + src/layouts/DashboardLayout.tsx | 35 +- src/layouts/Header.tsx | 7 + src/layouts/Navigation.tsx | 31 + .../access-control/AccessControlModal.tsx | 18 +- .../access-control/ssh/SSHAccessType.tsx | 4 +- .../ssh/SSHUsernameSelector.tsx | 4 +- .../table/AccessControlActionCell.tsx | 2 + .../table/AccessControlNameCell.tsx | 2 +- .../table/AccessControlTable.tsx | 17 +- .../access-tokens/AccessTokenActionCell.tsx | 2 +- .../access-tokens/CreateAccessTokenModal.tsx | 8 +- src/modules/activity/ActivityDescription.tsx | 89 +- src/modules/activity/ActivityTypeIcon.tsx | 2 + src/modules/billing/LimitsReachedModal.tsx | 141 +++ src/modules/billing/NavigationUsageInfo.tsx | 179 +++ src/modules/billing/PlanCard.tsx | 220 ++++ src/modules/billing/PlanCurrentPlan.tsx | 202 ++++ src/modules/billing/PlanIcon.tsx | 37 + src/modules/billing/PlanSuccessModal.tsx | 43 + src/modules/billing/PlansAndBillingTab.tsx | 291 +++++ src/modules/billing/Slider.tsx | 27 + .../locked-feature/LockedFeatureBadge.tsx | 84 ++ .../locked-feature/LockedFeatureContent.tsx | 168 +++ .../locked-feature/LockedFeatureInfoCard.tsx | 47 + .../locked-feature/LockedFeatureOverlay.tsx | 39 + .../locked-feature/LockedFeatureTooltip.tsx | 49 + .../billing/trial/TrialGradientCard.tsx | 57 + .../billing/trial/TrialNavigationInfoCard.tsx | 47 + .../billing/trial/TrialOrUpgradeButton.tsx | 168 +++ .../billing/trial/TrialSuccessModal.tsx | 61 + .../common-table-rows/ActiveInactiveRow.tsx | 6 +- src/modules/common-table-rows/GroupsRow.tsx | 7 +- .../dns/nameservers/NameserverModal.tsx | 28 +- .../nameservers/NameserverTemplateModal.tsx | 7 + .../table/NameserverActionCell.tsx | 3 + .../table/NameserverGroupTable.tsx | 2 + .../nameservers/table/NameserverNameCell.tsx | 1 + src/modules/dns/zones/DNSRecordModal.tsx | 9 +- src/modules/dns/zones/DNSZoneModal.tsx | 5 + .../dns/zones/records/DNSRecordActionCell.tsx | 2 + .../dns/zones/table/DNSZonesActionCell.tsx | 10 +- .../dns/zones/table/DNSZonesNameCell.tsx | 2 +- .../dns/zones/table/DNSZonesRecordsCell.tsx | 2 + .../zones/table/DNSZonesSearchDomainCell.tsx | 1 + src/modules/dns/zones/table/DNSZonesTable.tsx | 1 + .../firewall-gpt/FirewallGPTAccessForm.tsx | 178 +++ .../FirewallGPTAccessFormSuccessModal.tsx | 41 + .../FirewallGPTAnimatedMessageText.tsx | 68 ++ .../firewall-gpt/FirewallGPTAvatars.tsx | 60 + .../firewall-gpt/FirewallGPTButton.tsx | 77 ++ .../firewall-gpt/FirewallGPTChatInput.tsx | 105 ++ .../firewall-gpt/FirewallGPTExampleCards.tsx | 96 ++ .../firewall-gpt/FirewallGPTFakeLoader.tsx | 225 ++++ .../firewall-gpt/FirewallGPTHeader.tsx | 74 ++ src/modules/firewall-gpt/FirewallGPTModal.tsx | 373 ++++++ .../firewall-gpt/FirewallGPTNotApproved.tsx | 25 + .../firewall-gpt/FirewallGPTPolicyPreview.tsx | 509 ++++++++ .../FirewallGPTResponseMessage.tsx | 180 +++ .../firewall-gpt/FirewallGPTSuccessModal.tsx | 87 ++ .../firewall-gpt/FirewallGptMessage.tsx | 115 ++ src/modules/groups/EditGroupNameModal.tsx | 2 + src/modules/groups/table/GroupsActionCell.tsx | 4 +- .../integrations/EstimatedSetupTime.tsx | 21 + src/modules/integrations/IntegrationCard.tsx | 118 ++ .../integrations/IntegrationModalHeader.tsx | 56 + src/modules/integrations/edr/EDRTab.tsx | 75 ++ .../edr/crowdstrike/CrowdStrike.tsx | 152 +++ .../crowdstrike/CrowdStrikeConfiguration.tsx | 349 ++++++ .../edr/crowdstrike/CrowdStrikeRegions.tsx | 43 + .../edr/crowdstrike/CrowdStrikeSetup.tsx | 339 ++++++ .../crowdstrike/CrowdStrikeZtaScoreInput.tsx | 51 + .../edr/crowdstrike/CrowdStrikeZtaToggle.tsx | 42 + .../integrations/edr/fleetdm/FleetDM.tsx | 195 +++ .../edr/fleetdm/FleetDMConfiguration.tsx | 414 +++++++ .../edr/fleetdm/FleetDMMatchSettings.tsx | 147 +++ .../integrations/edr/fleetdm/FleetDMSetup.tsx | 478 ++++++++ .../integrations/edr/huntress/Huntress.tsx | 187 +++ .../edr/huntress/HuntressConfiguration.tsx | 425 +++++++ .../edr/huntress/HuntressMatchSettings.tsx | 103 ++ .../edr/huntress/HuntressSetup.tsx | 429 +++++++ .../integrations/edr/intune/Intune.tsx | 145 +++ .../edr/intune/IntuneConfiguration.tsx | 361 ++++++ .../integrations/edr/intune/IntuneSetup.tsx | 529 +++++++++ .../images/azure-grant-admin-conset.png | Bin 0 -> 176103 bytes .../edr/sentinel-one/SentinelOne.tsx | 216 ++++ .../sentinel-one/SentinelOneConfiguration.tsx | 402 +++++++ .../sentinel-one/SentinelOneMatchSettings.tsx | 162 +++ .../edr/sentinel-one/SentinelOneSetup.tsx | 478 ++++++++ .../edr/sentinel-one/SentinelOneUrlInput.tsx | 38 + .../integrations/edr/useIntegrations.tsx | 107 ++ .../event-streaming/EventStreamingCard.tsx | 91 ++ .../event-streaming/EventStreamingTab.tsx | 66 ++ .../event-streaming/amazon/AmazonRegions.tsx | 157 +++ .../amazon/exampleCredentials.ts | 26 + .../amazon/firehose/Firehose.tsx | 92 ++ .../amazon/firehose/FirehoseSetup.tsx | 404 +++++++ .../event-streaming/amazon/s3/S3.tsx | 90 ++ .../event-streaming/amazon/s3/S3Setup.tsx | 374 ++++++ .../event-streaming/datadog/Datadog.tsx | 90 ++ .../datadog/DatadogRegions.tsx | 42 + .../event-streaming/datadog/DatadogSetup.tsx | 281 +++++ .../generic-http/GenericHTTP.tsx | 107 ++ .../generic-http/GenericHTTPModal.tsx | 434 +++++++ .../EmbeddedIdentityProviderSelect.tsx | 132 +++ .../idp-sync/GroupPrefixHelpText.tsx | 27 + .../idp-sync/GroupPrefixInput.tsx | 106 ++ .../idp-sync/IdentityProviderCard.tsx | 103 ++ .../idp-sync/IdentityProviderTab.tsx | 102 ++ .../idp-sync/azure-ad/AzureAD.tsx | 170 +++ .../azure-ad/AzureADConfiguration.tsx | 375 ++++++ .../idp-sync/azure-ad/AzureADSetup.tsx | 521 ++++++++ .../images/azure-add-application-uri.png | Bin 0 -> 180171 bytes .../images/azure-authorize-application.png | Bin 0 -> 192345 bytes .../images/azure-grant-admin-conset.png | Bin 0 -> 175281 bytes .../azure-ad/images/azure-new-application.png | Bin 0 -> 120774 bytes .../azure-ad/images/azure-spa-uri-setup.png | Bin 0 -> 185888 bytes .../idp-sync/entra-scim/EntraSCIM.tsx | 21 + .../idp-sync/entra-scim/EntraSCIMSetup.tsx | 620 ++++++++++ .../images/entra-assign-users-groups.png | Bin 0 -> 109263 bytes .../images/entra-edit-externalid.png | Bin 0 -> 52650 bytes .../images/entra-group-attribute-mapping.png | Bin 0 -> 103897 bytes .../images/entra-provisioning-get-started.png | Bin 0 -> 207700 bytes .../images/entra-provisioning-started.png | Bin 0 -> 160107 bytes .../idp-sync/generic-scim/GenericSCIM.tsx | 210 ++++ .../generic-scim/GenericSCIMConfiguration.tsx | 352 ++++++ .../generic-scim/GenericSCIMSetup.tsx | 389 ++++++ .../google-workspace/GoogleWorkspace.tsx | 170 +++ .../GoogleWorkspaceConfiguration.tsx | 364 ++++++ .../google-workspace/GoogleWorkspaceSetup.tsx | 693 +++++++++++ .../images/google-assign-service-account.png | Bin 0 -> 36114 bytes .../images/google-edit-service-account.png | Bin 0 -> 84235 bytes .../images/google-new-admin-role.png | Bin 0 -> 29095 bytes .../images/google-privileges-review.png | Bin 0 -> 33213 bytes .../images/google-service-account-create.png | Bin 0 -> 133317 bytes .../google-service-account-privileges.png | Bin 0 -> 119961 bytes .../idp-sync/jumpcloud/Jumpcloud.tsx | 158 +++ .../jumpcloud/JumpcloudConfiguration.tsx | 338 ++++++ .../idp-sync/jumpcloud/JumpcloudSetup.tsx | 460 +++++++ .../integrations/idp-sync/okta-scim/Okta.tsx | 185 +++ .../idp-sync/okta-scim/OktaConfiguration.tsx | 336 ++++++ .../idp-sync/okta-scim/OktaSetup.tsx | 683 +++++++++++ .../images/okta-groups-assignments.png | Bin 0 -> 90222 bytes .../okta-scim/images/okta-provisioning.png | Bin 0 -> 49256 bytes .../images/okta-saml-configuration.png | Bin 0 -> 73145 bytes .../images/okta-scim-provisioning-enabled.png | Bin 0 -> 65710 bytes .../images/okta-scim-to-app-sync-enabled.png | Bin 0 -> 95592 bytes .../images/okta-sso-configuration.png | Bin 0 -> 34157 bytes .../okta-scim/images/okta-sync-groups.png | Bin 0 -> 66504 bytes .../integrations/idp-sync/useIntegrations.tsx | 76 ++ .../sso/DomainVerificationCard.tsx | 110 ++ .../sso/DomainVerificationModal.tsx | 143 +++ src/modules/integrations/sso/SSOTab.tsx | 98 ++ .../sso/oidc/OidcIntegrationCard.tsx | 170 +++ .../integrations/sso/oidc/OidcSetupModal.tsx | 218 ++++ .../sso/okta/DomainVerificationTable.tsx | 71 ++ .../sso/okta/OktaSSOIntegrationCard.tsx | 149 +++ .../integrations/sso/okta/OktaSSOSettings.tsx | 242 ++++ .../integrations/sso/okta/OktaSSOSetup.tsx | 335 ++++++ .../sso/useEnterpriseConnections.tsx | 95 ++ .../integrations/sso/useSSOConnections.tsx | 49 + src/modules/jobs/table/JobStatusCell.tsx | 2 +- src/modules/networks/NetworkModal.tsx | 4 +- .../NetworkResourceAccessControl.tsx | 1 + .../resources/NetworkResourceModal.tsx | 8 +- .../resources/ResourceExposeServiceCell.tsx | 10 +- .../resources/ResourceSingleAddressInput.tsx | 1 + .../networks/resources/ResourcesTable.tsx | 5 +- .../routing-peers/NetworkRoutingPeerModal.tsx | 15 +- .../RoutingPeerMasqueradeSwitch.tsx | 3 + .../networks/table/NetworkActionCell.tsx | 4 +- .../networks/table/NetworkResourceCell.tsx | 2 + src/modules/networks/table/NetworksTable.tsx | 1 + src/modules/onboarding/Onboarding.tsx | 21 +- src/modules/onboarding/OnboardingDemoCall.tsx | 261 ++++ src/modules/onboarding/OnboardingProvider.tsx | 7 +- src/modules/peer/PeerIssueIcon.tsx | 56 +- src/modules/peer/PeerRemoteJobsSection.tsx | 5 +- src/modules/peer/usePeerSSHPolicies.ts | 0 src/modules/peers/PeerActionCell.tsx | 119 +- src/modules/peers/PeerMultiSelect.tsx | 120 ++ src/modules/peers/PeerStatusCell.tsx | 9 +- src/modules/peers/PeersTable.tsx | 168 ++- .../table/PostureCheckTable.tsx | 241 ++-- .../posture-checks/ui/PostureCheckTab.tsx | 48 +- .../remote-access/rdp/RDPCredentialsModal.tsx | 3 +- .../rdp/ironrdp-input-handler.ts | 809 ------------- .../rdp/rdp-certificate-handler.ts | 138 ++- .../rdp/useRDPCertificateHandler.ts | 5 +- src/modules/remote-access/ssh/useSSH.ts | 7 +- src/modules/remote-access/useNetBirdClient.ts | 7 +- .../ReverseProxyAccessControlRules.tsx | 87 +- .../ReverseProxyCrowdSecIPReputation.tsx | 3 +- .../reverse-proxy/ReverseProxyHTTPTargets.tsx | 9 +- .../ReverseProxyLayer4Content.tsx | 2 + .../reverse-proxy/ReverseProxyModal.tsx | 72 +- .../ReverseProxyServiceModeSelector.tsx | 5 +- .../reverse-proxy/auth/AuthHeaderModal.tsx | 17 +- .../reverse-proxy/auth/AuthPasswordModal.tsx | 4 +- .../reverse-proxy/auth/AuthPinModal.tsx | 3 +- .../reverse-proxy/auth/AuthSSOModal.tsx | 8 +- .../reverse-proxy/clusters/ClustersModal.tsx | 15 +- .../domain/CustomDomainModal.tsx | 8 +- .../domain/CustomDomainSelector.tsx | 7 +- .../domain/CustomDomainVerificationModal.tsx | 8 +- .../domain/CustomDomainsTable.tsx | 3 + .../domain/ReverseProxyDomainInput.tsx | 6 +- .../table/ReverseProxyAccessControlCell.tsx | 15 +- .../table/ReverseProxyActionCell.tsx | 4 + .../table/ReverseProxyAuthCell.tsx | 26 +- .../table/ReverseProxyClusterCell.tsx | 12 +- .../reverse-proxy/table/ReverseProxyTable.tsx | 2 + .../ReverseProxyTargetCustomHeaders.tsx | 10 +- .../targets/ReverseProxyTargetModal.tsx | 6 + .../flat/ReverseProxyFlatTargetsTable.tsx | 4 +- .../route-group/GroupedRouteNameCell.tsx | 2 +- .../route-group/NetworkRoutesTable.tsx | 2 + src/modules/routes/RouteModal.tsx | 38 +- src/modules/settings/AuthenticationTab.tsx | 161 ++- src/modules/settings/ClientSettingsTab.tsx | 16 +- src/modules/settings/DangerZoneTab.tsx | 32 +- src/modules/settings/GroupsSettings.tsx | 8 +- .../settings/IdentityProviderModal.tsx | 1 + src/modules/settings/NetworkSettingsTab.tsx | 29 +- src/modules/settings/PermissionsTab.tsx | 2 + src/modules/setup-keys/SetupKeyActionCell.tsx | 3 + src/modules/setup-keys/SetupKeyModal.tsx | 14 +- src/modules/setup-keys/SetupKeysTable.tsx | 2 + .../setup-netbird-modal/SetupModal.tsx | 12 +- src/modules/users/ServiceUserModal.tsx | 4 +- src/modules/users/ServiceUsersTable.tsx | 4 +- src/modules/users/UserInviteModal.tsx | 17 +- src/modules/users/UserInvitesTable.tsx | 8 +- src/modules/users/UserRoleSelector.tsx | 24 +- src/modules/users/UsersTable.tsx | 22 +- .../users/table-cells/UserActionCell.tsx | 14 +- .../users/table-cells/UserNameCell.tsx | 2 +- .../users/table-cells/UserStatusCell.tsx | 16 +- src/utils/config.ts | 19 + src/utils/netbird.ts | 41 +- src/utils/version.ts | 1 - 508 files changed, 45839 insertions(+), 2188 deletions(-) create mode 100644 .github/workflows/e2e-test.yml delete mode 100644 cypress.config.ts delete mode 100644 cypress/e2e/test.cy.ts delete mode 100644 cypress/fixtures/example.json delete mode 100644 cypress/support/commands.ts delete mode 100644 cypress/support/e2e.ts delete mode 100644 cypress/tsconfig.json create mode 100644 e2e/CLAUDE.md create mode 100644 e2e/environment/.gitignore create mode 100644 e2e/environment/clean-test-env.sh create mode 100644 e2e/environment/create-test-env.sh create mode 100644 e2e/fixtures/auth/.gitkeep create mode 100644 e2e/helpers/api.ts create mode 100644 e2e/helpers/auth.ts create mode 100644 e2e/helpers/fixtures.ts create mode 100644 e2e/helpers/navigation.ts create mode 100644 e2e/helpers/reverse-proxy-l4.ts create mode 100644 e2e/helpers/utils.ts create mode 100644 e2e/playwright.config.ts create mode 100644 e2e/tests/access-control-groups.spec.ts create mode 100644 e2e/tests/access-control.spec.ts create mode 100644 e2e/tests/dns-nameservers.spec.ts create mode 100644 e2e/tests/dns-settings.spec.ts create mode 100644 e2e/tests/dns-zones.spec.ts create mode 100644 e2e/tests/edition-gating.spec.ts create mode 100644 e2e/tests/login.spec.ts create mode 100644 e2e/tests/network-routes.spec.ts create mode 100644 e2e/tests/networks.spec.ts create mode 100644 e2e/tests/reverse-proxy-crowdsec.spec.ts create mode 100644 e2e/tests/reverse-proxy-custom-domains.spec.ts create mode 100644 e2e/tests/reverse-proxy-services-https.spec.ts create mode 100644 e2e/tests/reverse-proxy-services-tcp.spec.ts create mode 100644 e2e/tests/reverse-proxy-services-tls.spec.ts create mode 100644 e2e/tests/reverse-proxy-services-udp-no-custom-ports.spec.ts create mode 100644 e2e/tests/reverse-proxy-services-udp.spec.ts create mode 100644 e2e/tests/settings-authentication.spec.ts create mode 100644 e2e/tests/settings-clients.spec.ts create mode 100644 e2e/tests/settings-groups.spec.ts create mode 100644 e2e/tests/settings-networks.spec.ts create mode 100644 e2e/tests/settings-notifications-email.spec.ts create mode 100644 e2e/tests/settings-notifications-slack.spec.ts create mode 100644 e2e/tests/settings-notifications-webhook.spec.ts create mode 100644 e2e/tests/settings-permissions.spec.ts create mode 100644 e2e/tests/setup-keys.spec.ts create mode 100644 e2e/tests/team-service-users.spec.ts create mode 100644 e2e/tests/team-users-approval-and-billing.spec.ts create mode 100644 e2e/tests/team-users.spec.ts create mode 100644 postbuild.js create mode 100644 src/app/(dashboard)/(cloud)/customers/layout.tsx create mode 100644 src/app/(dashboard)/(cloud)/customers/page.tsx create mode 100644 src/app/(dashboard)/(cloud)/integrations/layout.tsx create mode 100644 src/app/(dashboard)/(cloud)/integrations/page.tsx create mode 100644 src/app/(dashboard)/(cloud)/msp/page.tsx create mode 100644 src/app/(dashboard)/(cloud)/plans/cancel/layout.tsx create mode 100644 src/app/(dashboard)/(cloud)/plans/cancel/page.tsx create mode 100644 src/app/(dashboard)/(cloud)/plans/layout.tsx create mode 100644 src/app/(dashboard)/(cloud)/plans/page.tsx create mode 100644 src/app/(dashboard)/(cloud)/plans/success/layout.tsx create mode 100644 src/app/(dashboard)/(cloud)/plans/success/page.tsx create mode 100644 src/app/(dashboard)/(cloud)/tenants/layout.tsx create mode 100644 src/app/(dashboard)/(cloud)/tenants/page.tsx create mode 100644 src/app/(dashboard)/events/traffic/layout.tsx create mode 100644 src/app/(dashboard)/events/traffic/page.tsx create mode 100644 src/assets/avatars/jack.jpeg create mode 100644 src/assets/icons/MSPIcon.tsx create mode 100644 src/assets/integrations/aws-marketplace.svg create mode 100644 src/assets/integrations/crowdstrike.png create mode 100644 src/assets/integrations/firehose.png create mode 100644 src/assets/integrations/fleetdm.png create mode 100644 src/assets/integrations/generic-http.png create mode 100644 src/assets/integrations/generic-scim.png create mode 100644 src/assets/integrations/huntress.png create mode 100644 src/assets/integrations/intune.png create mode 100644 src/assets/integrations/jumpcloud.png create mode 100644 src/assets/integrations/keycloak.png create mode 100644 src/assets/integrations/s3.svg create mode 100644 src/assets/integrations/sentinelone.png create mode 100644 src/assets/integrations/slack.png create mode 100644 src/cloud/analytics/Hubspot.tsx create mode 100644 src/cloud/aws/AWSChoosePlan.tsx create mode 100644 src/cloud/aws/useAWSMarketplace.ts create mode 100644 src/cloud/cloud-hooks/useAuthService.ts create mode 100644 src/cloud/cloud-hooks/useCookies.ts create mode 100644 src/cloud/cloud-hooks/useDomainCategory.tsx create mode 100644 src/cloud/cloud-hooks/useExperiment.ts create mode 100644 src/cloud/cloud-hooks/useIsFeatureLocked.tsx create mode 100644 src/cloud/cloud-hooks/useTrial.tsx create mode 100644 src/cloud/contexts/NetBirdCloudProvider.tsx create mode 100644 src/cloud/distributor/DistributorAccountExistsModal.tsx create mode 100644 src/cloud/distributor/DistributorCustomerModal.tsx create mode 100644 src/cloud/distributor/DistributorDocsLink.tsx create mode 100644 src/cloud/distributor/DistributorNavigation.tsx create mode 100644 src/cloud/distributor/DistributorSubscriptionModal.tsx create mode 100644 src/cloud/distributor/DistributorTransferAccountModal.tsx create mode 100644 src/cloud/distributor/contexts/CustomersProvider.tsx create mode 100644 src/cloud/distributor/contexts/DistributorProvider.tsx create mode 100644 src/cloud/distributor/hooks/useCustomerPlan.ts create mode 100644 src/cloud/distributor/interfaces/Distributor.ts create mode 100644 src/cloud/distributor/table/CustomerActionCell.tsx create mode 100644 src/cloud/distributor/table/CustomerNameCell.tsx create mode 100644 src/cloud/distributor/table/CustomerPlanCell.tsx create mode 100644 src/cloud/distributor/table/CustomerTenantsCell.tsx create mode 100644 src/cloud/distributor/table/DistributorCustomersTable.tsx create mode 100644 src/cloud/distributor/useDistributorRedirect.tsx create mode 100644 src/cloud/edr/PeerDisapprovalReason.tsx create mode 100644 src/cloud/edr/useBypass.tsx create mode 100644 src/cloud/invoices/InvoicesTab.tsx create mode 100644 src/cloud/invoices/table/InvoicesActionCell.tsx create mode 100644 src/cloud/invoices/table/InvoicesPeriodCell.tsx create mode 100644 src/cloud/invoices/table/InvoicesTable.tsx create mode 100644 src/cloud/invoices/table/InvoicesTypeCell.tsx create mode 100644 src/cloud/mfa/AccountMFACard.tsx create mode 100644 src/cloud/mfa/AccountMFAInfoModal.tsx create mode 100644 src/cloud/mfa/AccountMFASettings.tsx create mode 100644 src/cloud/mfa/UserMFAListItem.tsx create mode 100644 src/cloud/msp/MSPAccountExistsModal.tsx create mode 100644 src/cloud/msp/MSPDomainVerificationModal.tsx create mode 100644 src/cloud/msp/MSPNavigationItem.tsx create mode 100644 src/cloud/msp/MSPSubscriptionModal.tsx create mode 100644 src/cloud/msp/MSPTenantDocsLink.tsx create mode 100644 src/cloud/msp/MSPTenantModal.tsx create mode 100644 src/cloud/msp/MSPTenantPermissionsTab.tsx create mode 100644 src/cloud/msp/MSPTenantPlanTab.tsx create mode 100644 src/cloud/msp/MSPTenantsSwitcher.tsx create mode 100644 src/cloud/msp/MSPTenantsTable.tsx create mode 100644 src/cloud/msp/MSPTransferAccountModal.tsx create mode 100644 src/cloud/msp/MSPTrialExpiredModal.tsx create mode 100644 src/cloud/msp/MSPUnlinkModal.tsx create mode 100644 src/cloud/msp/contexts/MSPProvider.tsx create mode 100644 src/cloud/msp/contexts/TenantsProvider.tsx create mode 100644 src/cloud/msp/hooks/useTenantPlan.ts create mode 100644 src/cloud/msp/hooks/useTenantSubscription.ts create mode 100644 src/cloud/msp/interfaces/Invoice.ts create mode 100644 src/cloud/msp/interfaces/MSP.ts create mode 100644 src/cloud/msp/interfaces/Tenant.ts create mode 100644 src/cloud/msp/table/TenantActionCell.tsx create mode 100644 src/cloud/msp/table/TenantGroupsCell.tsx create mode 100644 src/cloud/msp/table/TenantNameCell.tsx create mode 100644 src/cloud/msp/table/TenantPeersCell.tsx create mode 100644 src/cloud/msp/table/TenantPlanCell.tsx create mode 100644 src/cloud/msp/table/TenantPlanCostCell.tsx create mode 100644 src/cloud/msp/table/TenantUsersCell.tsx create mode 100644 src/cloud/notifications/NotificationChannelListItem.tsx create mode 100644 src/cloud/notifications/NotificationEventTypes.tsx create mode 100644 src/cloud/notifications/NotificationProvider.tsx create mode 100644 src/cloud/notifications/NotificationTab.tsx create mode 100644 src/cloud/notifications/channels/NotificationEmailChannel.tsx create mode 100644 src/cloud/notifications/channels/NotificationSlackChannel.tsx create mode 100644 src/cloud/notifications/channels/NotificationSlackModal.tsx create mode 100644 src/cloud/notifications/channels/NotificationWebhookChannel.tsx create mode 100644 src/cloud/notifications/channels/NotificationWebhookModal.tsx create mode 100644 src/cloud/reverse-proxy/TerminatedProxiesProvider.tsx create mode 100644 src/cloud/settings/CloudSettings.tsx create mode 100644 src/cloud/survey/HowDidYouHearAboutUs.tsx create mode 100644 src/cloud/traffic-events/TrafficEventSetting.tsx create mode 100644 src/cloud/traffic-events/TrafficEventsConnectionTypeFilter.tsx create mode 100644 src/cloud/traffic-events/TrafficEventsFilter.tsx create mode 100644 src/cloud/traffic-events/TrafficEventsInboundOutboundFilter.tsx create mode 100644 src/cloud/traffic-events/TrafficEventsPeerTabContent.tsx create mode 100644 src/cloud/traffic-events/TrafficEventsTable.tsx create mode 100644 src/cloud/traffic-events/interfaces/TrafficEvent.ts create mode 100644 src/cloud/traffic-events/interfaces/TrafficEventICMP.ts create mode 100644 src/cloud/traffic-events/interfaces/TrafficEventICMPv6.ts create mode 100644 src/cloud/traffic-events/interfaces/TrafficEventProtocol.ts create mode 100644 src/cloud/traffic-events/misc/TrafficEventChart.tsx create mode 100644 src/cloud/traffic-events/misc/TrafficEventDetails.tsx create mode 100644 src/cloud/traffic-events/table/TrafficEventDescription.tsx create mode 100644 src/cloud/traffic-events/table/TrafficEventsBytesCell.tsx create mode 100644 src/cloud/traffic-events/table/TrafficEventsDetailRow.tsx create mode 100644 src/cloud/traffic-events/table/TrafficEventsDirectionCell.tsx create mode 100644 src/cloud/traffic-events/table/TrafficEventsMachineCell.tsx create mode 100644 src/cloud/traffic-events/table/TrafficEventsPortCell.tsx create mode 100644 src/cloud/traffic-events/table/TrafficEventsReporterCell.tsx create mode 100644 src/cloud/traffic-events/table/TrafficEventsTextCell.tsx create mode 100644 src/cloud/traffic-events/table/TrafficEventsTimeCell.tsx create mode 100644 src/cloud/traffic-events/utils/parseAddress.ts create mode 100644 src/cloud/webhooks/WebhookAuthenticationSettings.tsx create mode 100644 src/cloud/webhooks/WebhookGeneralTabContent.tsx create mode 100644 src/cloud/webhooks/WebhookHeadersInput.tsx create mode 100644 src/cloud/webhooks/WebhookHeadersTabContent.tsx create mode 100644 src/cloud/webhooks/useWebhookConfig.tsx create mode 100644 src/components/skeletons/SkeletonNotificationSettings.tsx create mode 100644 src/components/skeletons/SkeletonPricingTable.tsx create mode 100644 src/contexts/BillingProvider.tsx create mode 100644 src/hooks/useIsLicensed.tsx create mode 100644 src/interfaces/AccountUsageStats.ts create mode 100644 src/interfaces/EDR.ts create mode 100644 src/interfaces/FirewallGPT.ts create mode 100644 src/interfaces/NotificationChannel.ts create mode 100644 src/interfaces/Plan.ts create mode 100644 src/interfaces/Subscription.ts create mode 100644 src/modules/billing/LimitsReachedModal.tsx create mode 100644 src/modules/billing/NavigationUsageInfo.tsx create mode 100644 src/modules/billing/PlanCard.tsx create mode 100644 src/modules/billing/PlanCurrentPlan.tsx create mode 100644 src/modules/billing/PlanIcon.tsx create mode 100644 src/modules/billing/PlanSuccessModal.tsx create mode 100644 src/modules/billing/PlansAndBillingTab.tsx create mode 100644 src/modules/billing/Slider.tsx create mode 100644 src/modules/billing/locked-feature/LockedFeatureBadge.tsx create mode 100644 src/modules/billing/locked-feature/LockedFeatureContent.tsx create mode 100644 src/modules/billing/locked-feature/LockedFeatureInfoCard.tsx create mode 100644 src/modules/billing/locked-feature/LockedFeatureOverlay.tsx create mode 100644 src/modules/billing/locked-feature/LockedFeatureTooltip.tsx create mode 100644 src/modules/billing/trial/TrialGradientCard.tsx create mode 100644 src/modules/billing/trial/TrialNavigationInfoCard.tsx create mode 100644 src/modules/billing/trial/TrialOrUpgradeButton.tsx create mode 100644 src/modules/billing/trial/TrialSuccessModal.tsx create mode 100644 src/modules/firewall-gpt/FirewallGPTAccessForm.tsx create mode 100644 src/modules/firewall-gpt/FirewallGPTAccessFormSuccessModal.tsx create mode 100644 src/modules/firewall-gpt/FirewallGPTAnimatedMessageText.tsx create mode 100644 src/modules/firewall-gpt/FirewallGPTAvatars.tsx create mode 100644 src/modules/firewall-gpt/FirewallGPTButton.tsx create mode 100644 src/modules/firewall-gpt/FirewallGPTChatInput.tsx create mode 100644 src/modules/firewall-gpt/FirewallGPTExampleCards.tsx create mode 100644 src/modules/firewall-gpt/FirewallGPTFakeLoader.tsx create mode 100644 src/modules/firewall-gpt/FirewallGPTHeader.tsx create mode 100644 src/modules/firewall-gpt/FirewallGPTModal.tsx create mode 100644 src/modules/firewall-gpt/FirewallGPTNotApproved.tsx create mode 100644 src/modules/firewall-gpt/FirewallGPTPolicyPreview.tsx create mode 100644 src/modules/firewall-gpt/FirewallGPTResponseMessage.tsx create mode 100644 src/modules/firewall-gpt/FirewallGPTSuccessModal.tsx create mode 100644 src/modules/firewall-gpt/FirewallGptMessage.tsx create mode 100644 src/modules/integrations/EstimatedSetupTime.tsx create mode 100644 src/modules/integrations/IntegrationCard.tsx create mode 100644 src/modules/integrations/IntegrationModalHeader.tsx create mode 100644 src/modules/integrations/edr/EDRTab.tsx create mode 100644 src/modules/integrations/edr/crowdstrike/CrowdStrike.tsx create mode 100644 src/modules/integrations/edr/crowdstrike/CrowdStrikeConfiguration.tsx create mode 100644 src/modules/integrations/edr/crowdstrike/CrowdStrikeRegions.tsx create mode 100644 src/modules/integrations/edr/crowdstrike/CrowdStrikeSetup.tsx create mode 100644 src/modules/integrations/edr/crowdstrike/CrowdStrikeZtaScoreInput.tsx create mode 100644 src/modules/integrations/edr/crowdstrike/CrowdStrikeZtaToggle.tsx create mode 100644 src/modules/integrations/edr/fleetdm/FleetDM.tsx create mode 100644 src/modules/integrations/edr/fleetdm/FleetDMConfiguration.tsx create mode 100644 src/modules/integrations/edr/fleetdm/FleetDMMatchSettings.tsx create mode 100644 src/modules/integrations/edr/fleetdm/FleetDMSetup.tsx create mode 100644 src/modules/integrations/edr/huntress/Huntress.tsx create mode 100644 src/modules/integrations/edr/huntress/HuntressConfiguration.tsx create mode 100644 src/modules/integrations/edr/huntress/HuntressMatchSettings.tsx create mode 100644 src/modules/integrations/edr/huntress/HuntressSetup.tsx create mode 100644 src/modules/integrations/edr/intune/Intune.tsx create mode 100644 src/modules/integrations/edr/intune/IntuneConfiguration.tsx create mode 100644 src/modules/integrations/edr/intune/IntuneSetup.tsx create mode 100644 src/modules/integrations/edr/intune/images/azure-grant-admin-conset.png create mode 100644 src/modules/integrations/edr/sentinel-one/SentinelOne.tsx create mode 100644 src/modules/integrations/edr/sentinel-one/SentinelOneConfiguration.tsx create mode 100644 src/modules/integrations/edr/sentinel-one/SentinelOneMatchSettings.tsx create mode 100644 src/modules/integrations/edr/sentinel-one/SentinelOneSetup.tsx create mode 100644 src/modules/integrations/edr/sentinel-one/SentinelOneUrlInput.tsx create mode 100644 src/modules/integrations/edr/useIntegrations.tsx create mode 100644 src/modules/integrations/event-streaming/EventStreamingCard.tsx create mode 100644 src/modules/integrations/event-streaming/EventStreamingTab.tsx create mode 100644 src/modules/integrations/event-streaming/amazon/AmazonRegions.tsx create mode 100644 src/modules/integrations/event-streaming/amazon/exampleCredentials.ts create mode 100644 src/modules/integrations/event-streaming/amazon/firehose/Firehose.tsx create mode 100644 src/modules/integrations/event-streaming/amazon/firehose/FirehoseSetup.tsx create mode 100644 src/modules/integrations/event-streaming/amazon/s3/S3.tsx create mode 100644 src/modules/integrations/event-streaming/amazon/s3/S3Setup.tsx create mode 100644 src/modules/integrations/event-streaming/datadog/Datadog.tsx create mode 100644 src/modules/integrations/event-streaming/datadog/DatadogRegions.tsx create mode 100644 src/modules/integrations/event-streaming/datadog/DatadogSetup.tsx create mode 100644 src/modules/integrations/event-streaming/generic-http/GenericHTTP.tsx create mode 100644 src/modules/integrations/event-streaming/generic-http/GenericHTTPModal.tsx create mode 100644 src/modules/integrations/idp-sync/EmbeddedIdentityProviderSelect.tsx create mode 100644 src/modules/integrations/idp-sync/GroupPrefixHelpText.tsx create mode 100644 src/modules/integrations/idp-sync/GroupPrefixInput.tsx create mode 100644 src/modules/integrations/idp-sync/IdentityProviderCard.tsx create mode 100644 src/modules/integrations/idp-sync/IdentityProviderTab.tsx create mode 100644 src/modules/integrations/idp-sync/azure-ad/AzureAD.tsx create mode 100644 src/modules/integrations/idp-sync/azure-ad/AzureADConfiguration.tsx create mode 100644 src/modules/integrations/idp-sync/azure-ad/AzureADSetup.tsx create mode 100644 src/modules/integrations/idp-sync/azure-ad/images/azure-add-application-uri.png create mode 100644 src/modules/integrations/idp-sync/azure-ad/images/azure-authorize-application.png create mode 100644 src/modules/integrations/idp-sync/azure-ad/images/azure-grant-admin-conset.png create mode 100644 src/modules/integrations/idp-sync/azure-ad/images/azure-new-application.png create mode 100644 src/modules/integrations/idp-sync/azure-ad/images/azure-spa-uri-setup.png create mode 100644 src/modules/integrations/idp-sync/entra-scim/EntraSCIM.tsx create mode 100644 src/modules/integrations/idp-sync/entra-scim/EntraSCIMSetup.tsx create mode 100644 src/modules/integrations/idp-sync/entra-scim/images/entra-assign-users-groups.png create mode 100644 src/modules/integrations/idp-sync/entra-scim/images/entra-edit-externalid.png create mode 100644 src/modules/integrations/idp-sync/entra-scim/images/entra-group-attribute-mapping.png create mode 100644 src/modules/integrations/idp-sync/entra-scim/images/entra-provisioning-get-started.png create mode 100644 src/modules/integrations/idp-sync/entra-scim/images/entra-provisioning-started.png create mode 100644 src/modules/integrations/idp-sync/generic-scim/GenericSCIM.tsx create mode 100644 src/modules/integrations/idp-sync/generic-scim/GenericSCIMConfiguration.tsx create mode 100644 src/modules/integrations/idp-sync/generic-scim/GenericSCIMSetup.tsx create mode 100644 src/modules/integrations/idp-sync/google-workspace/GoogleWorkspace.tsx create mode 100644 src/modules/integrations/idp-sync/google-workspace/GoogleWorkspaceConfiguration.tsx create mode 100644 src/modules/integrations/idp-sync/google-workspace/GoogleWorkspaceSetup.tsx create mode 100644 src/modules/integrations/idp-sync/google-workspace/images/google-assign-service-account.png create mode 100644 src/modules/integrations/idp-sync/google-workspace/images/google-edit-service-account.png create mode 100644 src/modules/integrations/idp-sync/google-workspace/images/google-new-admin-role.png create mode 100644 src/modules/integrations/idp-sync/google-workspace/images/google-privileges-review.png create mode 100644 src/modules/integrations/idp-sync/google-workspace/images/google-service-account-create.png create mode 100644 src/modules/integrations/idp-sync/google-workspace/images/google-service-account-privileges.png create mode 100644 src/modules/integrations/idp-sync/jumpcloud/Jumpcloud.tsx create mode 100644 src/modules/integrations/idp-sync/jumpcloud/JumpcloudConfiguration.tsx create mode 100644 src/modules/integrations/idp-sync/jumpcloud/JumpcloudSetup.tsx create mode 100644 src/modules/integrations/idp-sync/okta-scim/Okta.tsx create mode 100644 src/modules/integrations/idp-sync/okta-scim/OktaConfiguration.tsx create mode 100644 src/modules/integrations/idp-sync/okta-scim/OktaSetup.tsx create mode 100644 src/modules/integrations/idp-sync/okta-scim/images/okta-groups-assignments.png create mode 100644 src/modules/integrations/idp-sync/okta-scim/images/okta-provisioning.png create mode 100644 src/modules/integrations/idp-sync/okta-scim/images/okta-saml-configuration.png create mode 100644 src/modules/integrations/idp-sync/okta-scim/images/okta-scim-provisioning-enabled.png create mode 100644 src/modules/integrations/idp-sync/okta-scim/images/okta-scim-to-app-sync-enabled.png create mode 100644 src/modules/integrations/idp-sync/okta-scim/images/okta-sso-configuration.png create mode 100644 src/modules/integrations/idp-sync/okta-scim/images/okta-sync-groups.png create mode 100644 src/modules/integrations/idp-sync/useIntegrations.tsx create mode 100644 src/modules/integrations/sso/DomainVerificationCard.tsx create mode 100644 src/modules/integrations/sso/DomainVerificationModal.tsx create mode 100644 src/modules/integrations/sso/SSOTab.tsx create mode 100644 src/modules/integrations/sso/oidc/OidcIntegrationCard.tsx create mode 100644 src/modules/integrations/sso/oidc/OidcSetupModal.tsx create mode 100644 src/modules/integrations/sso/okta/DomainVerificationTable.tsx create mode 100644 src/modules/integrations/sso/okta/OktaSSOIntegrationCard.tsx create mode 100644 src/modules/integrations/sso/okta/OktaSSOSettings.tsx create mode 100644 src/modules/integrations/sso/okta/OktaSSOSetup.tsx create mode 100644 src/modules/integrations/sso/useEnterpriseConnections.tsx create mode 100644 src/modules/integrations/sso/useSSOConnections.tsx create mode 100644 src/modules/onboarding/OnboardingDemoCall.tsx create mode 100644 src/modules/peer/usePeerSSHPolicies.ts delete mode 100644 src/modules/remote-access/rdp/ironrdp-input-handler.ts diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 1767161..eea8c22 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -10,3 +10,10 @@ Select exactly one: Paste the PR link from https://github.com/netbirdio/docs here: https://github.com/netbirdio/docs/pull/__ + +## E2E tests +Optional: override the image tags used by the Playwright e2e workflow. +Defaults to `main` when omitted. + +management-cloud-tag: main +reverse-proxy-tag: main diff --git a/.github/workflows/build_and_push.yml b/.github/workflows/build_and_push.yml index fcf415e..b88100a 100644 --- a/.github/workflows/build_and_push.yml +++ b/.github/workflows/build_and_push.yml @@ -7,17 +7,24 @@ on: - "**" pull_request: +# Cancel in-progress runs on the same ref (PR or branch) when a new commit +# arrives, so we don't waste CI building superseded commits. +concurrency: + group: build-and-push-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + env: - IMAGE_NAME: netbirdio/dashboard + DOCKERHUB_IMAGE: netbirdio/dashboard + GHCR_IMAGE: ghcr.io/netbirdio/dashboard-cloud jobs: build_n_push: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v5 - name: setup-node - uses: actions/setup-node@v3 + uses: actions/setup-node@v5 with: node-version: '20' cache: 'npm' @@ -69,25 +76,43 @@ jobs: NEXT_PUBLIC_DASHBOARD_VERSION: ${{ steps.version.outputs.version }} - name: Set up QEMU - uses: docker/setup-qemu-action@v2 + uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 + uses: docker/setup-buildx-action@v3 - name: Docker meta id: meta - uses: docker/metadata-action@v4 + uses: docker/metadata-action@v5 with: - images: ${{ env.IMAGE_NAME }} - - - name: Login to DockerHub - uses: docker/login-action@v2 + images: | + ${{ env.DOCKERHUB_IMAGE }} + ${{ env.GHCR_IMAGE }} + flavor: | + latest=false + tags: | + type=schedule + type=ref,event=branch + type=ref,event=tag + type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/') }} + type=ref,event=pr + type=sha + + - name: Log in to Docker Hub + uses: docker/login-action@v3 with: username: ${{ secrets.NB_DOCKER_USER }} password: ${{ secrets.NB_DOCKER_TOKEN }} - - - name: Docker build and push - uses: docker/build-push-action@v3 + + - name: Log in to the GitHub Container registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push Docker image + uses: docker/build-push-action@v5 with: context: . file: docker/Dockerfile @@ -95,3 +120,7 @@ jobs: platforms: linux/amd64,linux/arm64,linux/arm tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + + - run: | + echo '### Pushed tags' >> $GITHUB_STEP_SUMMARY + echo '${{ steps.meta.outputs.tags }}' >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml new file mode 100644 index 0000000..8bf45fc --- /dev/null +++ b/.github/workflows/e2e-test.yml @@ -0,0 +1,162 @@ +name: Playwright E2E Tests +on: + push: + branches: [main] + pull_request: + # `edited` is included so that updating the PR description (e.g. to set + # `management-cloud-tag: ` or `reverse-proxy-tag: `) re-triggers + # the e2e run with the new tag. + types: [opened, synchronize, reopened, edited] + workflow_dispatch: + inputs: + management-cloud-tag: + description: 'Management Cloud image tag' + required: true + type: string + default: 'main' + reverse-proxy-tag: + description: 'Reverse Proxy image tag' + required: true + type: string + default: 'main' + +env: + REGISTRY: ghcr.io + +jobs: + playwright-run: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v5 + with: + ref: ${{ github.event.pull_request.head.sha || github.ref }} + fetch-depth: 0 + + - name: setup-node + uses: actions/setup-node@v5 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm install + + - name: Install Playwright browsers + run: npx playwright install --with-deps chromium + + - name: Log in to the Container registry + uses: docker/login-action@v4 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.CI_DOCKER_PULL_GITHUB_TOKEN }} + + - run: echo '{}' > .local-config.json + + - name: Install jq + run: sudo apt-get install jq + + - name: Resolve management-cloud image tag + id: management_tag + env: + INPUT_TAG: ${{ inputs.management-cloud-tag }} + PR_BODY: ${{ github.event.pull_request.body }} + run: | + # Use workflow_dispatch input if provided, otherwise parse PR body. + # Falls back to `main` when not specified. + if [ -n "$INPUT_TAG" ]; then + TAG="$INPUT_TAG" + else + TAG=$(printf '%s' "$PR_BODY" \ + | grep -iE '^[[:space:]]*management-cloud-tag:[[:space:]]*[A-Za-z0-9._-]+[[:space:]]*$' \ + | head -n1 \ + | sed -E 's/^[[:space:]]*[Mm]anagement-cloud-tag:[[:space:]]*([A-Za-z0-9._-]+)[[:space:]]*$/\1/' \ + || true) + if [ -z "$TAG" ]; then + TAG="main" + fi + fi + echo "Using management-cloud tag: $TAG" + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + + - name: Resolve reverse-proxy image tag + id: reverse_proxy_tag + env: + INPUT_TAG: ${{ inputs.reverse-proxy-tag }} + PR_BODY: ${{ github.event.pull_request.body }} + run: | + if [ -n "$INPUT_TAG" ]; then + TAG="$INPUT_TAG" + else + TAG=$(printf '%s' "$PR_BODY" \ + | grep -iE '^[[:space:]]*reverse-proxy-tag:[[:space:]]*[A-Za-z0-9._-]+[[:space:]]*$' \ + | head -n1 \ + | sed -E 's/^[[:space:]]*[Rr]everse-proxy-tag:[[:space:]]*([A-Za-z0-9._-]+)[[:space:]]*$/\1/' \ + || true) + if [ -z "$TAG" ]; then + TAG="main" + fi + fi + echo "Using reverse-proxy tag: $TAG" + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + + - name: Setup test environment + env: + MANAGEMENT_IMAGE_TAG: ${{ steps.management_tag.outputs.tag }} + REVERSE_PROXY_IMAGE_TAG: ${{ steps.reverse_proxy_tag.outputs.tag }} + run: cd ./e2e/environment && bash create-test-env.sh + + - name: Run Playwright tests + id: playwright + run: | + set -o pipefail + npm run test:ci 2>&1 | tee playwright-output.log + + - name: Append Playwright summary to job summary + if: always() && hashFiles('e2e/test-results/results.json') != '' + run: | + if [ -f e2e/test-results/results.json ]; then + passed=$(jq '.stats.expected // 0' e2e/test-results/results.json) + failed=$(jq '.stats.unexpected // 0' e2e/test-results/results.json) + skipped=$(jq '.stats.skipped // 0' e2e/test-results/results.json) + duration=$(jq '.stats.duration // 0' e2e/test-results/results.json) + { + echo '### Playwright results' + echo '' + echo "| Passed | Failed | Skipped | Duration |" + echo "|--------|--------|---------|----------|" + echo "| $passed | $failed | $skipped | ${duration}ms |" + } >> "$GITHUB_STEP_SUMMARY" + fi + + - name: Collect container logs + if: failure() + run: | + cd e2e/environment + docker compose logs management --tail=500 --no-color > management.log 2>&1 || true + docker compose logs reverse-proxy --tail=500 --no-color > reverse-proxy.log 2>&1 || true + + - uses: actions/upload-artifact@v7 + if: ${{ !cancelled() }} + with: + name: playwright-report + path: e2e/playwright-report/ + + - uses: actions/upload-artifact@v7 + if: ${{ failure() }} + with: + name: playwright-traces + path: e2e/test-results/ + + - uses: actions/upload-artifact@v7 + if: ${{ failure() }} + with: + name: management-logs + path: e2e/environment/management.log + + - uses: actions/upload-artifact@v7 + if: ${{ failure() }} + with: + name: reverse-proxy-logs + path: e2e/environment/reverse-proxy.log diff --git a/.gitignore b/.gitignore index 86420ac..57bccfe 100644 --- a/.gitignore +++ b/.gitignore @@ -36,9 +36,14 @@ yarn-error.log* next-env.d.ts # config -.local-config.json +.local*config*.json .test-config.json -cypress.env.json +e2e/playwright.env.json +e2e/fixtures/auth/*.json +e2e/test-results/ +e2e/playwright-report/ +/test-results/ +/playwright-report/ .configs/.local-config.zitadel.json .configs/.staging-config.json .configs/.temp-config.json diff --git a/AUTHORS b/AUTHORS index 27eb796..565d2e6 100644 --- a/AUTHORS +++ b/AUTHORS @@ -1,3 +1,3 @@ Mikhail Bragin (https://github.com/braginini) Maycon Santos (https://github.com/mlsmaycon) -Wiretrustee UG (haftungsbeschränkt) \ No newline at end of file +NetBird GmbH \ No newline at end of file diff --git a/config.json b/config.json index 021bbfd..8976b53 100644 --- a/config.json +++ b/config.json @@ -14,5 +14,13 @@ "hotjarTrackID": "$NETBIRD_HOTJAR_TRACK_ID", "googleAnalyticsID": "$NETBIRD_GOOGLE_ANALYTICS_ID", "googleTagManagerID": "$NETBIRD_GOOGLE_TAG_MANAGER_ID", - "wasmPath": "$NETBIRD_WASM_PATH" + "authServiceUrl": "$NETBIRD_AUTH_SERVICE_URL", + "wasmPath": "$NETBIRD_WASM_PATH", + "licensed": "$NETBIRD_LICENSED", + "cloud": "$NETBIRD_CLOUD", + "hubspotPortalId": "$NETBIRD_HUBSPOT_PORTAL_ID", + "hubspotSignupFormId": "$NETBIRD_HUBSPOT_SIGNUP_FORM_ID", + "hubspotOnboardingFormId": "$NETBIRD_HUBSPOT_ONBOARDING_FORM_ID", + "hubspotSurveyFormId": "$NETBIRD_HUBSPOT_SURVEY_FORM_ID", + "analyticsExcludedEmails": "$NETBIRD_ANALYTICS_EXCLUDED_EMAILS" } diff --git a/cypress.config.ts b/cypress.config.ts deleted file mode 100644 index b75915a..0000000 --- a/cypress.config.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { defineConfig } from "cypress"; - -export default defineConfig({ - e2e: { - baseUrl: "http://localhost:3000", - }, - component: { - devServer: { - framework: "next", - bundler: "webpack", - }, - }, - viewportWidth: 1920, - viewportHeight: 1080, -}); diff --git a/cypress/e2e/test.cy.ts b/cypress/e2e/test.cy.ts deleted file mode 100644 index 5e3214f..0000000 --- a/cypress/e2e/test.cy.ts +++ /dev/null @@ -1,13 +0,0 @@ -describe("Click all tabs in peer modal", () => { - it("passes", () => { - cy.visit("/install"); - cy.get("div").contains("Linux").click(); - cy.get("[data-cy=copy-to-clipboard]").click(); - cy.get("div").contains("Windows").click(); - cy.get("[data-cy=copy-to-clipboard]").click(); - cy.get("div").contains("Android").click(); - cy.get("[data-cy=copy-to-clipboard]").click(); - cy.get("div").contains("Docker").click(); - cy.get("[data-cy=copy-to-clipboard]").click(); - }); -}); diff --git a/cypress/fixtures/example.json b/cypress/fixtures/example.json deleted file mode 100644 index 02e4254..0000000 --- a/cypress/fixtures/example.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "name": "Using fixtures to represent data", - "email": "hello@cypress.io", - "body": "Fixtures are a great way to mock data for responses to routes" -} diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts deleted file mode 100644 index 698b01a..0000000 --- a/cypress/support/commands.ts +++ /dev/null @@ -1,37 +0,0 @@ -/// -// *********************************************** -// This example commands.ts shows you how to -// create various custom commands and overwrite -// existing commands. -// -// For more comprehensive examples of custom -// commands please read more here: -// https://on.cypress.io/custom-commands -// *********************************************** -// -// -// -- This is a parent command -- -// Cypress.Commands.add('login', (email, password) => { ... }) -// -// -// -- This is a child command -- -// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... }) -// -// -// -- This is a dual command -- -// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... }) -// -// -// -- This will overwrite an existing command -- -// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) -// -// declare global { -// namespace Cypress { -// interface Chainable { -// login(email: string, password: string): Chainable -// drag(subject: string, options?: Partial): Chainable -// dismiss(subject: string, options?: Partial): Chainable -// visit(originalFn: CommandOriginalFn, url: string, options: Partial): Chainable -// } -// } -// } \ No newline at end of file diff --git a/cypress/support/e2e.ts b/cypress/support/e2e.ts deleted file mode 100644 index f80f74f..0000000 --- a/cypress/support/e2e.ts +++ /dev/null @@ -1,20 +0,0 @@ -// *********************************************************** -// This example support/e2e.ts is processed and -// loaded automatically before your test files. -// -// This is a great place to put global configuration and -// behavior that modifies Cypress. -// -// You can change the location of this file or turn off -// automatically serving support files with the -// 'supportFile' configuration option. -// -// You can read more here: -// https://on.cypress.io/configuration -// *********************************************************** - -// Import commands.js using ES2015 syntax: -import './commands' - -// Alternatively you can use CommonJS syntax: -// require('./commands') \ No newline at end of file diff --git a/cypress/tsconfig.json b/cypress/tsconfig.json deleted file mode 100644 index 230dbb5..0000000 --- a/cypress/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "compilerOptions": { - "target": "es5", - "lib": ["es5", "dom"], - "baseUrl": "http://localhost:3000", - "types": ["cypress", "node"], - }, - "include": ["**/*.ts"] -} \ No newline at end of file diff --git a/docker/default.conf b/docker/default.conf index 4549f93..131d29c 100644 --- a/docker/default.conf +++ b/docker/default.conf @@ -14,14 +14,24 @@ server { location / { try_files $uri $uri.html $uri/ =404; - add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0"; + add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header Content-Security-Policy "default-src 'self' 'unsafe-inline' 'unsafe-eval' data: blob: ws: wss: https:;" always; + add_header Last-Modified ""; expires off; } error_page 404 /404.html; location = /404.html { internal; - add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0"; + add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header Content-Security-Policy "default-src 'self' 'unsafe-inline' 'unsafe-eval' data: blob: ws: wss: https:;" always; + add_header Last-Modified ""; expires off; } } \ No newline at end of file diff --git a/docker/init_react_envs.sh b/docker/init_react_envs.sh index d1c5a18..7a666d6 100644 --- a/docker/init_react_envs.sh +++ b/docker/init_react_envs.sh @@ -61,12 +61,95 @@ export NETBIRD_GOOGLE_ANALYTICS_ID=${NETBIRD_GOOGLE_ANALYTICS_ID} export NETBIRD_GOOGLE_TAG_MANAGER_ID=${NETBIRD_GOOGLE_TAG_MANAGER_ID} export NETBIRD_TOKEN_SOURCE=${NETBIRD_TOKEN_SOURCE:-accessToken} export NETBIRD_DRAG_QUERY_PARAMS=${NETBIRD_DRAG_QUERY_PARAMS:-false} +export NETBIRD_AUTH_SERVICE_URL=${NETBIRD_AUTH_SERVICE_URL} export NETBIRD_WASM_PATH=${NETBIRD_WASM_PATH} +export NETBIRD_CSP=${NETBIRD_CSP} +export NETBIRD_LICENSED=${NETBIRD_LICENSED:-false} +export NETBIRD_CLOUD=${NETBIRD_CLOUD:-false} +export NETBIRD_HUBSPOT_PORTAL_ID=${NETBIRD_HUBSPOT_PORTAL_ID} +export NETBIRD_HUBSPOT_SIGNUP_FORM_ID=${NETBIRD_HUBSPOT_SIGNUP_FORM_ID} +export NETBIRD_HUBSPOT_ONBOARDING_FORM_ID=${NETBIRD_HUBSPOT_ONBOARDING_FORM_ID} +export NETBIRD_HUBSPOT_SURVEY_FORM_ID=${NETBIRD_HUBSPOT_SURVEY_FORM_ID} +export NETBIRD_ANALYTICS_EXCLUDED_EMAILS=${NETBIRD_ANALYTICS_EXCLUDED_EMAILS} echo "NetBird latest version: ${NETBIRD_LATEST_VERSION}" +# Build CSP +FIRST_PARTY_CSP="pkgs.netbird.io" +FIRST_PARTY_CSP_CONNECT_SRC="wss://*.netbird.io" +THIRD_PARTY_CSP="*.licdn.com *.linkedin.com *.vector.co *.sibforms.com *.hotjar.com *.hotjar.io *.redditstatic.com pixel-config.reddit.com *.clarity.ms c.bing.com *.microsoft.com googleads.g.doubleclick.net pagead2.googlesyndication.com www.google.com www.googleadservices.com *.google-analytics.com *.googletagmanager.com analytics.google.com *.hubapi.com *.hs-banner.com *.hubspot.com *.hubspot.net js.hs-analytics.com *.hsforms.net *.hscollectedforms.net *.hs-analytics.net *.hsforms.com track.hubspot.com *.hsadspixel.net static.hsappstatic.net" +THIRD_PARTY_CSP_CONNECT_SRC="https://api.github.com/repos/netbirdio/netbird/releases/latest https://raw.githubusercontent.com/netbirdio/dashboard/ wss://ws.hotjar.com" +THIRD_PARTY_CSP_SCRIPT_SRC="'sha256-7knV6EIjKUvCpYWE2rCYx8dYV2WCNb2bpTuitFXzBcA=' *.hs-scripts.com" + +CSP_DOMAINS="" +CSP_DOMAINS_CONNECT_SRC="" + +if [[ -n "${NETBIRD_CSP}" ]]; then + CSP_DOMAINS="$CSP_DOMAINS $NETBIRD_CSP" +fi + +# Add AUTH_AUTHORITY to CSP +if [[ -n "${AUTH_AUTHORITY}" ]]; then + CSP_DOMAINS="$CSP_DOMAINS $AUTH_AUTHORITY" +fi + +# Add AUTH_AUDIENCE to CSP +if [[ -n "${AUTH_AUDIENCE}" && ("${AUTH_AUDIENCE}" == *"http://"* || "${AUTH_AUDIENCE}" == *"https://"*) ]]; then + CSP_DOMAINS="$CSP_DOMAINS $AUTH_AUDIENCE" +fi + +# Add NETBIRD_AUTH_SERVICE_URL to CSP +if [[ -n "${NETBIRD_AUTH_SERVICE_URL}" ]]; then + CSP_DOMAINS="$CSP_DOMAINS $NETBIRD_AUTH_SERVICE_URL" +fi + +# Add NETBIRD_MGMT_API_ENDPOINT to CSP +if [[ -n "${NETBIRD_MGMT_API_ENDPOINT}" ]]; then + MGMT_DOMAIN=$(echo "$NETBIRD_MGMT_API_ENDPOINT" | sed -E 's|https?://||' | cut -d'/' -f1 | cut -d':' -f1) + if [[ -n "$MGMT_DOMAIN" ]]; then + if [[ "$NETBIRD_MGMT_API_ENDPOINT" == https://* ]]; then + CSP_DOMAINS="$CSP_DOMAINS $NETBIRD_MGMT_API_ENDPOINT" + CSP_DOMAINS_CONNECT_SRC="$CSP_DOMAINS_CONNECT_SRC wss://$MGMT_DOMAIN" + elif [[ "$NETBIRD_MGMT_API_ENDPOINT" == http://* ]]; then + CSP_DOMAINS="$CSP_DOMAINS $NETBIRD_MGMT_API_ENDPOINT" + CSP_DOMAINS_CONNECT_SRC="$CSP_DOMAINS_CONNECT_SRC ws://$MGMT_DOMAIN" + fi + fi +fi + +# Add LETSENCRYPT_DOMAIN to CSP +if [[ -n "${LETSENCRYPT_DOMAIN}" ]]; then + if [[ "$LETSENCRYPT_DOMAIN" == *"localhost"* ]]; then + CSP_DOMAINS="$CSP_DOMAINS http://$LETSENCRYPT_DOMAIN" + CSP_DOMAINS_CONNECT_SRC="$CSP_DOMAINS_CONNECT_SRC ws://$LETSENCRYPT_DOMAIN" + else + CSP_DOMAINS="$CSP_DOMAINS https://$LETSENCRYPT_DOMAIN" + CSP_DOMAINS_CONNECT_SRC="$CSP_DOMAINS_CONNECT_SRC wss://$LETSENCRYPT_DOMAIN" + fi +fi + +CSP_CONNECT_SRC="$CSP_DOMAINS $CSP_DOMAINS_CONNECT_SRC $FIRST_PARTY_CSP $FIRST_PARTY_CSP_CONNECT_SRC $THIRD_PARTY_CSP $THIRD_PARTY_CSP_CONNECT_SRC" +CSP_FRAME_SRC="$CSP_DOMAINS $FIRST_PARTY_CSP $THIRD_PARTY_CSP" +CSP_SCRIPT_SRC="$CSP_DOMAINS $FIRST_PARTY_CSP $THIRD_PARTY_CSP $THIRD_PARTY_CSP_SCRIPT_SRC" + +# Remove duplicates +CSP_CONNECT_SRC=$(echo $CSP_CONNECT_SRC | tr ' ' '\n' | sort -u | tr '\n' ' ' | sed 's/ $//') +CSP_FRAME_SRC=$(echo $CSP_FRAME_SRC | tr ' ' '\n' | sort -u | tr '\n' ' ' | sed 's/ $//') +CSP_SCRIPT_SRC=$(echo $CSP_SCRIPT_SRC | tr ' ' '\n' | sort -u | tr '\n' ' ' | sed 's/ $//') + +# Update CSP in nginx config +CSP_POLICY="default-src 'none'; connect-src 'self' $CSP_CONNECT_SRC; frame-src 'self' $CSP_FRAME_SRC; script-src 'self' 'wasm-unsafe-eval' $CSP_SCRIPT_SRC; font-src 'self'; img-src * data:; manifest-src 'self'; style-src 'self' 'unsafe-inline'; frame-ancestors 'self'; base-uri 'self'; form-action 'self'; upgrade-insecure-requests;" +CSP_HEADER="add_header Content-Security-Policy \"$CSP_POLICY\" always;" + +echo "CSP header: $CSP_HEADER" + +# Replace CSP header in nginx config +sed -i "s|add_header Content-Security-Policy \"[^\"]*\" always;|$CSP_HEADER|g" /etc/nginx/http.d/default.conf || { + echo "Failed to replace CSP header" +} + # replace ENVs in the config -ENV_STR="\$\$USE_AUTH0 \$\$AUTH_AUDIENCE \$\$AUTH_AUTHORITY \$\$AUTH_CLIENT_ID \$\$AUTH_CLIENT_SECRET \$\$AUTH_SUPPORTED_SCOPES \$\$NETBIRD_MGMT_API_ENDPOINT \$\$NETBIRD_MGMT_GRPC_API_ENDPOINT \$\$NETBIRD_HOTJAR_TRACK_ID \$\$NETBIRD_GOOGLE_ANALYTICS_ID \$\$NETBIRD_GOOGLE_TAG_MANAGER_ID \$\$AUTH_REDIRECT_URI \$\$AUTH_SILENT_REDIRECT_URI \$\$NETBIRD_TOKEN_SOURCE \$\$NETBIRD_DRAG_QUERY_PARAMS \$\$NETBIRD_WASM_PATH" +ENV_STR="\$\$USE_AUTH0 \$\$AUTH_AUDIENCE \$\$AUTH_AUTHORITY \$\$AUTH_CLIENT_ID \$\$AUTH_CLIENT_SECRET \$\$AUTH_SUPPORTED_SCOPES \$\$NETBIRD_MGMT_API_ENDPOINT \$\$NETBIRD_MGMT_GRPC_API_ENDPOINT \$\$NETBIRD_HOTJAR_TRACK_ID \$\$NETBIRD_GOOGLE_ANALYTICS_ID \$\$NETBIRD_GOOGLE_TAG_MANAGER_ID \$\$AUTH_REDIRECT_URI \$\$AUTH_SILENT_REDIRECT_URI \$\$NETBIRD_TOKEN_SOURCE \$\$NETBIRD_DRAG_QUERY_PARAMS \$\$NETBIRD_AUTH_SERVICE_URL \$\$NETBIRD_WASM_PATH \$\$NETBIRD_LICENSED \$\$NETBIRD_CLOUD \$\$NETBIRD_HUBSPOT_PORTAL_ID \$\$NETBIRD_HUBSPOT_SIGNUP_FORM_ID \$\$NETBIRD_HUBSPOT_ONBOARDING_FORM_ID \$\$NETBIRD_HUBSPOT_SURVEY_FORM_ID \$\$NETBIRD_ANALYTICS_EXCLUDED_EMAILS" OIDC_TRUSTED_DOMAINS="/usr/share/nginx/html/OidcTrustedDomains.js" envsubst "$ENV_STR" < "$OIDC_TRUSTED_DOMAINS".tmpl > "$OIDC_TRUSTED_DOMAINS" diff --git a/e2e/CLAUDE.md b/e2e/CLAUDE.md new file mode 100644 index 0000000..76a3ba8 --- /dev/null +++ b/e2e/CLAUDE.md @@ -0,0 +1,233 @@ +# Playwright E2E Testing Guide + +Complete reference for writing, running, and debugging Playwright E2E tests in the NetBird Dashboard. + +## Philosophy + +Tests simulate real user behavior: navigate via sidebar, click buttons, type into inputs, verify outcomes on screen. Use `{ force: true }` for Radix modal pointer-events issues. + +## Setup & Running + +```bash +npm run test:setup # Create docker-based test environment with Zitadel +npm run test:dev # Start app in test mode on http://localhost:1337 +npm run test # Run all e2e tests headless +npm run test:ui # Open Playwright interactive UI +npx playwright test --config=e2e/playwright.config.ts tests/networks.spec.ts # Single spec +npm run test:clean # Tear down test environment +``` + +Config: `e2e/playwright.config.ts` (baseURL: `http://localhost:1337`). Auth: `e2e/playwright.env.json` (gitignored). + +### Config Details + +- `fullyParallel: false` — tests run sequentially within each spec +- Workers: 2 in CI, 4 locally +- Retries: 1 +- Viewport: 1920x1080 +- Timeouts: action 10s, navigation 15s +- On failure: screenshot, trace, video retained + +## File Structure + +``` +e2e/ + playwright.config.ts + helpers/ + fixtures.ts # dashboardAsOwner / dashboardAsUser fixtures + auth.ts # loginToApp(), navigateTo() + navigation.ts # visitByNavigation() + utils.ts # generateRandomName(), clearScrollLock() + api.ts # Direct REST API helpers (list/delete for all entities) + reverse-proxy-l4.ts # Shared L4 reverse proxy helpers + fixtures/auth/ # Generated storageState files (gitignored) + environment/ # Docker compose, setup/teardown scripts + tests/ + login.spec.ts # Auth setup (login both users, save storageState) + *.spec.ts # Test specs +``` + +## Architecture + +Auth is handled by `login.spec.ts`, which runs as a separate Playwright project (`"login"`) that all other tests depend on via `dependencies: ["login"]` in the config. It logs in both users and saves Zitadel session cookies to `fixtures/auth/`. If auth files already exist, login is skipped. Each test file that modifies shared state (e.g., user roles) must restore it before finishing. + +## Authentication + +Two test users authenticated via the `login` project, saved as `storageState`: + +| User | File | Role | Usage | +|------|------|------|-------| +| owner | `fixtures/auth/owner.json` | Owner | Default for all tests | +| user | `fixtures/auth/user.json` | User (changeable) | Role-based testing | + +### Custom Fixtures (`helpers/fixtures.ts`) + +Tests use custom fixtures instead of raw `page`: + +```typescript +import { test, expect } from "../helpers/fixtures"; + +test("example", async ({ dashboardAsOwner: page }) => { + // Pre-authenticated as owner, reused across worker +}); + +test("multi-user", async ({ dashboardAsUser: page }) => { + // Pre-authenticated as user +}); +``` + +- `dashboardAsOwner` — Pre-authenticated Page for the owner user (worker-scoped, reused across tests) +- `dashboardAsUser` — Pre-authenticated Page for the user user (worker-scoped) + +For multi-context scenarios (e.g., approval/billing tests), create a new browser context directly: + +```typescript +const context = await browser.newContext({ storageState: "e2e/fixtures/auth/user.json" }); +const page = await context.newPage(); +``` + +## Helpers Reference + +### `auth.ts` +- **`loginToApp(page, user?)`** — Full Zitadel OIDC login flow. Handles app ready, setup modal, approval pending, onboarding, account selection, and login form states. +- **`navigateTo(page, path)`** — `page.goto(path)` + dismisses setup modal if present + clears scroll-lock. + +### `navigation.ts` +- **`visitByNavigation(page, navText)`** — Clicks sidebar items by exact text via `left-navigation-item` testid. + +### `utils.ts` +- **`generateRandomName(prefix?)`** — Returns `prefix` + 7 random alphanumeric chars. +- **`clearScrollLock(page)`** — Removes Radix artifacts: `data-scroll-locked`, `pointer-events: none`, stale overlay divs. + +### `api.ts` +Direct REST API helpers that extract Bearer tokens from intercepted responses. Used for cleanup (deleting test artifacts by prefix). Covers: groups, networks, policies, routes, setup keys, DNS zones, nameserver groups, notification channels, reverse proxy services, users. + +Pattern: `listX(page)` / `deleteXById(page, id)` / `deleteXByPrefix(page, prefix)` + +### `reverse-proxy-l4.ts` +Shared helpers for TCP/TLS/UDP reverse proxy service tests: +- **`createNetwork(page)`** — Creates network, returns name +- **`addResource(page, networkName, address)`** — Adds resource to a network +- **`selectL4Resource(page, resourceName)`** — Selects resource in L4 target dropdown +- **`addAccessControlRules(page)`** / **`removeAllAccessControlRules(page)`** — Manages standard test rules +- **`resetServiceFilters(page)`** — Clicks "Reset Filters & Search" button if visible +- **`openServiceEdit(page, subdomain)`** — Navigates to services, resets filters, opens edit modal +- **`deleteService(page, subdomain)`** — Deletes service via action dropdown +- **`saveServiceEdit(page)`** — Saves with "No Protection" confirmation handling +- **`deleteNetwork(page, networkName)`** — Navigates to networks and deletes by name + +## Writing Tests + +### Standard Structure + +```typescript +import { test, expect } from "../helpers/fixtures"; +import { navigateTo } from "../helpers/auth"; +import { generateRandomName } from "../helpers/utils"; + +test.describe.serial("Feature Name", () => { + test("Should create an item", async ({ dashboardAsOwner: page }) => { + await navigateTo(page, "/feature-page"); + const name = generateRandomName("prefix-"); + // ... create item + }); + + test("Should delete the item", async ({ dashboardAsOwner: page }) => { + // ... cleanup + }); +}); +``` + +### Key Patterns + +**Selectors** — Always use `data-testid` via `page.getByTestId()`: +```typescript +page.getByTestId("group-name-input") // [data-testid="group-name-input"] +page.getByTestId("confirmation.confirm") // Confirmation dialogs +``` + +**Text matching:** +```typescript +page.getByText("Some text") +page.locator("tr").filter({ hasText: name }) +``` + +**Assertions:** +```typescript +await expect(locator).toBeVisible() +await expect(locator).not.toBeVisible() +await expect(locator).toHaveAttribute("data-state", "checked") +await expect(locator).toContainText("text") +``` + +**Form inputs:** +```typescript +await input.fill("text") // Clears and types +await input.press("Enter") +await input.press("Escape") +``` + +**Radix modal workaround:** +```typescript +await button.click({ force: true }); // Force click, bypasses pointer-events checks +``` + +**Waiting for API responses:** +```typescript +const responsePromise = page.waitForResponse( + resp => resp.url().includes("/api/...") && resp.request().method() === "POST", + { timeout: 30_000 }, +); +await page.getByTestId("submit").click(); +const response = await responsePromise; +expect([200, 201]).toContain(response.status()); +``` + +**Cleanup with API helpers:** +```typescript +import { deleteGroupsByPrefix, deleteServicesByPrefix } from "../helpers/api"; + +// At the start of a test or in cleanup +await deleteServicesByPrefix(page, "my-prefix-"); +await deleteGroupsByPrefix(page, "my-prefix-"); +``` + +### Sidebar Navigation + +```typescript +await visitByNavigation(page, "Access Control"); // Expand parent +await visitByNavigation(page, "Policies"); // Click child +``` + +| Parent | Children | +|--------|----------| +| Access Control | Policies, Groups, Posture Checks | +| Team | Users, Service Users | +| DNS | Nameservers, Zones, DNS Settings | +| Reverse Proxy | Custom Domains, Services | + +## Test Coverage + +| Area | Spec Files | Tag | +|------|-----------|-----| +| Access Control | `access-control.spec.ts`, `access-control-groups.spec.ts` | `@access-control` | +| DNS | `dns-zones.spec.ts`, `dns-nameservers.spec.ts`, `dns-settings.spec.ts` | `@dns` | +| Networks | `networks.spec.ts`, `network-routes.spec.ts` | `@network` | +| Reverse Proxy | `reverse-proxy-services-https.spec.ts`, `reverse-proxy-services-tcp.spec.ts`, `reverse-proxy-services-tls.spec.ts`, `reverse-proxy-services-udp.spec.ts`, `reverse-proxy-custom-domains.spec.ts` | `@reverse-proxy` | +| Settings | `settings-authentication.spec.ts`, `settings-clients.spec.ts`, `settings-groups.spec.ts`, `settings-networks.spec.ts`, `settings-permissions.spec.ts` | `@settings` | +| Notifications | `settings-notifications-email.spec.ts`, `settings-notifications-slack.spec.ts`, `settings-notifications-webhook.spec.ts` | `@notifications` | +| Team | `team-users.spec.ts`, `team-service-users.spec.ts`, `team-users-approval-and-billing.spec.ts` | `@team` | +| Setup Keys | `setup-keys.spec.ts` | `@setup-keys` | + +## Debugging + +1. `e2e/test-results/` — traces and screenshots on failure +2. `npx playwright show-report` — open the HTML report +3. `npm run test:ui` — interactive mode with step-by-step execution +4. `npx playwright test --config=e2e/playwright.config.ts --debug tests/` — debugger mode + +## `data-testid` Conventions + +- Use `data-testid` selectors throughout. Add new ones to React components as needed. +- Kebab-case naming: `feature-field-input`, `action-feature`, `feature-actions`. +- Always use `data-testid` — both on native HTML elements and custom components. Custom components declare `"data-testid"?: string` in their props interface and place it on the appropriate internal DOM element. diff --git a/e2e/environment/.gitignore b/e2e/environment/.gitignore new file mode 100644 index 0000000..e83bd1b --- /dev/null +++ b/e2e/environment/.gitignore @@ -0,0 +1,12 @@ +# Ignore zitadel environment +.env +Caddyfile +management.json +turnserver.conf +zitadel.env +proxy.env +proxy-no-ports.env +proxy-certs/ +proxy-certs-no-ports/ +docker-compose.yml +/machinekey \ No newline at end of file diff --git a/e2e/environment/clean-test-env.sh b/e2e/environment/clean-test-env.sh new file mode 100644 index 0000000..6445e7d --- /dev/null +++ b/e2e/environment/clean-test-env.sh @@ -0,0 +1,25 @@ +#!/bin/bash + +check_docker_compose() { + if command -v docker-compose &> /dev/null + then + echo "docker-compose" + return + fi + if docker compose --help &> /dev/null + then + echo "docker compose" + return + fi + + echo "docker-compose is not installed or not in PATH. Please follow the steps from the official guide: https://docs.docker.com/engine/install/" > /dev/stderr + exit 1 +} + +DOCKER_COMPOSE_COMMAND=$(check_docker_compose) + +$DOCKER_COMPOSE_COMMAND down --volumes +rm -f docker-compose.yml Caddyfile zitadel.env .env dashboard.env machinekey/zitadel-admin-sa.token turnserver.conf management.json proxy.env proxy-no-ports.env +rm -rf proxy-certs proxy-certs-no-ports +rm -f ../../.test-config.json ../playwright.env.json +rm -f ../fixtures/auth/owner.json ../fixtures/auth/user.json \ No newline at end of file diff --git a/e2e/environment/create-test-env.sh b/e2e/environment/create-test-env.sh new file mode 100644 index 0000000..183a677 --- /dev/null +++ b/e2e/environment/create-test-env.sh @@ -0,0 +1,927 @@ +#!/bin/bash + +set -e + +# Tag of the management-cloud image to pull. Override via env var to pin the +# tests to a specific management-cloud build (e.g., a feature branch image). +MANAGEMENT_IMAGE_TAG="${MANAGEMENT_IMAGE_TAG:-main}" +echo "Using ghcr.io/netbirdio/management-cloud:${MANAGEMENT_IMAGE_TAG}" + +# Tag of the reverse-proxy image to pull. Override via env var to pin the +# tests to a specific reverse-proxy build (e.g., a feature branch image). +REVERSE_PROXY_IMAGE_TAG="${REVERSE_PROXY_IMAGE_TAG:-main}" +echo "Using ghcr.io/netbirdio/reverse-proxy:${REVERSE_PROXY_IMAGE_TAG}" + +handle_request_command_status() { + PARSED_RESPONSE=$1 + FUNCTION_NAME=$2 + RESPONSE=$3 + if [[ $PARSED_RESPONSE -ne 0 ]]; then + echo "ERROR calling $FUNCTION_NAME:" $(echo "$RESPONSE" | jq -r '.message') > /dev/stderr + exit 1 + fi +} + +handle_zitadel_request_response() { + PARSED_RESPONSE=$1 + FUNCTION_NAME=$2 + RESPONSE=$3 + if [[ $PARSED_RESPONSE == "null" ]]; then + echo "ERROR calling $FUNCTION_NAME:" $(echo "$RESPONSE" | jq -r '.message') > /dev/stderr + exit 1 + fi + sleep 1 +} + +check_docker_compose() { + if command -v docker-compose &> /dev/null + then + echo "docker-compose" + return + fi + if docker compose --help &> /dev/null + then + echo "docker compose" + return + fi + + echo "docker-compose is not installed or not in PATH. Please follow the steps from the official guide: https://docs.docker.com/engine/install/" > /dev/stderr + exit 1 +} + +check_jq() { + if ! command -v jq &> /dev/null + then + echo "jq is not installed or not in PATH, please install with your package manager. e.g. sudo apt install jq" > /dev/stderr + exit 1 + fi +} + +wait_proxy_cluster() { + SERVICE_NAME=${1:-reverse-proxy} + echo -n "Waiting for $SERVICE_NAME to register with management " + set +e + local attempts=60 + local i + for ((i = 1; i <= attempts; i++)); do + if $DOCKER_COMPOSE_COMMAND logs "$SERVICE_NAME" 2>&1 | grep -q "Initial mapping sync complete"; then + echo " done" + set -e + return + fi + echo -n " ." + sleep 2 + done + echo "" + echo "ERROR: $SERVICE_NAME did not register with management after $((attempts * 2))s" + echo "--- $SERVICE_NAME logs ---" + $DOCKER_COMPOSE_COMMAND logs --tail=50 "$SERVICE_NAME" || true + exit 1 +} + +wait_crdb() { + set +e + while true; do + if $DOCKER_COMPOSE_COMMAND exec -T crdb curl -sf -o /dev/null 'http://localhost:8080/health?ready=1'; then + break + fi + echo -n " ." + sleep 5 + done + echo " done" + set -e +} + +init_crdb() { + echo -e "\nInitializing Zitadel's CockroachDB\n\n" + $DOCKER_COMPOSE_COMMAND up -d crdb + echo "" + # shellcheck disable=SC2028 + echo -n "Waiting cockroachDB to become ready " + wait_crdb + $DOCKER_COMPOSE_COMMAND exec -T crdb /bin/bash -c "cp /cockroach/certs/* /zitadel-certs/ && cockroach cert create-client --overwrite --certs-dir /zitadel-certs/ --ca-key /zitadel-certs/ca.key zitadel_user && chown -R 1000:1000 /zitadel-certs/" + handle_request_command_status $? "init_crdb failed" "" +} + +get_main_ip_address() { + if [[ "$OSTYPE" == "darwin"* ]]; then + interface=$(route -n get default | grep 'interface:' | awk '{print $2}') + ip_address=$(ifconfig "$interface" | grep 'inet ' | awk '{print $2}') + else + interface=$(ip route | grep default | awk '{print $5}' | head -n 1) + ip_address=$(ip addr show "$interface" | grep 'inet ' | awk '{print $2}' | cut -d'/' -f1) + fi + + echo "$ip_address" +} + +wait_pat() { + PAT_PATH=$1 + set +e + while true; do + if [[ -f "$PAT_PATH" ]]; then + break + fi + echo -n " ." + sleep 1 + done + echo " done" + set -e +} + +wait_api() { + INSTANCE_URL=$1 + PAT=$2 + set +e + while true; do + curl -s --fail -o /dev/null "$INSTANCE_URL/auth/v1/users/me" -H "Authorization: Bearer $PAT" + if [[ $? -eq 0 ]]; then + break + fi + echo -n " ." + sleep 1 + done + echo " done" + set -e +} + +create_new_project() { + INSTANCE_URL=$1 + PAT=$2 + PROJECT_NAME="NETBIRD" + + RESPONSE=$( + curl -sS -X POST "$INSTANCE_URL/management/v1/projects" \ + -H "Authorization: Bearer $PAT" \ + -H "Content-Type: application/json" \ + -d '{"name": "'"$PROJECT_NAME"'"}' + ) + PARSED_RESPONSE=$(echo "$RESPONSE" | jq -r '.id') + handle_zitadel_request_response "$PARSED_RESPONSE" "create_new_project" "$RESPONSE" + echo "$PARSED_RESPONSE" +} + +create_new_application() { + INSTANCE_URL=$1 + PAT=$2 + APPLICATION_NAME=$3 + BASE_REDIRECT_URL1=$4 + BASE_REDIRECT_URL2=$5 + LOGOUT_URL=$6 + ZITADEL_DEV_MODE=$7 + + RESPONSE=$( + curl -sS -X POST "$INSTANCE_URL/management/v1/projects/$PROJECT_ID/apps/oidc" \ + -H "Authorization: Bearer $PAT" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "'"$APPLICATION_NAME"'", + "redirectUris": [ + "'"$BASE_REDIRECT_URL1"'", + "'"$BASE_REDIRECT_URL2"'" + ], + "postLogoutRedirectUris": [ + "'"$LOGOUT_URL"'" + ], + "RESPONSETypes": [ + "OIDC_RESPONSE_TYPE_CODE" + ], + "grantTypes": [ + "OIDC_GRANT_TYPE_AUTHORIZATION_CODE", + "OIDC_GRANT_TYPE_REFRESH_TOKEN" + ], + "appType": "OIDC_APP_TYPE_USER_AGENT", + "authMethodType": "OIDC_AUTH_METHOD_TYPE_NONE", + "version": "OIDC_VERSION_1_0", + "devMode": '"$ZITADEL_DEV_MODE"', + "accessTokenType": "OIDC_TOKEN_TYPE_JWT", + "accessTokenRoleAssertion": true, + "skipNativeAppSuccessPage": true + }' + ) + + PARSED_RESPONSE=$(echo "$RESPONSE" | jq -r '.clientId') + handle_zitadel_request_response "$PARSED_RESPONSE" "create_new_application" "$RESPONSE" + echo "$PARSED_RESPONSE" +} + +create_service_user() { + INSTANCE_URL=$1 + PAT=$2 + + RESPONSE=$( + curl -sS -X POST "$INSTANCE_URL/management/v1/users/machine" \ + -H "Authorization: Bearer $PAT" \ + -H "Content-Type: application/json" \ + -d '{ + "userName": "netbird-service-account", + "name": "Netbird Service Account", + "description": "Netbird Service Account for IDP management", + "accessTokenType": "ACCESS_TOKEN_TYPE_JWT" + }' + ) + PARSED_RESPONSE=$(echo "$RESPONSE" | jq -r '.userId') + handle_zitadel_request_response "$PARSED_RESPONSE" "create_service_user" "$RESPONSE" + echo "$PARSED_RESPONSE" +} + +create_service_user_secret() { + INSTANCE_URL=$1 + PAT=$2 + USER_ID=$3 + + RESPONSE=$( + curl -sS -X PUT "$INSTANCE_URL/management/v1/users/$USER_ID/secret" \ + -H "Authorization: Bearer $PAT" \ + -H "Content-Type: application/json" \ + -d '{}' + ) + SERVICE_USER_CLIENT_ID=$(echo "$RESPONSE" | jq -r '.clientId') + handle_zitadel_request_response "$SERVICE_USER_CLIENT_ID" "create_service_user_secret_id" "$RESPONSE" + SERVICE_USER_CLIENT_SECRET=$(echo "$RESPONSE" | jq -r '.clientSecret') + handle_zitadel_request_response "$SERVICE_USER_CLIENT_SECRET" "create_service_user_secret" "$RESPONSE" +} + +add_organization_user_manager() { + INSTANCE_URL=$1 + PAT=$2 + USER_ID=$3 + + RESPONSE=$( + curl -sS -X POST "$INSTANCE_URL/management/v1/orgs/me/members" \ + -H "Authorization: Bearer $PAT" \ + -H "Content-Type: application/json" \ + -d '{ + "userId": "'"$USER_ID"'", + "roles": [ + "ORG_USER_MANAGER" + ] + }' + ) + PARSED_RESPONSE=$(echo "$RESPONSE" | jq -r '.details.creationDate') + handle_zitadel_request_response "$PARSED_RESPONSE" "add_organization_user_manager" "$RESPONSE" + echo "$PARSED_RESPONSE" +} + +create_admin_user() { + INSTANCE_URL=$1 + PAT=$2 + USERNAME=$3 + PASSWORD=$4 + FIRST_NAME=${5:-"Zitadel"} + LAST_NAME=${6:-"Admin"} + RESPONSE=$( + curl -sS -X POST "$INSTANCE_URL/management/v1/users/human/_import" \ + -H "Authorization: Bearer $PAT" \ + -H "Content-Type: application/json" \ + -d '{ + "userName": "'"$USERNAME"'", + "profile": { + "firstName": "'"$FIRST_NAME"'", + "lastName": "'"$LAST_NAME"'" + }, + "email": { + "email": "'"$USERNAME"'", + "isEmailVerified": true + }, + "password": "'"$PASSWORD"'", + "passwordChangeRequired": false + }' + ) + PARSED_RESPONSE=$(echo "$RESPONSE" | jq -r '.userId') + handle_zitadel_request_response "$PARSED_RESPONSE" "create_admin_user" "$RESPONSE" + echo "$PARSED_RESPONSE" +} + +add_instance_admin() { + INSTANCE_URL=$1 + PAT=$2 + USER_ID=$3 + + RESPONSE=$( + curl -sS -X POST "$INSTANCE_URL/admin/v1/members" \ + -H "Authorization: Bearer $PAT" \ + -H "Content-Type: application/json" \ + -d '{ + "userId": "'"$USER_ID"'", + "roles": [ + "IAM_OWNER" + ] + }' + ) + PARSED_RESPONSE=$(echo "$RESPONSE" | jq -r '.details.creationDate') + handle_zitadel_request_response "$PARSED_RESPONSE" "add_instance_admin" "$RESPONSE" + echo "$PARSED_RESPONSE" +} + +delete_auto_service_user() { + INSTANCE_URL=$1 + PAT=$2 + + RESPONSE=$( + curl -sS -X GET "$INSTANCE_URL/auth/v1/users/me" \ + -H "Authorization: Bearer $PAT" \ + -H "Content-Type: application/json" \ + ) + USER_ID=$(echo "$RESPONSE" | jq -r '.user.id') + handle_zitadel_request_response "$USER_ID" "delete_auto_service_user_get_user" "$RESPONSE" + + RESPONSE=$( + curl -sS -X DELETE "$INSTANCE_URL/admin/v1/members/$USER_ID" \ + -H "Authorization: Bearer $PAT" \ + -H "Content-Type: application/json" \ + ) + PARSED_RESPONSE=$(echo "$RESPONSE" | jq -r '.details.changeDate') + handle_zitadel_request_response "$PARSED_RESPONSE" "delete_auto_service_user_remove_instance_permissions" "$RESPONSE" + + RESPONSE=$( + curl -sS -X DELETE "$INSTANCE_URL/management/v1/orgs/me/members/$USER_ID" \ + -H "Authorization: Bearer $PAT" \ + -H "Content-Type: application/json" \ + ) + PARSED_RESPONSE=$(echo "$RESPONSE" | jq -r '.details.changeDate') + handle_zitadel_request_response "$PARSED_RESPONSE" "delete_auto_service_user_remove_org_permissions" "$RESPONSE" + echo "$PARSED_RESPONSE" +} + +create_proxy_token() { + TOKEN_NAME=$1 + echo "Creating proxy token '$TOKEN_NAME'..." >&2 + local attempts=30 + local delay=2 + local i + local out="" + local tok="" + for ((i = 1; i <= attempts; i++)); do + out=$($DOCKER_COMPOSE_COMMAND exec -T management /go/bin/netbird-mgmt token create \ + --name "$TOKEN_NAME" \ + --config /etc/netbird/management.json \ + --log-file console \ + --log-level error 2>&1 || true) + + tok=$(echo "$out" | grep "^Token:" | awk '{print $2}') + if [ -n "$tok" ]; then + break + fi + echo " attempt $i/$attempts: management not ready yet, retrying in ${delay}s..." >&2 + sleep "$delay" + done + + if [ -z "$tok" ]; then + echo "ERROR: Failed to create proxy token '$TOKEN_NAME' after $attempts attempts" >&2 + echo "Last output from management:" >&2 + echo "$out" >&2 + echo "--- docker compose ps ---" >&2 + $DOCKER_COMPOSE_COMMAND ps >&2 || true + echo "--- management logs ---" >&2 + $DOCKER_COMPOSE_COMMAND logs --tail=200 management >&2 || true + exit 1 + fi + echo "Proxy token '$TOKEN_NAME' created: ${tok:0:10}..." >&2 + echo "$tok" +} + +init_proxy_tokens() { + echo "Waiting for management container to become ready..." + # Default proxy (supports custom ports) + NB_PROXY_TOKEN=$(create_proxy_token "test-proxy") + cat > proxy.env < proxy-no-ports.env < /dev/stderr + return 1 + fi + + if [ "$DOMAIN" == "netbird.example.com" ]; then + echo "The NETBIRD_DOMAIN cannot be netbird.example.com" > /dev/stderr + return 1 + fi + return 0 +} + +read_nb_domain() { + READ_NETBIRD_DOMAIN="" + echo -n "Enter the domain you want to use for NetBird (e.g. netbird.my-domain.com): " > /dev/stderr + read -r READ_NETBIRD_DOMAIN < /dev/tty + if ! check_nb_domain "$READ_NETBIRD_DOMAIN"; then + read_nb_domain + fi + echo "$READ_NETBIRD_DOMAIN" +} + +initEnvironment() { + CADDY_SECURE_DOMAIN="" + ZITADEL_EXTERNALSECURE="false" + ZITADEL_TLS_MODE="disabled" + ZITADEL_MASTERKEY="$(openssl rand -base64 32 | head -c 32)" + NETBIRD_PORT=33080 + NETBIRD_HTTP_PROTOCOL="http" + TURN_USER="self" + TURN_PASSWORD=$(openssl rand -base64 32 | sed 's/=//g') + TURN_MIN_PORT=49152 + TURN_MAX_PORT=65535 + + NETBIRD_DOMAIN=$(get_main_ip_address) + + if [[ "$OSTYPE" == "darwin"* ]]; then + ZIDATE_TOKEN_EXPIRATION_DATE=$(date -u -v+30M "+%Y-%m-%dT%H:%M:%SZ") + else + ZIDATE_TOKEN_EXPIRATION_DATE=$(date -u -d "+30 minutes" "+%Y-%m-%dT%H:%M:%SZ") + fi + + check_jq + + DOCKER_COMPOSE_COMMAND=$(check_docker_compose) + + if [ -f zitadel.env ]; then + echo "Generated files already exist, if you want to reinitialize the environment, please remove them first." + echo "You can use the following commands:" + echo " $DOCKER_COMPOSE_COMMAND down --volumes # to remove all containers and volumes" + echo " rm -f docker-compose.yml Caddyfile zitadel.env dashboard.env machinekey/zitadel-admin-sa.token turnserver.conf management.json proxy.env proxy-no-ports.env && rm -rf proxy-certs proxy-certs-no-ports" + echo "Be aware that this will remove all data from the database, and you will have to reconfigure the dashboard." + exit 1 + fi + + echo Rendering initial files... + renderDockerCompose > docker-compose.yml + renderCaddyfile > Caddyfile + renderZitadelEnv > zitadel.env + echo "" > turnserver.conf + echo "" > management.json + echo "" > proxy.env + echo "" > proxy-no-ports.env + + mkdir -p machinekey + chmod 777 machinekey + + init_crdb + + echo -e "\nStarting Zidatel IDP for user management\n\n" + $DOCKER_COMPOSE_COMMAND up -d caddy zitadel + init_zitadel + + echo -e "\nRendering NetBird files...\n" + renderTurnServerConf > turnserver.conf + renderManagementJson > management.json + renderDashboardEnv > "../../.test-config.json" + + echo -e "\nRendering Playwright environment file...\n" + renderPlaywrightEnv > "../playwright.env.json" + + echo -e "\nPulling latest images...\n" + docker pull "ghcr.io/netbirdio/management-cloud:${MANAGEMENT_IMAGE_TAG}" + docker pull "ghcr.io/netbirdio/reverse-proxy:${REVERSE_PROXY_IMAGE_TAG}" + + # Pre-create the proxy cert directories BEFORE starting containers so that + # docker's bind-mounts (./proxy-certs and ./proxy-certs-no-ports) reuse our + # runner-owned dirs instead of creating root-owned ones, which would + # prevent openssl from writing the generated keys/certs below. Each proxy + # gets its own cert dir so it registers with a distinct identity (a shared + # cert collapses both proxies onto one proxy ID and management superseding + # flaps cluster registration). + mkdir -p proxy-certs proxy-certs-no-ports + + echo -e "\nStarting NetBird services\n" + $DOCKER_COMPOSE_COMMAND up -d + + echo -e "\nWaiting for management to be ready...\n" + sleep 5 + + echo -e "\nGenerating self-signed TLS certificates for reverse proxies...\n" + openssl req -x509 -newkey rsa:2048 -keyout proxy-certs/tls.key -out proxy-certs/tls.crt \ + -days 365 -nodes -subj "/CN=example.com" \ + -addext "subjectAltName=DNS:example.com,DNS:*.example.com,DNS:noports.example.com,DNS:*.noports.example.com" + chmod 644 proxy-certs/tls.key proxy-certs/tls.crt + openssl req -x509 -newkey rsa:2048 -keyout proxy-certs-no-ports/tls.key -out proxy-certs-no-ports/tls.crt \ + -days 365 -nodes -subj "/CN=noports.example.com" \ + -addext "subjectAltName=DNS:noports.example.com,DNS:*.noports.example.com" + chmod 644 proxy-certs-no-ports/tls.key proxy-certs-no-ports/tls.crt + + echo -e "\nCreating proxy access tokens...\n" + init_proxy_tokens + + echo -e "\nStarting reverse proxy services...\n" + $DOCKER_COMPOSE_COMMAND up -d reverse-proxy reverse-proxy-no-ports + + echo -e "\nWaiting for reverse proxies to register with management...\n" + wait_proxy_cluster reverse-proxy + wait_proxy_cluster reverse-proxy-no-ports + + echo -e "\nDone!\n" + echo "Run 'npm run test:dev' to start the dashboard at http://localhost:1337" + echo "Management API is at $NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN:$NETBIRD_PORT" + echo "Login with the following credentials:" + echo "Username: $ZITADEL_ADMIN_USERNAME" | tee .env + echo "Password: $ZITADEL_ADMIN_PASSWORD" | tee -a .env +} + +renderCaddyfile() { + cat <(); + +async function getApiContext( + page: Page, +): Promise<{ token: string; origin: string }> { + const cached = apiContextCache.get(page); + if (cached) return cached; + + // Navigate to the users page to trigger an API call we can intercept. + // The predicate runs for EVERY response the page receives and returns + // whether it's the one we want: a successful GET to the management API. + // Non-matching responses (4xx/5xx, non-GET, non-API) are skipped — the + // wait keeps going until a match or the 10s timeout. Network-level + // request failures never produce a response, so they can't match either; + // if nothing succeeds, this throws a TimeoutError. + // Set E2E_DEBUG_API=1 to log every API response the predicate considers. + const debugApi = !!process.env.E2E_DEBUG_API; + const [response] = await Promise.all([ + page.waitForResponse( + (resp) => { + const req = resp.request(); + if (!resp.url().includes("/api/")) return false; + const isMatch = req.method() === "GET" && resp.status() === 200; + if (debugApi) { + // eslint-disable-next-line no-console + console.log( + `[api-context] ${req.method()} ${resp.status()} ${resp.url()} ${ + isMatch ? "← MATCH" : "(skipped)" + }`, + ); + } + return isMatch; + }, + { timeout: 10_000 }, + ), + page.goto("/team/users"), + ]); + + const request = response.request(); + const authHeader = + (await request.allHeaders())["authorization"] || ""; + const token = authHeader.replace("Bearer ", ""); + const url = new URL(request.url()); + const origin = `${url.protocol}//${url.host}`; + + if (!token) { + throw new Error("Could not capture auth token from API response"); + } + + const ctx = { token, origin }; + apiContextCache.set(page, ctx); + return ctx; +} + +async function apiGet(page: Page, path: string): Promise { + const { token, origin } = await getApiContext(page); + const resp = await page.request.get(`${origin}/api${path}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + return resp.json(); +} + +async function apiDelete(page: Page, path: string): Promise { + const { token, origin } = await getApiContext(page); + await page.request.delete(`${origin}/api${path}`, { + headers: { Authorization: `Bearer ${token}` }, + }); +} + +/** List all groups. */ +export async function listGroups(page: Page): Promise { + return apiGet(page, "/groups"); +} + +/** Delete a group by ID. */ +export async function deleteGroup(page: Page, groupId: string) { + await apiDelete(page, `/groups/${groupId}`); +} + +/** Delete all groups matching a prefix. */ +export async function deleteGroupsByPrefix(page: Page, prefix: string) { + const groups = await listGroups(page); + const toDelete = groups.filter((g) => g.name.startsWith(prefix)); + for (const g of toDelete) { + await deleteGroup(page, g.id); + } +} + +// ── Networks ──────────────────────────────────────────────────────────── + +type Network = { + id: string; + name: string; +}; + +/** List all networks. */ +export async function listNetworks(page: Page): Promise { + return apiGet(page, "/networks"); +} + +/** Delete a network by ID. */ +export async function deleteNetworkById(page: Page, networkId: string) { + await apiDelete(page, `/networks/${networkId}`); +} + +/** Delete all networks matching a prefix. */ +export async function deleteNetworksByPrefix(page: Page, prefix: string) { + const networks = await listNetworks(page); + const toDelete = networks.filter((n) => n.name.startsWith(prefix)); + for (const n of toDelete) { + await deleteNetworkById(page, n.id); + } +} + +// ── Policies ─────────────────────────────────────────────────────────── + +type Policy = { + id: string; + name: string; + description: string; + rules: { sources: string[]; destinations: string[] }[]; +}; + +/** List all policies. */ +export async function listPolicies(page: Page): Promise { + return apiGet(page, "/policies"); +} + +/** Delete a policy by ID. */ +export async function deletePolicyById(page: Page, policyId: string) { + await apiDelete(page, `/policies/${policyId}`); +} + +/** Delete all policies whose name or description contains a substring. */ +export async function deletePoliciesBySubstring(page: Page, substring: string) { + const policies = await listPolicies(page); + const toDelete = policies.filter( + (p) => p.name?.includes(substring) || p.description?.includes(substring), + ); + for (const p of toDelete) { + await deletePolicyById(page, p.id); + } +} + +/** Delete all policies that reference a group name in sources or destinations. */ +export async function deletePoliciesByGroupName(page: Page, groupName: string) { + const [policies, groups] = await Promise.all([ + listPolicies(page), + listGroups(page), + ]); + const groupId = groups.find((g) => g.name === groupName)?.id; + if (!groupId) return; + + const toDelete = policies.filter((p) => + p.rules.some( + (r) => r.sources?.includes(groupId) || r.destinations?.includes(groupId), + ), + ); + for (const p of toDelete) { + await deletePolicyById(page, p.id); + } +} + +// ── Routes ───────────────────────────────────────────────────────────── + +type Route = { + id: string; + network_id: string; +}; + +/** List all routes. */ +export async function listRoutes(page: Page): Promise { + return apiGet(page, "/routes"); +} + +/** Delete a route by ID. */ +export async function deleteRouteById(page: Page, routeId: string) { + await apiDelete(page, `/routes/${routeId}`); +} + +/** Delete all routes matching a network_id prefix. */ +export async function deleteRoutesByNetworkIdPrefix(page: Page, prefix: string) { + const routes = await listRoutes(page); + const toDelete = routes.filter((r) => r.network_id.startsWith(prefix)); + for (const r of toDelete) { + await deleteRouteById(page, r.id); + } +} + +// ── Setup Keys ───────────────────────────────────────────────────────── + +type SetupKey = { + id: string; + name: string; +}; + +/** List all setup keys. */ +export async function listSetupKeys(page: Page): Promise { + return apiGet(page, "/setup-keys"); +} + +/** Delete a setup key by ID. */ +export async function deleteSetupKeyById(page: Page, keyId: string) { + await apiDelete(page, `/setup-keys/${keyId}`); +} + +/** Delete all setup keys matching a name prefix. */ +export async function deleteSetupKeysByPrefix(page: Page, prefix: string) { + const keys = await listSetupKeys(page); + const toDelete = keys.filter((k) => k.name.startsWith(prefix)); + for (const k of toDelete) { + await deleteSetupKeyById(page, k.id); + } +} + +// ── DNS Zones ────────────────────────────────────────────────────────── + +type DnsZone = { + id: string; + domain: string; +}; + +/** List all DNS zones. */ +export async function listDnsZones(page: Page): Promise { + return apiGet(page, "/dns/zones"); +} + +/** Delete a DNS zone by ID. */ +export async function deleteDnsZoneById(page: Page, zoneId: string) { + await apiDelete(page, `/dns/zones/${zoneId}`); +} + +/** Delete all DNS zones matching a domain prefix. */ +export async function deleteDnsZonesByPrefix(page: Page, prefix: string) { + const zones = await listDnsZones(page); + const toDelete = zones.filter((z) => z.domain.startsWith(prefix)); + for (const z of toDelete) { + await deleteDnsZoneById(page, z.id); + } +} + +// ── Notification Channels ───────────────────────────────────────────── + +type NotificationChannel = { + id: string; + type: string; + enabled: boolean; +}; + +/** List all notification channels. */ +export async function listNotificationChannels(page: Page): Promise { + return apiGet(page, "/integrations/notifications/channels"); +} + +/** Delete a notification channel by ID. */ +export async function deleteNotificationChannel(page: Page, channelId: string) { + await apiDelete(page, `/integrations/notifications/channels/${channelId}`); +} + +/** Delete all notification channels. */ +export async function deleteAllNotificationChannels(page: Page) { + const channels = await listNotificationChannels(page); + for (const c of channels) { + await deleteNotificationChannel(page, c.id); + } +} + +/** Delete notification channels by type (e.g., "email", "slack", "webhook"). */ +export async function deleteNotificationChannelsByType(page: Page, type: string) { + const channels = await listNotificationChannels(page); + const toDelete = channels.filter((c) => c.type === type); + for (const c of toDelete) { + await deleteNotificationChannel(page, c.id); + } +} + +// ── Nameservers ─────────────────────────────────────────────────────── + +type NameserverGroup = { + id: string; + name: string; +}; + +/** List all nameserver groups. */ +export async function listNameserverGroups(page: Page): Promise { + return apiGet(page, "/dns/nameservers"); +} + +/** Delete a nameserver group by ID. */ +export async function deleteNameserverGroupById(page: Page, id: string) { + await apiDelete(page, `/dns/nameservers/${id}`); +} + +/** Delete all nameserver groups matching a name prefix. */ +export async function deleteNameserverGroupsByPrefix(page: Page, prefix: string) { + const groups = await listNameserverGroups(page); + const toDelete = groups.filter((g) => g.name.startsWith(prefix)); + for (const g of toDelete) { + await deleteNameserverGroupById(page, g.id); + } +} + +// ── Reverse Proxy Services ──────────────────────────────────────────── + +type ReverseProxyService = { + id: string; + name: string; +}; + +/** List all reverse proxy services. */ +export async function listReverseProxyServices(page: Page): Promise { + return apiGet(page, "/reverse-proxies/services"); +} + +/** Delete a reverse proxy service by ID. */ +export async function deleteReverseProxyServiceById(page: Page, serviceId: string) { + await apiDelete(page, `/reverse-proxies/services/${serviceId}`); +} + +/** Delete all reverse proxy services matching a name prefix. */ +export async function deleteServicesByPrefix(page: Page, prefix: string) { + const services = await listReverseProxyServices(page); + const toDelete = services.filter((s) => s.name.startsWith(prefix)); + for (const s of toDelete) { + await deleteReverseProxyServiceById(page, s.id); + } +} + +// ── Reverse Proxy Clusters ──────────────────────────────────────────── + +type ReverseProxyCluster = { + id?: string; + address: string; + online: boolean; + connected_proxies: number; +}; + +/** List all reverse proxy clusters. */ +export async function listReverseProxyClusters( + page: Page, +): Promise { + return apiGet(page, "/reverse-proxies/clusters"); +} + +/** + * Poll the management API until every given cluster address is present and + * online with at least one connected proxy. The test reverse-proxy + * containers register asynchronously after `test:setup` returns, so the + * domain picker can be briefly empty; gating here keeps the reverse-proxy + * suite deterministic instead of flaking on a half-registered env. + */ +export async function waitForProxyClustersOnline( + page: Page, + addresses: string[], + timeoutMs = 120_000, +): Promise { + const deadline = Date.now() + timeoutMs; + let last: ReverseProxyCluster[] = []; + while (Date.now() < deadline) { + // Don't silently coerce errors to "no clusters" — a failed call (token + // capture timeout, 401, network) is a different problem than an empty + // list, and hiding it makes the gate undiagnosable. + last = await listReverseProxyClusters(page).catch((err) => { + // eslint-disable-next-line no-console + console.warn( + `[clusters-gate] list call failed: ${(err as Error).message}`, + ); + return []; + }); + const ready = addresses.every((addr) => + last.some( + (c) => c.address === addr && c.online && c.connected_proxies > 0, + ), + ); + if (ready) return; + await page.waitForTimeout(3000); + } + throw new Error( + `Proxy clusters not online after ${timeoutMs}ms. Expected ${addresses.join( + ", ", + )}; got ${JSON.stringify(last.map((c) => ({ a: c.address, online: c.online, n: c.connected_proxies })))}`, + ); +} + +// ── Users ───────────────────────────────────────────────────────────── + +type User = { + id: string; + email: string; + name: string; + role: string; + status: string; + is_current: boolean; +}; + +/** List all users. */ +export async function listUsers(page: Page): Promise { + return apiGet(page, "/users"); +} + +/** Delete a user by ID. */ +export async function deleteUserById(page: Page, userId: string) { + await apiDelete(page, `/users/${userId}`); +} + +/** Delete a user by email (skip current user). */ +export async function deleteUserByEmail(page: Page, email: string) { + const users = await listUsers(page); + const user = users.find((u) => u.email === email && !u.is_current); + if (user) { + await deleteUserById(page, user.id); + } +} diff --git a/e2e/helpers/auth.ts b/e2e/helpers/auth.ts new file mode 100644 index 0000000..2e7e82c --- /dev/null +++ b/e2e/helpers/auth.ts @@ -0,0 +1,117 @@ +/** + * Login helper for Playwright tests. + * + * The OIDC library (@axa-fr/react-oidc) uses a service worker for token + * management, so storageState alone can't restore a session. Each test + * goes through the OIDC redirect flow. Zitadel session cookies from + * storageState make re-auth fast (account selection, no credentials). + */ +import type { Page } from "@playwright/test"; +import { expect } from "@playwright/test"; +import { clearScrollLock } from "./utils"; + +export type TestUser = "owner" | "user"; + +const credentials: Record = { + owner: { username: "owner@localhost.test", password: "testMe123@" }, + user: { username: "user@localhost.test", password: "testMe123@" }, +}; + +/** + * Navigate to the app, authenticate via Zitadel, and wait for the app to load. + */ +export async function loginToApp(page: Page, user: TestUser = "owner") { + const { username, password } = credentials[user]; + + await page.goto("/"); + + // The app either loads directly or redirects to Zitadel. + // Use locators that match either outcome — Playwright auto-waits. + const appReady = page.getByTestId("left-navigation-item").first(); + const setupModal = page.getByTestId("setup-netbird-modal"); + const approvalPending = page.getByText("User Approval Pending"); + const onboarding = page.getByText("Add new device to your network"); + const selectAccount = page.getByText("Select account"); + const loginInput = page.locator("input[id=loginName]"); + const passwordInput = page.locator("input[id=password]"); + + // Wait for any of these outcomes + const which = await Promise.race([ + appReady.waitFor({ timeout: 20_000 }).then(() => "app" as const), + setupModal.waitFor({ timeout: 20_000 }).then(() => "modal" as const), + approvalPending.waitFor({ timeout: 20_000 }).then(() => "approval" as const), + onboarding.waitFor({ timeout: 20_000 }).then(() => "onboarding" as const), + selectAccount.waitFor({ timeout: 20_000 }).then(() => "select" as const), + loginInput.waitFor({ timeout: 20_000 }).then(() => "login" as const), + passwordInput.waitFor({ timeout: 20_000 }).then(() => "password" as const), + ]); + + if (which === "app") { + return; + } + + if (which === "modal") { + await setupModal.getByTestId("modal-close").click(); + await expect(setupModal).not.toBeVisible(); + return; + } + + if (which === "approval" || which === "onboarding") { + return; + } + + // We're on Zitadel + if (which === "select") { + await page.getByText(username).click(); + } else if (which === "login") { + await loginInput.fill(username); + await page.locator("button[id=submit-button]").click(); + await passwordInput.waitFor({ state: "visible" }); + await passwordInput.fill(password); + await page.locator("button[id=submit-button]").click(); + } else { + // password form directly + await passwordInput.fill(password); + await page.locator("button[id=submit-button]").click(); + } + + // Handle 2FA skip if shown + const skipButton = page.locator("button[name=skip]"); + if (await skipButton.isVisible({ timeout: 3000 }).catch(() => false)) { + await skipButton.click(); + } + + // Wait for either nav or modal to appear + await Promise.race([ + appReady.waitFor({ timeout: 15_000 }), + setupModal.waitFor({ timeout: 15_000 }), + approvalPending.waitFor({ timeout: 15_000 }), + onboarding.waitFor({ timeout: 15_000 }), + ]); + + // Dismiss setup modal if present + if (await setupModal.isVisible().catch(() => false)) { + await setupModal.getByTestId("modal-close").click(); + await expect(setupModal).not.toBeVisible(); + } + + // Clear any stale Radix overlays + await clearScrollLock(page); +} + +/** + * Navigate to a path within the app, dismissing the setup modal if it appears. + * Use this instead of page.goto() for in-app navigation after loginToApp(). + */ +export async function navigateTo(page: Page, path: string) { + await page.goto(path, { waitUntil: "domcontentloaded" }); + const modal = page.getByTestId("setup-netbird-modal"); + try { + await modal.waitFor({ state: "visible", timeout: 3_000 }); + await modal.getByTestId("modal-close").click(); + await expect(modal).not.toBeVisible(); + } catch { + // No modal — fine + } + await clearScrollLock(page); +} diff --git a/e2e/helpers/fixtures.ts b/e2e/helpers/fixtures.ts new file mode 100644 index 0000000..a58e6d8 --- /dev/null +++ b/e2e/helpers/fixtures.ts @@ -0,0 +1,49 @@ +/** + * Custom Playwright fixtures that provide pre-authenticated pages. + * + * Usage: + * import { test, expect } from "../helpers/fixtures"; + * test.describe.serial("My Feature", () => { + * test("first test", async ({ dashboardAsOwner }) => { ... }); + * }); + * + * `dashboardAsOwner` logs in once (via OIDC redirect) and reuses the same + * browser page for every test in the worker — no per-test login overhead. + */ +import { test as base, type Page, type BrowserContext } from "@playwright/test"; +import { loginToApp, type TestUser } from "./auth"; + +type Fixtures = { + dashboardAsOwner: Page; + dashboardAsUser: Page; +}; + +export const test = base.extend<{}, Fixtures>({ + dashboardAsOwner: [ + async ({ browser }, use) => { + const context = await browser.newContext({ + storageState: "e2e/fixtures/auth/owner.json", + }); + const page = await context.newPage(); + await loginToApp(page, "owner"); + await use(page); + await context.close(); + }, + { scope: "worker" }, + ], + + dashboardAsUser: [ + async ({ browser }, use) => { + const context = await browser.newContext({ + storageState: "e2e/fixtures/auth/user.json", + }); + const page = await context.newPage(); + await loginToApp(page, "user"); + await use(page); + await context.close(); + }, + { scope: "worker" }, + ], +}); + +export { expect } from "@playwright/test"; diff --git a/e2e/helpers/navigation.ts b/e2e/helpers/navigation.ts new file mode 100644 index 0000000..2468de3 --- /dev/null +++ b/e2e/helpers/navigation.ts @@ -0,0 +1,8 @@ +import type { Page } from "@playwright/test"; + +export async function visitByNavigation(page: Page, navText: string) { + await page + .getByTestId("left-navigation-item") + .getByText(navText, { exact: true }) + .click(); +} diff --git a/e2e/helpers/reverse-proxy-l4.ts b/e2e/helpers/reverse-proxy-l4.ts new file mode 100644 index 0000000..41e919b --- /dev/null +++ b/e2e/helpers/reverse-proxy-l4.ts @@ -0,0 +1,232 @@ +/** + * Shared helpers for L4 reverse proxy tests (TLS, TCP, UDP). + * Keeps the individual spec files DRY. + */ +import type { Page } from "@playwright/test"; +import { expect } from "@playwright/test"; +import { navigateTo } from "./auth"; +import { generateRandomName, waitForApiCalls } from "./utils"; + +/** Create a network and return its name. */ +export async function createNetwork(page: Page): Promise { + // Networks now lives under the collapsible "Network Routing" sidebar + // group, so navigate by URL instead of clicking the (hidden) child item. + await navigateTo(page, "/networks"); + const name = generateRandomName("rp-network-"); + + await page.getByTestId("add-network").click(); + await page.getByTestId("network-name-input").fill(name); + await page.getByTestId("submit-network").click(); + + await page + .getByTestId("confirmation.cancel") + .click({ force: true }); + + // force: true because Radix dialog leaves data-scroll-locked on body + const searchInput = page.getByTestId("table-search-input"); + await searchInput.fill(name, { force: true }); + await expect(page.locator("tr").filter({ hasText: name })).toBeVisible(); + + return name; +} + +/** Add a resource to an already-visible network row. */ +export async function addResource( + page: Page, + networkName: string, + address: string, +): Promise { + const name = generateRandomName("rp-resource-"); + + const searchInput = page.getByTestId("table-search-input"); + await searchInput.fill(networkName, { force: true }); + await page + .locator("tr") + .filter({ hasText: networkName }) + .getByTestId("add-resource") + .click({ force: true }); + + await page.getByTestId("resource-name-input").fill(name); + await page.getByTestId("resource-address-input").fill(address); + await page.getByTestId("resource-continue").click(); + + const responsePromise = page.waitForResponse( + (resp) => + resp.url().includes("/api/networks/") && + resp.url().includes("/resources") && + resp.request().method() === "POST", + { timeout: 30_000 }, + ); + await page.getByTestId("submit-resource").click(); + await page + .getByTestId("confirmation.confirm") + .click({ force: true }); + const response = await responsePromise; + expect([200, 201]).toContain(response.status()); + + await page + .getByTestId("confirmation.cancel") + .click({ force: true }); + + return name; +} + +/** Domains advertised by the test reverse-proxy clusters. */ +export const CUSTOM_PORTS_DOMAIN = "example.com"; +export const NO_CUSTOM_PORTS_DOMAIN = "noports.example.com"; + +/** Pick a base domain (cluster) in the service modal — deterministic when multiple clusters exist. */ +export async function selectProxyDomain(page: Page, domain: string) { + const trigger = page.getByTestId("proxy-domain-selector"); + await trigger.click({ force: true }); + // Find the option whose label span contains the exact "." text, + // so ".example.com" doesn't also match ".noports.example.com". + const option = page + .locator('[role="option"]') + .filter({ has: page.getByText(`.${domain}`, { exact: true }) }) + .first(); + await option.click({ force: true }); + // Wait for the trigger to reflect the new selection and the popover + // options to detach, so subsequent clicks aren't intercepted by Radix's + // outside-click handling during the close animation. + await expect(trigger.getByText(`.${domain}`, { exact: true })).toBeVisible(); + await option.waitFor({ state: "detached", timeout: 5_000 }).catch(() => {}); +} + +/** Select a resource target in the L4 target selector. */ +export async function selectL4Resource(page: Page, resourceName: string) { + await expect(page.getByTestId("group-selector-dropdown")).toBeVisible({ timeout: 10_000 }); + await page.getByTestId("group-selector-dropdown").click(); + await page + .locator('[role="tab"]') + .filter({ hasText: "Resources" }) + .click({ force: true }); + const search = page.getByTestId("group-selector-dropdown-search"); + await expect(search).toBeVisible({ timeout: 5_000 }); + await search.fill(resourceName); + await page + .locator('[role="option"], [role="listbox"] >> text=' + resourceName) + .or(page.getByText(resourceName)) + .first() + .click({ force: true, timeout: 15_000 }); +} + +/** Add the standard two access control rules (Allow Germany + Block IP). */ +export async function addAccessControlRules(page: Page) { + // Rule 1: Allow Country (Germany) + await page.getByTestId("add-access-rule").click(); + await page.getByTestId("access-rule-0").getByText("Select country...").click(); + await page + .getByTestId("select-dropdown-search") + .fill("Germany"); + await page.getByText("Germany (DE)").click({ force: true }); + + // Rule 2: Block IP Address + await page.getByTestId("add-access-rule").click(); + await page + .getByTestId("access-rule-1") + .getByTestId("access-rule-action") + .click(); + await page.getByText("Block Only").click({ force: true }); + await page + .getByTestId("access-rule-1") + .getByTestId("access-rule-type") + .click(); + await page.locator('[role="option"]').filter({ hasText: "IP Address" }).click({ force: true }); + const ipInput = page.getByTestId("access-rule-1").getByTestId("access-rule-value"); + await expect(ipInput).toBeVisible(); + await ipInput.fill("85.203.15.42"); +} + +/** Remove all access control rules (expects exactly 2). */ +export async function removeAllAccessControlRules(page: Page) { + await expect(page.getByTestId("remove-access-rule")).toHaveCount(2); + await page.getByTestId("remove-access-rule").last().click({ force: true }); + await page.getByTestId("remove-access-rule").first().click({ force: true }); +} + +/** Reset any stale filters/search so all services are visible in the table. */ +export async function resetServiceFilters(page: Page) { + const resetBtn = page.getByTestId("reset-filters-and-search"); + if (await resetBtn.isVisible().catch(() => false)) { + await resetBtn.click(); + } +} + +/** + * Navigate to a reverse-proxy page and wait for every /api/reverse-prox* + * backend call triggered by the navigation to finish before proceeding, + * so the table/picker is fully populated when the test interacts with it. + */ +export async function gotoReverseProxyPage( + page: Page, + path = "/reverse-proxy/services", +) { + await waitForApiCalls(page, () => navigateTo(page, path)); +} + +/** Open the edit modal for a service row. */ +export async function openServiceEdit(page: Page, subdomain: string) { + await gotoReverseProxyPage(page, "/reverse-proxy/services"); + await resetServiceFilters(page); + await page + .locator("tr") + .filter({ hasText: subdomain }) + .getByTestId("service-actions") + .click({ force: true }); + await page.getByTestId("edit-service").click({ force: true }); + // Wait for the edit modal to fully load + await expect(page.getByTestId("proxy-save")).toBeVisible({ timeout: 10_000 }); +} + +/** Delete a service via the action dropdown and confirm. */ +export async function deleteService(page: Page, subdomain: string) { + await page + .locator("tr") + .filter({ hasText: subdomain }) + .getByTestId("service-actions") + .click({ force: true }); + await page.getByTestId("delete-service").click({ force: true }); + await page + .getByTestId("confirmation.confirm") + .click({ force: true }); + + await expect( + page.locator("tr").filter({ hasText: subdomain }), + ).not.toBeVisible({ timeout: 15_000 }); +} + +/** Save an edited service (handles the "No Protection" confirmation). */ +export async function saveServiceEdit(page: Page) { + await page.getByTestId("proxy-save").click(); + await page + .getByTestId("confirmation.confirm") + .click({ force: true }); +} + +/** Navigate to Networks, find the network by name, and delete it. */ +export async function deleteNetwork(page: Page, networkName: string) { + await navigateTo(page, "/networks"); + const searchInput = page.getByTestId("table-search-input"); + await expect(searchInput).toBeVisible({ timeout: 30_000 }); + await searchInput.fill(networkName, { force: true }); + await expect( + page.locator("tr").filter({ hasText: networkName }), + ).toBeVisible(); + + // Open the row's action menu (last button) and click Delete + await page + .locator("tr") + .filter({ hasText: networkName }) + .locator("button") + .last() + .click({ force: true }); + await page.getByText("Delete").click({ force: true }); + await page + .getByTestId("confirmation.confirm") + .click({ force: true }); + + await expect( + page.locator("tr").filter({ hasText: networkName }), + ).not.toBeVisible(); +} diff --git a/e2e/helpers/utils.ts b/e2e/helpers/utils.ts new file mode 100644 index 0000000..15d3296 --- /dev/null +++ b/e2e/helpers/utils.ts @@ -0,0 +1,103 @@ +import type { Page, Request } from "@playwright/test"; + +export function generateRandomName(prefix?: string): string { + return (prefix || "") + Math.random().toString(36).substring(7); +} + +/** + * Run an action (click, goto, ...) and wait until every API request whose + * URL contains `pattern` has finished (response received or failed), plus a + * short quiet window to catch request chains where one response triggers + * the next fetch. + * + * Use this to make navigation deterministic: e.g. when opening the services + * page, the table only renders fully after /api/reverse-proxies/* calls + * return, so asserting on rows right after the click races the backend. + * + * Returns whatever the action returns. + */ +export async function waitForApiCalls( + page: Page, + action: () => Promise, + { + pattern = "/api/reverse-prox", + quietMs = 500, + timeoutMs = 15_000, + }: { pattern?: string; quietMs?: number; timeoutMs?: number } = {}, +): Promise { + let inFlight = 0; + let sawRequest = false; + let lastActivity = Date.now(); + + const matches = (req: Request) => req.url().includes(pattern); + const onRequest = (req: Request) => { + if (!matches(req)) return; + inFlight++; + sawRequest = true; + lastActivity = Date.now(); + }; + const onSettled = (req: Request) => { + if (!matches(req)) return; + inFlight = Math.max(0, inFlight - 1); + lastActivity = Date.now(); + }; + + page.on("request", onRequest); + page.on("requestfinished", onSettled); + page.on("requestfailed", onSettled); + + try { + const result = await action(); + const deadline = Date.now() + timeoutMs; + // Wait until: at least one matching request was seen (unless none ever + // fires), none are in flight, and the network has been quiet for quietMs. + while (Date.now() < deadline) { + const quietFor = Date.now() - lastActivity; + if (inFlight === 0 && quietFor >= quietMs) { + if (sawRequest || quietFor >= quietMs * 2) break; + } + await page.waitForTimeout(100); + } + return result; + } finally { + page.off("request", onRequest); + page.off("requestfinished", onSettled); + page.off("requestfailed", onSettled); + } +} + +/** + * Apply a single-choice (radio) table filter via the new TableFilters UI: + * open the "Filters" popover, pick the filter by column id, then select the + * option by its visible label (e.g. "Active", "Inactive", "All"). + */ +export async function applyRadioTableFilter( + page: Page, + filterId: string, + optionLabel: string, +) { + await page.getByTestId("table-filters-button").click(); + await page.getByTestId(`table-filter-${filterId}`).click(); + const optionId = `radio-option-${optionLabel + .replace(/\s+/g, "-") + .toLowerCase()}`; + await page.getByTestId(optionId).click(); +} + +/** + * Clear stale Radix scroll-lock and overlay from body. + * Some Radix modals leave `data-scroll-locked`, `pointer-events: none`, + * or a stale overlay div blocking the entire page. + */ +export async function clearScrollLock(page: Page) { + await page.evaluate(() => { + document.body.removeAttribute("data-scroll-locked"); + document.body.style.removeProperty("pointer-events"); + // Remove stale Radix dialog overlays that block pointer events + document + .querySelectorAll( + 'div[data-state="open"].fixed[class*="backdrop-blur"]', + ) + .forEach((el) => el.remove()); + }); +} diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts new file mode 100644 index 0000000..0e7d93d --- /dev/null +++ b/e2e/playwright.config.ts @@ -0,0 +1,54 @@ +import { defineConfig, devices } from "@playwright/test"; +import * as fs from "fs"; +import * as path from "path"; + +const envPath = path.resolve(__dirname, "playwright.env.json"); +const env = fs.existsSync(envPath) + ? JSON.parse(fs.readFileSync(envPath, "utf-8")) + : {}; + +export default defineConfig({ + outputDir: "./test-results", + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 1, + workers: process.env.CI ? 2 : 4, + reporter: process.env.CI + ? [ + ["github"], + ["html", { outputFolder: "./playwright-report", open: "never" }], + ["json", { outputFile: "test-results/results.json" }], + ] + : [ + ["list"], + ["html", { outputFolder: "./playwright-report", open: "on-failure" }], + ], + use: { + ...devices["Desktop Chrome"], + baseURL: env.BASE_URL || "http://localhost:1337", + viewport: { width: 1920, height: 1080 }, + screenshot: "only-on-failure", + trace: "retain-on-failure", + video: "retain-on-failure", + actionTimeout: 10_000, + navigationTimeout: 15_000, + }, + testDir: "./tests", + webServer: { + command: "npx serve@latest out -p 1337 --no-request-logging", + port: 1337, + reuseExistingServer: true, + cwd: path.resolve(__dirname, ".."), + }, + projects: [ + { + name: "login", + testMatch: "login.spec.ts", + }, + { + name: "e2e", + testIgnore: "login.spec.ts", + dependencies: ["login"], + }, + ], +}); diff --git a/e2e/tests/access-control-groups.spec.ts b/e2e/tests/access-control-groups.spec.ts new file mode 100644 index 0000000..b260b09 --- /dev/null +++ b/e2e/tests/access-control-groups.spec.ts @@ -0,0 +1,116 @@ +import { test, expect } from "../helpers/fixtures"; +import { navigateTo } from "../helpers/auth"; +import { generateRandomName } from "../helpers/utils"; +import { deleteGroupsByPrefix } from "../helpers/api"; + +let createdGroupName = ""; + +const ALL_GROUP_TABS = [ + "policies", + "resources", + "network-routes", + "nameservers", + "zones", +]; + +const REGULAR_GROUP_TABS = [ + "users", + "peers", + ...ALL_GROUP_TABS, + "setup-keys", +]; + +test.describe.serial("Groups @access-control", () => { + // ── List page tests (no navigation between these) ────────────────── + + test('Should show the "All" group in the list', async ({ dashboardAsOwner: page }) => { + await navigateTo(page, "/groups"); + await expect( + page.locator('[aria-label="View details of group All"]'), + ).toBeVisible(); + }); + + test('Should search for "All" group and still find it', async ({ dashboardAsOwner: page }) => { + const input = page.getByTestId("table-search-input"); + await input.fill("All"); + await expect( + page.locator('[aria-label="View details of group All"]'), + ).toBeVisible(); + await input.fill(""); + }); + + test("Should create a new group", async ({ dashboardAsOwner: page }) => { + const name = generateRandomName("test-group-"); + createdGroupName = name; + await page.getByTestId("open-create-group").click(); + await page.getByTestId("group-name-input").fill(name); + await page.getByTestId("create-group").click(); + await expect(page.getByText(name).first()).toBeVisible(); + }); + + test("Should rename the group", async ({ dashboardAsOwner: page }) => { + // Go back to list via breadcrumb (client-side nav, faster than navigateTo) + await page.getByText("Groups").first().click(); + const input = page.getByTestId("table-search-input"); + await expect(input).toBeVisible(); + await input.fill(createdGroupName); + await page + .locator("tr") + .filter({ hasText: createdGroupName }) + .getByTestId("group-actions") + .click(); + await page.getByTestId("rename-group").click(); + + const newName = generateRandomName("renamed-group-"); + await page.getByTestId("group-name-input").fill(newName); + await page.getByTestId("save-group-name").click(); + await expect(page.getByText(newName).first()).toBeVisible(); + createdGroupName = newName; + }); + + // ── Detail page tests ────────────────────────────────────────────── + + test('Should open "All" group page and show only All-group tabs', async ({ + dashboardAsOwner: page, + }) => { + await navigateTo(page, "/groups"); + const input = page.getByTestId("table-search-input"); + await input.fill(""); + await page.locator('[aria-label="View details of group All"]').click({ force: true }); + + for (const tab of ALL_GROUP_TABS) { + await expect(page.getByTestId(`group-tab-${tab}`)).toBeVisible(); + } + for (const tab of ["users", "peers", "setup-keys"]) { + await expect(page.getByTestId(`group-tab-${tab}`)).not.toBeVisible(); + } + for (const tab of ALL_GROUP_TABS) { + await page.getByTestId(`group-tab-${tab}`).click({ force: true }); + await expect(page.getByTestId(`group-tab-${tab}`)).toHaveAttribute("data-state", "active"); + } + }); + + test("Should open the new group page and show all 8 tabs", async ({ + dashboardAsOwner: page, + }) => { + await navigateTo(page, "/groups"); + const input = page.getByTestId("table-search-input"); + await expect(input).toBeVisible(); + await input.fill(createdGroupName); + await page.locator(`[aria-label="View details of group ${createdGroupName}"]`).click({ force: true }); + + for (const tab of REGULAR_GROUP_TABS) { + await expect(page.getByTestId(`group-tab-${tab}`)).toBeVisible(); + } + for (const tab of REGULAR_GROUP_TABS) { + await page.getByTestId(`group-tab-${tab}`).click({ force: true }); + await expect(page.getByTestId(`group-tab-${tab}`)).toHaveAttribute("data-state", "active"); + } + }); + + // ── Cleanup ──────────────────────────────────────────────────────── + + test("Should delete the created group", async ({ dashboardAsOwner: page }) => { + await deleteGroupsByPrefix(page, createdGroupName); + }); +}); diff --git a/e2e/tests/access-control.spec.ts b/e2e/tests/access-control.spec.ts new file mode 100644 index 0000000..c843e17 --- /dev/null +++ b/e2e/tests/access-control.spec.ts @@ -0,0 +1,151 @@ +import { test, expect } from "../helpers/fixtures"; +import { generateRandomName } from "../helpers/utils"; +import { navigateTo } from "../helpers/auth"; +import { deleteGroupsByPrefix } from "../helpers/api"; + +let policies: string[] = []; +let createdGroups: string[] = []; + +test.describe.serial("Access Controls @access-control", () => { + test("Should have default policy", async ({ dashboardAsOwner: page }) => { + await navigateTo(page, "/access-control"); + await expect(page.getByText("Default", { exact: true })).toBeVisible(); + await expect(page.getByText("This is a default rule")).toBeVisible(); + }); + + test("Should create new policy", async ({ dashboardAsOwner: page }) => { + const srcGroup = generateRandomName("ac-src-"); + const dstGroup = generateRandomName("ac-dst-"); + createdGroups.push(srcGroup, dstGroup); + + const name = generateRandomName("Policy "); + await createPolicy(page, { + name, + source_groups: [srcGroup], + destination_groups: [dstGroup], + protocol: "TCP", + ports: ["80", "443"], + direction: "in", + description: "This is a test policy", + }); + policies.push(name); + }); + + test("Should delete created policies", async ({ dashboardAsOwner: page }) => { + for (const policy of policies) { + await deletePolicy(page, policy); + } + policies = []; + }); + + test("Should delete created groups", async ({ dashboardAsOwner: page }) => { + for (const prefix of createdGroups) { + await deleteGroupsByPrefix(page, prefix); + } + createdGroups = []; + }); +}); + +async function createPolicy( + page: import("@playwright/test").Page, + opts: { + protocol?: "ALL" | "TCP" | "UDP" | "ICMP"; + source_groups: string[]; + destination_groups: string[]; + direction?: "bi" | "in"; + ports?: string[]; + name: string; + description?: string; + }, +) { + await page.getByTestId("open-add-policy").click(); + + if (opts.protocol !== "ALL") { + await page.getByTestId("protocol-select-button").click(); + await page + .getByTestId("protocol-selection") + .getByText(opts.protocol!) + .click(); + } + + if (opts.direction === "in") { + await page.getByTestId("policy-direction").click(); + } + + // Add source groups + if (opts.source_groups.length > 0) { + await page.getByTestId("source-group-selector").click(); + for (const group of opts.source_groups) { + const search = page.getByTestId("source-group-selector-search"); + await expect(search).toBeVisible(); + await search.fill(group); + await search.press("Enter"); + } + await page.getByTestId("source-group-selector-search").press("Escape"); + await expect( + page.getByTestId("source-group-selector-search"), + ).not.toBeVisible(); + } + + // Add destination groups + if (opts.destination_groups.length > 0) { + await page.getByTestId("destination-group-selector").click(); + for (const group of opts.destination_groups) { + const search = page.getByTestId("destination-group-selector-search"); + await expect(search).toBeVisible(); + await search.fill(group); + await search.press("Enter"); + } + await page + .getByTestId("destination-group-selector-search") + .press("Escape"); + await expect( + page.getByTestId("destination-group-selector-search"), + ).not.toBeVisible(); + } + + // Add ports + if ( + opts.ports && + (opts.protocol === "TCP" || opts.protocol === "UDP") + ) { + await page.getByTestId("port-selector").click(); + for (const port of opts.ports) { + const input = page.getByTestId("port-input"); + await expect(input).toBeVisible(); + await input.fill(port); + await input.press("Enter"); + } + await page.getByTestId("port-input").press("Escape"); + } + + // Click Continue (policy → posture checks) + await page.getByTestId("policy-continue").click(); + // Skip posture checks and continue (posture checks → general) + await page.getByTestId("policy-continue").click(); + + // Enter name + await page.getByTestId("policy-name").fill(opts.name); + if (opts.description) { + await page.getByTestId("policy-description").fill(opts.description); + } + + // Create policy + await page.getByTestId("submit-policy").click(); + await expect(page.getByTestId(opts.name)).toBeVisible(); +} + +async function deletePolicy( + page: import("@playwright/test").Page, + name: string, +) { + // Row actions are now behind a dropdown menu. + await page + .locator("tr") + .filter({ hasText: name }) + .getByTestId("policy-actions") + .click({ force: true }); + await page.getByTestId("delete-policy").click({ force: true }); + await page.getByTestId("confirmation.confirm").click(); + await expect(page.getByTestId(name)).not.toBeVisible({ timeout: 10_000 }); +} diff --git a/e2e/tests/dns-nameservers.spec.ts b/e2e/tests/dns-nameservers.spec.ts new file mode 100644 index 0000000..f953eba --- /dev/null +++ b/e2e/tests/dns-nameservers.spec.ts @@ -0,0 +1,168 @@ +import { test, expect } from "../helpers/fixtures"; +import { navigateTo } from "../helpers/auth"; +import { generateRandomName } from "../helpers/utils"; +import { deleteGroupsByPrefix, deleteNameserverGroupsByPrefix } from "../helpers/api"; + +let nsName = ""; +let nsDomain = ""; +let nsGroup1 = ""; +let nsGroup2 = ""; + +test.describe.serial("DNS - Nameservers @dns", () => { + test("Should show all 4 DNS presets and create a custom nameserver", async ({ + dashboardAsOwner: page, + }) => { + // Clean up stale nameservers and groups from previous runs + await deleteNameserverGroupsByPrefix(page, "test-ns-"); + await deleteNameserverGroupsByPrefix(page, "renamed-ns-"); + await deleteGroupsByPrefix(page, "ns-group-"); + await deleteGroupsByPrefix(page, "ns-domain-"); + + await navigateTo(page, "/dns/nameservers"); + + await page.getByTestId("open-add-nameserver").click(); + await expect(page.getByTestId("nameserver-preset-google")).toBeVisible(); + await expect(page.getByTestId("nameserver-preset-cloudflare")).toBeVisible(); + await expect(page.getByTestId("nameserver-preset-quad9")).toBeVisible(); + await expect(page.getByTestId("nameserver-preset-custom")).toBeVisible(); + + // Create via Custom DNS + await page.getByTestId("nameserver-preset-custom").click(); + + await page.getByTestId("nameserver-ip-input").first().fill("10.0.0.1"); + await page.getByTestId("add-nameserver-row").click(); + await page.getByTestId("nameserver-ip-input").last().fill("10.0.0.2"); + await page.getByTestId("nameserver-port-input").last().fill("5353"); + + const groupName = generateRandomName("ns-group-"); + nsGroup1 = groupName; + await page.getByTestId("nameserver-groups-selector").click(); + await page.getByTestId("nameserver-groups-selector-search").fill(groupName); + await page.getByTestId("nameserver-groups-selector-search").press("Enter"); + await page.getByTestId("nameserver-groups-selector-search").press("Escape"); + + await page.getByTestId("nameserver-continue").click(); + + // Domains tab + const d = generateRandomName("ns-domain-"); + nsDomain = `${d}.internal`; + await page.getByTestId("add-match-domain").click(); + await page.getByTestId("domain-input").last().fill(nsDomain); + await page.getByTestId("nameserver-mark-search-domains").click(); + + await page.getByTestId("nameserver-continue").click(); + + // General tab + const name = generateRandomName("test-ns-"); + nsName = name; + await page.getByTestId("nameserver-name-input").fill(name); + await page.getByTestId("nameserver-description-input").fill("Test nameserver"); + + await page.getByTestId("submit-nameserver").click(); + }); + + test("Should verify the nameserver in the table", async ({ dashboardAsOwner: page }) => { + const row = page.locator("tr").filter({ hasText: nsName }); + await expect(row).toBeVisible({ timeout: 10_000 }); + await expect(row.getByText(nsDomain)).toBeVisible(); + await expect(row.getByText("10.0.0.1")).toBeVisible(); + await expect(row.getByText("10.0.0.2")).toBeVisible(); + await expect(row.getByText(nsGroup1)).toBeVisible(); + // Active state moved into the row action menu: a freshly-created + // nameserver is enabled, so the toggle item reads "Disable". + await row.getByTestId("nameserver-actions").click({ force: true }); + await expect(page.getByTestId("nameserver-active-toggle")).toContainText( + "Disable", + ); + await page.keyboard.press("Escape"); + }); + + test("Should edit the nameserver", async ({ dashboardAsOwner: page }) => { + await page.locator("tr").filter({ hasText: nsName }).getByTestId("nameserver-name-cell").click({ force: true }); + + // Nameserver tab — change IPs and add group + await page.getByTestId("nameserver-tab-nameserver").click({ force: true }); + await expect(page.getByTestId("nameserver-ip-input").first()).toBeVisible(); + await page.getByTestId("nameserver-ip-input").first().fill("192.168.1.1"); + await page.getByTestId("nameserver-ip-input").last().fill("192.168.1.2"); + + const groupName = generateRandomName("ns-group-"); + nsGroup2 = groupName; + await page.getByTestId("nameserver-groups-selector").click(); + await page.getByTestId("nameserver-groups-selector-search").fill(groupName); + await page.getByTestId("nameserver-groups-selector-search").press("Enter"); + await page.getByTestId("nameserver-groups-selector-search").press("Escape"); + + // Domains tab — remove domain + await page.getByTestId("nameserver-tab-domains").click({ force: true }); + await page.getByTestId("domain-input-remove").click({ force: true }); + + // General tab — rename + await page.getByTestId("nameserver-tab-general").click({ force: true }); + const newName = generateRandomName("renamed-ns-"); + await page.getByTestId("nameserver-name-input").fill(newName); + await page.getByTestId("nameserver-description-input").fill("Updated"); + + await page.getByTestId("submit-nameserver").click(); + await expect(page.getByText("successfully").first()).toBeVisible({ timeout: 10_000 }); + // Verify the renamed nameserver appears in the table + await expect(page.locator("tr").filter({ hasText: newName })).toBeVisible({ timeout: 10_000 }); + nsName = newName; + }); + + test("Should verify edits and toggle active state", async ({ dashboardAsOwner: page }) => { + await navigateTo(page, "/dns/nameservers"); + const row = page.locator("tr").filter({ hasText: nsName }); + await expect(row).toBeVisible({ timeout: 10_000 }); + await expect(row.getByText("192.168.1.1")).toBeVisible(); + await expect(row.getByText("192.168.1.2")).toBeVisible(); + // Distribution-groups cell now renders a count badge (2 groups after edit). + await expect(row.getByText("2 Groups")).toBeVisible(); + + // Toggle active off and back on via the row action menu. + // Two races to defend against on each toggle: + // 1. Radix leaves `pointer-events: none` on body briefly during the + // close transition — re-opening without `force: true` makes + // Playwright auto-wait for the body to accept pointer events. + // 2. The toast fires before SWR refetches `/dns/nameservers`, so the + // row's `ns.enabled` is stale and the re-opened menu shows the + // old label. Wait for the GET refetch before re-opening. + const actions = row.getByTestId("nameserver-actions"); + const toggle = page.getByTestId("nameserver-active-toggle"); + const waitForRefetch = () => + page.waitForResponse( + (r) => + r.url().includes("/api/dns/nameservers") && + r.request().method() === "GET" && + r.ok(), + { timeout: 10_000 }, + ); + + await actions.click({ force: true }); + let refetch = waitForRefetch(); + await toggle.click({ force: true }); + await expect(page.getByText("successfully disabled").first()).toBeVisible(); + await refetch; + + await expect(toggle).toBeHidden(); + await actions.click(); + await expect(toggle).toContainText("Enable"); + refetch = waitForRefetch(); + await toggle.click({ force: true }); + await expect(page.getByText("successfully enabled").first()).toBeVisible(); + await refetch; + await expect(toggle).toBeHidden(); + }); + + test("Should delete the nameserver and groups", async ({ dashboardAsOwner: page }) => { + await page.locator("tr").filter({ hasText: nsName }).getByTestId("nameserver-actions").click({ force: true }); + await page.getByTestId("delete-nameserver").click({ force: true }); + await page.getByTestId("confirmation.confirm").click({ force: true }); + await expect(page.locator("tr").filter({ hasText: nsName })).not.toBeVisible(); + + for (const group of [nsGroup1, nsGroup2]) { + if (!group) continue; + await deleteGroupsByPrefix(page, group); + } + }); +}); diff --git a/e2e/tests/dns-settings.spec.ts b/e2e/tests/dns-settings.spec.ts new file mode 100644 index 0000000..e371835 --- /dev/null +++ b/e2e/tests/dns-settings.spec.ts @@ -0,0 +1,89 @@ +import { test, expect } from "../helpers/fixtures"; +import { navigateTo } from "../helpers/auth"; +import { generateRandomName } from "../helpers/utils"; +import { deleteGroupsByPrefix } from "../helpers/api"; + +let dnsGroups: string[] = []; + +test.describe.serial("DNS - Settings @dns", () => { + test("Should add groups to DNS disabled management", async ({ dashboardAsOwner: page }) => { + // Clean up stale groups from previous failed runs + await deleteGroupsByPrefix(page, "dns-group-"); + + await navigateTo(page, "/dns/settings"); + + const name1 = generateRandomName("dns-group-"); + const name2 = generateRandomName("dns-group-"); + dnsGroups = [name1, name2]; + + await expect(page.getByTestId("dns-groups-selector")).toBeVisible({ timeout: 15_000 }); + + // Remove any existing group badges before adding new ones + const existingBadges = page.getByTestId("group-badge"); + const badgeCount = await existingBadges.count(); + for (let i = 0; i < badgeCount; i++) { + await existingBadges.first().click(); + } + if (badgeCount > 0) { + await page.getByTestId("save-changes").click(); + await expect(page.getByText("successfully").first()).toBeVisible(); + } + + for (const group of dnsGroups) { + // Ensure dropdown is closed before reopening + const search = page.getByTestId("dns-groups-selector-search"); + if (await search.isVisible().catch(() => false)) { + await page.keyboard.press("Escape"); + await expect(search).not.toBeVisible({ timeout: 3_000 }); + } + await page.getByTestId("dns-groups-selector-open-close").click({ force: true }); + await expect(search).toBeVisible({ timeout: 5_000 }); + await search.fill(group); + await search.press("Enter"); + // Wait for the group badge to appear before continuing + await expect(page.getByText(group).first()).toBeVisible({ timeout: 5_000 }); + } + // Close the dropdown if still open + await page.keyboard.press("Escape"); + + for (const group of dnsGroups) { + await expect(page.getByText(group).first()).toBeVisible(); + } + + const saveResponse = page.waitForResponse( + (resp) => resp.url().includes("/api/dns/settings") && resp.request().method() === "PUT", + { timeout: 10_000 }, + ); + await page.getByTestId("save-changes").click(); + await saveResponse; + await expect(page.getByText("successfully").first()).toBeVisible(); + }); + + test("Should persist groups after reload and then remove them", async ({ + dashboardAsOwner: page, + }) => { + await page.reload(); + await expect(page.getByTestId("dns-groups-selector")).toBeVisible({ timeout: 15_000 }); + for (const group of dnsGroups) { + await expect(page.getByText(group).first()).toBeVisible({ timeout: 10_000 }); + } + + // Remove groups + for (const group of dnsGroups) { + await page.getByTestId("group-badge").filter({ hasText: group }).click(); + } + await page.getByTestId("save-changes").click(); + + // Verify removed after reload + await page.reload(); + for (const group of dnsGroups) { + await expect(page.getByTestId("group-badge").filter({ hasText: group })).toHaveCount(0); + } + }); + + test("Should delete the created groups", async ({ dashboardAsOwner: page }) => { + for (const group of dnsGroups) { + await deleteGroupsByPrefix(page, group); + } + }); +}); diff --git a/e2e/tests/dns-zones.spec.ts b/e2e/tests/dns-zones.spec.ts new file mode 100644 index 0000000..66cfb0a --- /dev/null +++ b/e2e/tests/dns-zones.spec.ts @@ -0,0 +1,197 @@ +import { test, expect } from "../helpers/fixtures"; +import { navigateTo } from "../helpers/auth"; +import { applyRadioTableFilter, generateRandomName } from "../helpers/utils"; +import { deleteGroupsByPrefix, deleteDnsZonesByPrefix } from "../helpers/api"; + +let zoneDomain = ""; +let zoneGroup = ""; +let zoneGroup2 = ""; + +test.describe.serial("DNS - Zones @dns", () => { + test("Should add a new zone with a distribution group", async ({ dashboardAsOwner: page }) => { + // Clean up leftover zones from previous runs + await deleteDnsZonesByPrefix(page, "dns-zone-"); + await deleteGroupsByPrefix(page, "zone-group-"); + + await navigateTo(page, "/dns/zones"); + + const name = generateRandomName("dns-zone-"); + zoneDomain = `${name}.test`; + + await page.getByTestId("add-dns-zone").click(); + await page.getByTestId("dns-zone-domain-input").fill(zoneDomain); + + const groupName = generateRandomName("zone-group-"); + zoneGroup = groupName; + await page.getByTestId("dns-zone-groups-selector").click(); + await page.getByTestId("dns-zone-groups-selector-search").fill(groupName); + await page.getByTestId("dns-zone-groups-selector-search").press("Enter"); + await page.getByTestId("dns-zone-groups-selector-search").press("Escape"); + + await page.getByTestId("dns-zone-search-domains").click(); + await expect(page.getByTestId("dns-zone-enabled")).toHaveAttribute("data-state", "checked"); + + await page.getByTestId("submit-dns-zone").click(); + await expect(page.locator("tr").filter({ hasText: zoneDomain })).toBeVisible(); + }); + + test("Should add A, AAAA, and CNAME records", async ({ dashboardAsOwner: page }) => { + const zoneRow = page.locator("tr").filter({ hasText: zoneDomain }); + + // Dismiss or use the "Add Record" prompt from zone creation + const addRecordBtn = page.getByTestId("confirmation.confirm"); + if (await addRecordBtn.isVisible().catch(() => false)) { + await addRecordBtn.click({ force: true }); + } else { + await zoneRow.getByTestId("add-dns-record").click({ force: true }); + } + await expect(page.getByTestId("dns-record-hostname-input")).toBeVisible({ timeout: 10_000 }); + await page.getByTestId("dns-record-hostname-input").fill("server1"); + await page.getByTestId("dns-record-content-input").fill("10.0.0.10"); + await page.getByTestId("dns-record-ttl-select").click(); + await page.locator('[role="option"]').filter({ hasText: "1 Min." }).click({ force: true }); + await page.getByTestId("submit-dns-record").click(); + await expect(zoneRow.getByTestId("dns-zone-records-count")).toContainText("1"); + + // AAAA record + await zoneRow.getByTestId("add-dns-record").click({ force: true }); + await page.getByTestId("dns-record-type-select").click(); + await page.locator('[role="option"]').filter({ hasText: "AAAA" }).click({ force: true }); + await page.getByTestId("dns-record-hostname-input").fill("server2"); + await page.getByTestId("dns-record-content-input").fill("2001:db8::1"); + await page.getByTestId("submit-dns-record").click(); + await expect(zoneRow.getByTestId("dns-zone-records-count")).toContainText("2"); + + // CNAME record + await zoneRow.getByTestId("add-dns-record").click({ force: true }); + await page.getByTestId("dns-record-type-select").click(); + await page.locator('[role="option"]').filter({ hasText: "CNAME" }).click({ force: true }); + await page.getByTestId("dns-record-hostname-input").fill("alias"); + await page.getByTestId("dns-record-content-input").fill("server1.example.com"); + await page.getByTestId("submit-dns-record").click(); + await expect(zoneRow.getByTestId("dns-zone-records-count")).toContainText("3"); + }); + + test("Should edit a record", async ({ dashboardAsOwner: page }) => { + await page.reload(); + // Expand accordion to show records + await page.locator("tr").filter({ hasText: zoneDomain }).first().click({ force: true }); + await expect(page.getByText("10.0.0.10")).toBeVisible({ timeout: 10_000 }); + + // Edit A record + await page.getByTestId("edit-dns-record").first().click({ force: true }); + await page.getByTestId("dns-record-hostname-input").fill("web1"); + await page.getByTestId("dns-record-content-input").fill("10.0.0.99"); + await page.getByTestId("submit-dns-record").click(); + await expect(page.getByText(`web1.${zoneDomain}`).first()).toBeVisible(); + }); + + test("Should toggle active and search domain states", async ({ dashboardAsOwner: page }) => { + await page.reload(); + const row = page.locator("tr").filter({ hasText: zoneDomain }); + + // Active state moved into the row action menu (Enable/Disable item). + // Zone starts enabled → item reads "Disable"; toggle off then on, + // reopening the menu each time to read the updated label. + // Two races to defend against on each toggle: + // 1. Radix leaves `pointer-events: none` on body briefly during the + // close transition — re-opening without `force: true` makes + // Playwright auto-wait for the body to accept pointer events. + // 2. The toggle's PUT resolves before SWR refetches `/dns/zones`, so + // the row's `zone.enabled` is stale and the re-opened menu shows + // the old label. Wait for the GET refetch before re-opening. + const actions = row.getByTestId("dns-zone-actions"); + const toggle = page.getByTestId("dns-zone-active-toggle"); + const waitForRefetch = () => + page.waitForResponse( + (r) => + r.url().includes("/api/dns/zones") && + r.request().method() === "GET" && + r.ok(), + { timeout: 10_000 }, + ); + + await actions.click({ force: true }); + let refetch = waitForRefetch(); + await toggle.click({ force: true }); + await refetch; + + await expect(toggle).toBeHidden(); + await actions.click(); + await expect(toggle).toContainText("Enable"); + refetch = waitForRefetch(); + await toggle.click({ force: true }); + await refetch; + + await expect(toggle).toBeHidden(); + await actions.click(); + await expect(toggle).toContainText("Disable"); + await page.keyboard.press("Escape"); + + // Toggle search domain off + const searchToggle = row.getByTestId("dns-zone-search-domain-toggle"); + await searchToggle.click({ force: true }); + await expect(searchToggle).toHaveAttribute("data-state", "unchecked"); + }); + + test("Should update distribution groups", async ({ dashboardAsOwner: page }) => { + const newGroup = generateRandomName("zone-group-"); + zoneGroup2 = newGroup; + + await page.locator("tr").filter({ hasText: zoneDomain }).getByTestId("multiple-groups").click({ force: true }); + await expect(page.getByTestId("save-groups")).toBeVisible(); + + await page.getByTestId("group-selector-dropdown").click(); + await page.getByTestId("group-selector-dropdown-search").fill(newGroup); + await page.getByTestId("group-selector-dropdown-search").press("Enter"); + await page.getByTestId("group-selector-dropdown-search").press("Escape"); + + await page.getByTestId("save-groups").click(); + await expect(page.getByTestId("save-groups")).not.toBeVisible(); + }); + + test("Should edit the zone and toggle settings back", async ({ dashboardAsOwner: page }) => { + // Page is on /dns/zones from previous test + await page.locator("tr").filter({ hasText: zoneDomain }).getByTestId("dns-zone-actions").click({ force: true }); + await page.getByTestId("edit-dns-zone").click({ force: true }); + + await page.getByTestId("dns-zone-search-domains").click(); + await expect(page.getByTestId("dns-zone-search-domains")).toHaveAttribute("data-state", "checked"); + + await page.getByTestId("submit-dns-zone").click(); + }); + + test("Should filter and search zones", async ({ dashboardAsOwner: page }) => { + await page.reload(); + const zoneRow = page.locator("tr").filter({ hasText: zoneDomain }).first(); + + // Filter: Active should show, Inactive should hide + await applyRadioTableFilter(page, "enabled", "Active"); + await expect(zoneRow).toBeVisible(); + await applyRadioTableFilter(page, "enabled", "Inactive"); + await expect(zoneRow).toBeHidden(); + await applyRadioTableFilter(page, "enabled", "All"); + + // Search by domain + const searchInput = page.getByTestId("table-search-input"); + await searchInput.fill(zoneDomain); + await expect(page.locator("tr").filter({ hasText: zoneDomain })).toBeVisible(); + + // Search by content + await searchInput.fill("10.0.0.99"); + await expect(page.locator("tr").filter({ hasText: zoneDomain })).toBeVisible(); + + // Search by group + await searchInput.fill(zoneGroup); + await expect(page.locator("tr").filter({ hasText: zoneDomain })).toBeVisible(); + await searchInput.fill(""); + }); + + test("Should delete the zone and groups", async ({ dashboardAsOwner: page }) => { + await deleteDnsZonesByPrefix(page, zoneDomain); + for (const group of [zoneGroup, zoneGroup2]) { + if (!group) continue; + await deleteGroupsByPrefix(page, group); + } + }); +}); diff --git a/e2e/tests/edition-gating.spec.ts b/e2e/tests/edition-gating.spec.ts new file mode 100644 index 0000000..f5a0bb8 --- /dev/null +++ b/e2e/tests/edition-gating.spec.ts @@ -0,0 +1,216 @@ +/** + * Temporary spec validating edition gating (cloud / licensed / oss). + * + * The test build hard-codes APP_ENV=test, so isNetBirdCloud() normally returns + * true. This spec uses the test-only `netbird-test-edition` localStorage + * override (see testEditionOverride in src/utils/netbird.ts) to drive each + * edition against the OSS test management backend, which does not report the + * premium permission modules (edr, idp, event_streaming). That absence is what + * triggered the original `permission.event_streaming.read` crash and is now + * covered by withDefaultModules in PermissionsProvider. + */ +import { test, expect, type Browser, type Page } from "@playwright/test"; +import { loginToApp, navigateTo } from "../helpers/auth"; + +type Edition = "cloud" | "licensed" | "oss"; + +// Premium permission modules the open-source management server does not report. +const PREMIUM_MODULES = [ + "edr", + "idp", + "event_streaming", + "assistant", + "msp", + "tenants", + "billing", + "proxy", + "proxy_configuration", +]; + +// stripPremiumModules rewrites /users/current to drop the premium permission +// modules, reproducing an open-source management backend regardless of what the +// test management returns. This is the exact condition that crashed before the +// withDefaultModules default in PermissionsProvider. +async function stripPremiumModules(page: Page) { + await page.route("**/users/current", async (route) => { + const response = await route.fetch(); + let body: any; + try { + body = await response.json(); + } catch (e) { + return route.fulfill({ response }); + } + if (body?.permissions?.modules) { + PREMIUM_MODULES.forEach((m) => delete body.permissions.modules[m]); + } + return route.fulfill({ response, json: body }); + }); +} + +async function openAs( + browser: Browser, + edition: Edition, + opts: { stripModules?: boolean } = {}, +): Promise<{ page: Page; close: () => Promise }> { + const context = await browser.newContext({ + storageState: "e2e/fixtures/auth/owner.json", + }); + await context.addInitScript((ed) => { + try { + window.localStorage.setItem("netbird-test-edition", ed as string); + } catch (e) {} + }, edition); + const page = await context.newPage(); + if (opts.stripModules) await stripPremiumModules(page); + await loginToApp(page, "owner"); + return { page, close: () => context.close() }; +} + +function collectPageErrors(page: Page): string[] { + const errors: string[] = []; + page.on("pageerror", (err) => errors.push(err.message)); + return errors; +} + +const SELF_HOSTED_CTA = "self-hosted-upgrade-cta"; +const START_TRIAL = "Start 14-Day Free Trial"; + +test.describe.serial("Edition gating @edition", () => { + test("integrations renders when premium permission modules are absent", async ({ + browser, + }) => { + // Reproduces the original crash: OSS management omits event_streaming/edr/ + // idp permission modules, and the integrations children read them directly. + const { page, close } = await openAs(browser, "oss", { + stripModules: true, + }); + const errors = collectPageErrors(page); + try { + await navigateTo(page, "/integrations"); + + await expect( + page.getByText("Identity Provider Sync").first(), + ).toBeVisible(); + await expect(page.getByText("MDM & EDR").first()).toBeVisible(); + + expect( + errors, + `unexpected runtime errors: ${errors.join(" | ")}`, + ).toHaveLength(0); + } finally { + await close(); + } + }); + + test("integrations renders without crashing on oss (teaser + upsell)", async ({ + browser, + }) => { + const { page, close } = await openAs(browser, "oss"); + const errors = collectPageErrors(page); + try { + await navigateTo(page, "/integrations"); + + // Tabs render (the crash happened while rendering these children). + await expect( + page.getByText("Identity Provider Sync").first(), + ).toBeVisible(); + await expect(page.getByText("MDM & EDR").first()).toBeVisible(); + + // Self-hosted upsell CTA is present. + await expect(page.getByTestId(SELF_HOSTED_CTA).first()).toBeVisible(); + + expect( + errors, + `unexpected runtime errors: ${errors.join(" | ")}`, + ).toHaveLength(0); + } finally { + await close(); + } + }); + + test("integrations renders unlocked on licensed (no upsell)", async ({ + browser, + }) => { + const { page, close } = await openAs(browser, "licensed"); + const errors = collectPageErrors(page); + try { + await navigateTo(page, "/integrations"); + + await expect( + page.getByText("Identity Provider Sync").first(), + ).toBeVisible(); + + // Licensed self-hosted unlocks features: no upsell CTA. + await expect(page.getByTestId(SELF_HOSTED_CTA)).toHaveCount(0); + + expect( + errors, + `unexpected runtime errors: ${errors.join(" | ")}`, + ).toHaveLength(0); + } finally { + await close(); + } + }); + + test("traffic events is locked with cloud upgrade CTA on cloud free", async ({ + browser, + }) => { + const { page, close } = await openAs(browser, "cloud"); + const errors = collectPageErrors(page); + try { + await navigateTo(page, "/events/traffic"); + + // Cloud free plan locks the feature with a trial/upgrade CTA, not the + // self-hosted license CTA. + await expect(page.getByText(START_TRIAL).first()).toBeVisible(); + await expect(page.getByTestId(SELF_HOSTED_CTA)).toHaveCount(0); + + expect( + errors, + `unexpected runtime errors: ${errors.join(" | ")}`, + ).toHaveLength(0); + } finally { + await close(); + } + }); + + test("traffic events is locked with self-hosted CTA on oss", async ({ + browser, + }) => { + const { page, close } = await openAs(browser, "oss"); + const errors = collectPageErrors(page); + try { + await navigateTo(page, "/events/traffic"); + + await expect(page.getByTestId(SELF_HOSTED_CTA).first()).toBeVisible(); + await expect(page.getByText(START_TRIAL)).toHaveCount(0); + + expect( + errors, + `unexpected runtime errors: ${errors.join(" | ")}`, + ).toHaveLength(0); + } finally { + await close(); + } + }); + + test("traffic events is unlocked on licensed (no upsell)", async ({ + browser, + }) => { + const { page, close } = await openAs(browser, "licensed"); + const errors = collectPageErrors(page); + try { + await navigateTo(page, "/events/traffic"); + + await expect(page.getByTestId(SELF_HOSTED_CTA)).toHaveCount(0); + await expect(page.getByText(START_TRIAL)).toHaveCount(0); + + expect( + errors, + `unexpected runtime errors: ${errors.join(" | ")}`, + ).toHaveLength(0); + } finally { + await close(); + } + }); +}); diff --git a/e2e/tests/login.spec.ts b/e2e/tests/login.spec.ts new file mode 100644 index 0000000..5615e57 --- /dev/null +++ b/e2e/tests/login.spec.ts @@ -0,0 +1,109 @@ +import { test } from "@playwright/test"; +import * as fs from "fs"; +import * as path from "path"; +import { waitForProxyClustersOnline } from "../helpers/api"; +import { loginToApp } from "../helpers/auth"; + +type TestUser = "owner" | "user"; + +const AUTH_DIR = path.resolve(__dirname, "../fixtures/auth"); + +const credentials: Record = { + owner: { username: "owner@localhost.test", password: "testMe123@" }, + user: { username: "user@localhost.test", password: "testMe123@" }, +}; + +async function loginAndSave( + page: import("@playwright/test").Page, + user: TestUser, +) { + const { username, password } = credentials[user]; + + await page.goto("/"); + + await page.locator("input[id=loginName]").waitFor({ state: "visible" }); + await page.locator("input[id=loginName]").fill(username); + await page.locator("button[id=submit-button]").click(); + await page.locator("input[id=password]").waitFor({ state: "visible" }); + await page.locator("input[id=password]").fill(password); + await page.locator("button[id=submit-button]").click(); + + // After submitting credentials, we land on either: + // - 2FA skip prompt, or + // - the app directly (redirect to localhost:1337) + const skipButton = page.locator("button[name=skip]"); + const appNav = page.getByTestId("left-navigation-item").first(); + const modal = page.getByTestId("setup-netbird-modal"); + const approval = page.getByText("User Approval Pending"); + + const after_login = await Promise.race([ + skipButton.waitFor({ timeout: 15_000 }).then(() => "2fa" as const), + appNav.waitFor({ timeout: 15_000 }).then(() => "app" as const), + modal.waitFor({ timeout: 15_000 }).then(() => "modal" as const), + approval.waitFor({ timeout: 15_000 }).then(() => "approval" as const), + ]); + + if (after_login === "2fa") { + await skipButton.click(); + await Promise.race([ + appNav.waitFor({ timeout: 15_000 }), + modal.waitFor({ timeout: 15_000 }), + approval.waitFor({ timeout: 15_000 }), + ]); + } + + // Dismiss setup modal if present + if (await modal.isVisible().catch(() => false)) { + await modal.getByTestId("modal-close").click(); + } + + await page + .context() + .storageState({ path: path.join(AUTH_DIR, `${user}.json`) }); +} + +test.describe("Global Setup", () => { + for (const user of ["owner", "user"] as TestUser[]) { + test(`authenticate ${user}`, async ({ page }) => { + const authFile = path.join(AUTH_DIR, `${user}.json`); + test.skip(fs.existsSync(authFile), `${user} auth file already exists`); + await loginAndSave(page, user); + }); + } + + // Wait for the test reverse-proxy clusters to be registered and online + // before the rest of the suite runs. They come up asynchronously after + // test:setup, so without this the reverse-proxy specs flake when the + // domain picker is still empty. + // + // This deliberately does NOT fail the run if the clusters never appear: + // it only adds a bounded wait so slow registration is absorbed. A hard + // gate would skip the entire suite on any cluster hiccup, which is worse + // than letting the individual reverse-proxy specs report the problem. + test("wait for reverse-proxy clusters to be online", async ({ browser }) => { + test.setTimeout(15_000); + const context = await browser.newContext({ + storageState: path.join(AUTH_DIR, "owner.json"), + }); + const page = await context.newPage(); + try { + // storageState only carries the Zitadel session cookies — the app + // still needs the OIDC redirect flow to get an access token before + // it makes any API call, so log in like every other consumer does. + await loginToApp(page, "owner"); + await waitForProxyClustersOnline(page, [ + "example.com", + "noports.example.com", + ]); + } catch (err) { + // eslint-disable-next-line no-console + console.warn( + `[setup] proxy clusters not confirmed online; reverse-proxy specs may be affected: ${ + (err as Error).message + }`, + ); + } finally { + await context.close(); + } + }); +}); diff --git a/e2e/tests/network-routes.spec.ts b/e2e/tests/network-routes.spec.ts new file mode 100644 index 0000000..c35df20 --- /dev/null +++ b/e2e/tests/network-routes.spec.ts @@ -0,0 +1,176 @@ +import { test, expect } from "../helpers/fixtures"; +import { navigateTo } from "../helpers/auth"; +import { generateRandomName } from "../helpers/utils"; +import { deleteGroupsByPrefix, deleteRoutesByNetworkIdPrefix } from "../helpers/api"; + +const networkRoutes: string[] = []; +let networkRoutesCreatedGroups: string[] = []; + +async function closePopover( + page: import("@playwright/test").Page, + selectorCy: string, +) { + await page.getByTestId(`${selectorCy}-search`).press("Escape"); + await expect(page.getByTestId(`${selectorCy}-search`)).not.toBeVisible(); +} + +test.describe.serial("Network Routes @network", () => { + test("Should create a network route with IP range", async ({ dashboardAsOwner: page }) => { + // Clean up leftovers from previous runs + await deleteRoutesByNetworkIdPrefix(page, "network-route-"); + await deleteGroupsByPrefix(page, "route-peer-"); + await deleteGroupsByPrefix(page, "route-dist-"); + await deleteGroupsByPrefix(page, "route-acl-"); + await navigateTo(page, "/network-routes"); + + const peerGroup = generateRandomName("route-peer-"); + const distGroup = generateRandomName("route-dist-"); + const aclGroup = generateRandomName("route-acl-"); + networkRoutesCreatedGroups.push(peerGroup, distGroup, aclGroup); + + const name = generateRandomName("network-route-"); + await createNetworkRoute(page, { + name, + range: "192.168.1.0/24", + peer_groups: [peerGroup], + distribution_groups: [distGroup], + access_control_groups: [aclGroup], + description: "This is a test route", + }); + networkRoutes.push(name); + }); + + test("Should create a network route with domains", async ({ dashboardAsOwner: page }) => { + const peerGroup = generateRandomName("route-peer-"); + const distGroup = generateRandomName("route-dist-"); + const aclGroup = generateRandomName("route-acl-"); + networkRoutesCreatedGroups.push(peerGroup, distGroup, aclGroup); + + const name = generateRandomName("network-route-"); + await createNetworkRoute(page, { + name, + domains: ["netbird.io"], + peer_groups: [peerGroup], + distribution_groups: [distGroup], + access_control_groups: [aclGroup], + description: "This is a test route with domains", + }); + networkRoutes.push(name); + }); + + test("Should delete network routes", async ({ dashboardAsOwner: page }) => { + for (const route of networkRoutes) { + await deleteNetworkRoute(page, route); + } + }); + + test("Should delete created groups", async ({ dashboardAsOwner: page }) => { + for (const prefix of networkRoutesCreatedGroups) { + await deleteGroupsByPrefix(page, prefix); + } + networkRoutesCreatedGroups = []; + }); +}); + +async function createNetworkRoute( + page: import("@playwright/test").Page, + opts: { + range?: string; + domains?: string[]; + peer_groups?: string[]; + distribution_groups?: string[]; + access_control_groups?: string[]; + name: string; + description?: string; + masquerade?: boolean; + metric?: string; + }, +) { + await page.getByTestId("open-add-route").click(); + + if (opts.range) { + await page.getByTestId("network-range").fill(opts.range); + } + + if (opts.domains && opts.domains.length > 0) { + await page.getByTestId("route-type-domains").click(); + for (const domain of opts.domains) { + await page.getByTestId("add-domain").click(); + await page.getByTestId("domain-input").last().fill(domain); + } + } + + if (opts.peer_groups && opts.peer_groups.length > 0) { + await page.getByTestId("route-tab-peer-group").click(); + await page.getByTestId("routing-peer-groups-selector").click(); + for (const group of opts.peer_groups) { + const search = page.getByTestId("routing-peer-groups-selector-search"); + await expect(search).toBeVisible({ timeout: 10_000 }); + await search.fill(group); + await search.press("Enter"); + } + await closePopover(page, "routing-peer-groups-selector"); + } + + await page.getByTestId("route-continue").click(); + + if (opts.distribution_groups && opts.distribution_groups.length > 0) { + await page.getByTestId("distribution-groups-selector").click(); + for (const group of opts.distribution_groups) { + const search = page.getByTestId("distribution-groups-selector-search"); + await expect(search).toBeVisible(); + await search.fill(group); + await search.press("Enter"); + } + await closePopover(page, "distribution-groups-selector"); + } + + if (opts.access_control_groups && opts.access_control_groups.length > 0) { + await page.getByTestId("access-control-groups-selector").click(); + for (const group of opts.access_control_groups) { + const search = page.getByTestId("access-control-groups-selector-search"); + await expect(search).toBeVisible(); + await search.fill(group); + await search.press("Enter"); + } + await closePopover(page, "access-control-groups-selector"); + } + + await page.getByTestId("route-continue").click(); + + await page.getByTestId("network-identifier").fill(opts.name); + if (opts.description) { + await page.getByTestId("description").fill(opts.description); + } + + await page.getByTestId("route-continue").click(); + + if (opts.masquerade === false) { + await page.getByText("Masquerade").click(); + } + + if (opts.metric) { + await page.getByTestId("metric").fill(opts.metric); + } + + await page.getByTestId("submit-route").click(); + + if (opts.access_control_groups && opts.access_control_groups.length > 0) { + await page.getByTestId("confirmation.cancel").click(); + } + + await expect(page.getByTestId(opts.name)).toBeVisible(); +} + +async function deleteNetworkRoute( + page: import("@playwright/test").Page, + name: string, +) { + await page + .locator("tr") + .filter({ hasText: name }) + .getByRole("button", { name: "Delete" }) + .click(); + await page.getByTestId("confirmation.confirm").click(); + await expect(page.getByTestId(name)).not.toBeVisible(); +} diff --git a/e2e/tests/networks.spec.ts b/e2e/tests/networks.spec.ts new file mode 100644 index 0000000..43ced75 --- /dev/null +++ b/e2e/tests/networks.spec.ts @@ -0,0 +1,164 @@ +import { test, expect } from "../helpers/fixtures"; +import { navigateTo } from "../helpers/auth"; +import { generateRandomName } from "../helpers/utils"; +import { deleteGroupsByPrefix, deleteNetworksByPrefix, deletePoliciesByGroupName, deletePoliciesBySubstring } from "../helpers/api"; + +let networkName = ""; +let resourceName = ""; +let policySourceGroup = ""; +let routingPeerGroup = ""; + +test.describe.serial("Networks @network", () => { + test("Should create a network with a resource, policy, and routing peer", async ({ + dashboardAsOwner: page, + }) => { + await navigateTo(page, "/networks"); + + const name = generateRandomName("test-network-"); + networkName = name; + await page.getByTestId("add-network").click(); + await page.getByTestId("network-name-input").fill(name); + await page.getByTestId("network-description-input").fill("E2E test network"); + await page.getByTestId("submit-network").click(); + + // "Add Resource?" → confirm + await page.getByTestId("confirmation.confirm").click({ force: true }); + + // Resource tab + const resName = generateRandomName("test-resource-"); + resourceName = resName; + await page.getByTestId("resource-name-input").fill(resName); + await page.getByTestId("resource-address-input").fill("10.50.0.1"); + await page.getByTestId("resource-optional-settings").click(); + await page.getByTestId("resource-description-input").fill("E2E test resource"); + await page.getByTestId("resource-continue").click(); + + // Access control tab — add policy + await page.getByTestId("add-policy").click(); + const srcGroup = generateRandomName("net-src-group-"); + policySourceGroup = srcGroup; + await page.getByTestId("source-group-selector").click(); + await page.getByTestId("source-group-selector-search").fill(srcGroup); + await page.getByTestId("source-group-selector-search").press("Enter"); + await page.getByTestId("source-group-selector-search").press("Escape"); + await page.getByTestId("policy-continue").click(); + await page.getByTestId("policy-continue").click(); + await page.getByTestId("submit-policy").click(); + + // Submit resource + await page.getByTestId("submit-resource").click(); + + // "Add Routing Peer?" → confirm + await page.getByTestId("confirmation.confirm").click({ force: true }); + + // Routing peer + await page.getByTestId("routing-peer-tab-group").click({ force: true }); + const rpGroup = generateRandomName("net-rp-group-"); + routingPeerGroup = rpGroup; + await page.getByTestId("group-selector-dropdown").click(); + await page.getByTestId("group-selector-dropdown-search").fill(rpGroup); + await page.getByTestId("group-selector-dropdown-search").press("Enter"); + await page.getByTestId("group-selector-dropdown-search").press("Escape"); + await page.getByTestId("routing-peer-continue").click(); + await page.getByTestId("toggle-masquerade").click(); + await page.getByTestId("metric").fill("100"); + await page.getByTestId("submit-routing-peer").click(); + + // Verify network in table + await expect(page.locator("tr").filter({ hasText: name })).toBeVisible({ timeout: 10_000 }); + }); + + test("Should add a CIDR range resource", async ({ dashboardAsOwner: page }) => { + await addResourceToNetwork(page, "cidr-resource-", "192.168.100.0/24"); + }); + + test("Should add a domain resource", async ({ dashboardAsOwner: page }) => { + await addResourceToNetwork(page, "domain-resource-", "resource.internal"); + }); + + test("Should rename the network from the table", async ({ dashboardAsOwner: page }) => { + // Page is already on /networks from previous test + const row = page.locator("tr").filter({ hasText: networkName }); + await expect(row).toBeVisible(); + + await row.getByTestId("network-actions").click({ force: true }); + await page.getByTestId("rename-network").click({ force: true }); + + const newName = generateRandomName("test-network-"); + await page.getByTestId("network-name-input").fill(newName); + await page.getByTestId("network-description-input").fill("Updated description"); + await page.getByTestId("submit-network").click(); + + await expect(page.locator("tr").filter({ hasText: newName })).toBeVisible({ timeout: 10_000 }); + networkName = newName; + }); + + test("Should navigate to the network detail page and verify tabs", async ({ + dashboardAsOwner: page, + }) => { + await navigateTo(page, "/networks"); + const row = page.locator("tr").filter({ hasText: networkName }); + await expect(row).toBeVisible({ timeout: 10_000 }); + await row.locator("button").first().click(); + + // Wait for detail page to load (tab bar appears) + await expect(page.locator('[role="tab"]').filter({ hasText: "Resource" })).toBeVisible(); + await expect(page.getByText(resourceName).first()).toBeVisible({ timeout: 10_000 }); + + // Routing Peers tab + await page.getByTestId("network-tab-routing-peers").click(); + await expect(page.getByText(routingPeerGroup).first()).toBeVisible(); + + // Services tab + await page.getByTestId("network-tab-services").click(); + await expect(page.getByTestId("network-tab-services")).toHaveAttribute("data-state", "active"); + }); + + test("Should rename the network from the detail page", async ({ dashboardAsOwner: page }) => { + // Already on the detail page from previous test + await page.getByTestId("network-detail-actions").click(); + await page.getByTestId("rename-network").click({ force: true }); + + const newName = generateRandomName("test-network-"); + await page.getByTestId("network-name-input").fill(newName); + await page.getByTestId("network-description-input").fill("Renamed from detail page"); + await page.getByTestId("submit-network").click(); + + await expect(page.getByText(newName).first()).toBeVisible(); + networkName = newName; + }); + + test("Should delete the network and clean up", async ({ dashboardAsOwner: page }) => { + await deleteNetworksByPrefix(page, "test-network-"); + await deletePoliciesByGroupName(page, policySourceGroup); + await deletePoliciesBySubstring(page, "test-resource-"); + for (const group of [policySourceGroup, routingPeerGroup]) { + await deleteGroupsByPrefix(page, group); + } + }); +}); + +async function addResourceToNetwork( + page: import("@playwright/test").Page, + prefix: string, + address: string, +) { + // Page should already be on /networks from previous test + const row = page.locator("tr").filter({ hasText: networkName }); + await expect(row).toBeVisible(); + + const name = generateRandomName(prefix); + // The per-row resource-add affordance is now an icon "Add" button. + await row.getByTestId("add-resource").click(); + + await expect(page.getByTestId("resource-name-input")).toBeVisible({ timeout: 10_000 }); + await page.getByTestId("resource-name-input").fill(name); + await page.getByTestId("resource-address-input").fill(address); + await page.getByTestId("resource-continue").click(); + + await page.getByTestId("submit-resource").click(); + // "No policies configured" warning + await page.getByTestId("confirmation.confirm").click(); + // "Add Routing Peer?" prompt — wait for it and dismiss + await page.getByTestId("confirmation.cancel").click(); +} diff --git a/e2e/tests/reverse-proxy-crowdsec.spec.ts b/e2e/tests/reverse-proxy-crowdsec.spec.ts new file mode 100644 index 0000000..d311705 --- /dev/null +++ b/e2e/tests/reverse-proxy-crowdsec.spec.ts @@ -0,0 +1,167 @@ +import { test, expect } from "../helpers/fixtures"; +import { navigateTo } from "../helpers/auth"; +import { generateRandomName } from "../helpers/utils"; +import { deleteNetworksByPrefix, deleteServicesByPrefix } from "../helpers/api"; +import { + gotoReverseProxyPage, + selectL4Resource, + selectProxyDomain, + openServiceEdit, + deleteService, + resetServiceFilters, + CUSTOM_PORTS_DOMAIN, +} from "../helpers/reverse-proxy-l4"; + +const DOMAINS_GLOB = "**/reverse-proxies/domains"; + +// Force the test clusters to advertise CrowdSec support so the selector renders, +// independent of whether the test backend has CrowdSec configured. The save +// payload assertion below verifies the real wiring regardless of the backend. +async function forceCrowdSecSupport(page: import("@playwright/test").Page) { + await page.route(DOMAINS_GLOB, async (route) => { + if (route.request().method() !== "GET") return route.continue(); + const response = await route.fetch(); + let body: any; + try { + body = await response.json(); + } catch (e) { + return route.fulfill({ response }); + } + if (Array.isArray(body)) { + body = body.map((d) => ({ ...d, supports_crowdsec: true })); + } + return route.fulfill({ response, json: body }); + }); +} + +test.describe.serial("Reverse Proxy - CrowdSec @reverse-proxy", () => { + let network = ""; + let resource = ""; + let subdomain = ""; + + test("Should configure CrowdSec on a service and send crowdsec_mode on save", async ({ + dashboardAsOwner: page, + }) => { + test.setTimeout(90_000); + await forceCrowdSecSupport(page); + await deleteServicesByPrefix(page, "crowdsec-svc-"); + await deleteNetworksByPrefix(page, "rp-crowdsec-net-"); + + // Create a network with a resource (same inline flow as the L4 specs). + await navigateTo(page, "/networks"); + network = generateRandomName("rp-crowdsec-net-"); + await page.getByTestId("add-network").click(); + await page.getByTestId("network-name-input").fill(network); + await page.getByTestId("submit-network").click(); + await page.getByTestId("confirmation.confirm").click({ force: true }); + + resource = generateRandomName("rp-resource-"); + await page.getByTestId("resource-name-input").fill(resource); + await page.getByTestId("resource-address-input").fill("10.99.99.40"); + await page.getByTestId("resource-continue").click(); + const resourcePromise = page.waitForResponse( + (resp) => + resp.url().includes("/api/networks/") && + resp.url().includes("/resources") && + resp.request().method() === "POST", + { timeout: 30_000 }, + ); + await page.getByTestId("submit-resource").click(); + await page.getByTestId("confirmation.confirm").click({ force: true }); + await resourcePromise; + const cancelBtn = page.getByTestId("confirmation.cancel"); + if (await cancelBtn.isVisible({ timeout: 3000 }).catch(() => false)) { + await cancelBtn.click({ force: true }); + } + + await gotoReverseProxyPage(page, "/reverse-proxy/services"); + subdomain = generateRandomName("crowdsec-svc-"); + + await page.getByTestId("add-service").first().click(); + await expect(page.getByTestId("proxy-subdomain-input")).toBeVisible({ + timeout: 10_000, + }); + await page.getByTestId("proxy-subdomain-input").fill(subdomain); + await selectProxyDomain(page, CUSTOM_PORTS_DOMAIN); + await page + .getByTestId("service-mode-select-button") + .click({ force: true }); + await page.getByTestId("service-mode-option-tcp").click({ force: true }); + await expect(page.getByTestId("group-selector-dropdown")).toBeVisible({ + timeout: 10_000, + }); + await selectL4Resource(page, resource); + await expect(page.getByTestId("listen-port-input")).toBeEnabled({ + timeout: 10_000, + }); + await page.getByTestId("listen-port-input").fill("3306"); + await page.getByTestId("destination-port-input").fill("3306"); + await page.getByTestId("proxy-continue").click(); + + // Access control step: the CrowdSec selector renders for supporting clusters. + const crowdsecTrigger = page.getByTestId("crowdsec-mode-trigger"); + await expect(crowdsecTrigger).toBeVisible({ timeout: 10_000 }); + await crowdsecTrigger.click({ force: true }); + await page.getByTestId("crowdsec-mode-enforce").click({ force: true }); + await expect(crowdsecTrigger).toContainText("Enforce"); + + await page.getByTestId("proxy-continue").click(); + + const savePromise = page.waitForResponse( + (resp) => + resp.url().includes("/reverse-proxies/services") && + resp.request().method() === "POST", + { timeout: 30_000 }, + ); + await page.getByTestId("submit-service").click(); + const saveResp = await savePromise; + + // Core assertion: the configured mode is included in the save payload. + const payload = saveResp.request().postDataJSON(); + expect( + payload?.access_restrictions?.crowdsec_mode, + "crowdsec_mode should be sent in the service payload", + ).toBe("enforce"); + + await resetServiceFilters(page); + await expect( + page.locator("tr").filter({ hasText: subdomain }), + ).toBeVisible({ timeout: 30_000 }); + }); + + test("Should show CrowdSec in the access control cell and persist on reopen", async ({ + dashboardAsOwner: page, + }) => { + test.setTimeout(60_000); + await forceCrowdSecSupport(page); + await gotoReverseProxyPage(page, "/reverse-proxy/services"); + await resetServiceFilters(page); + + // The access control cell counts CrowdSec as a rule and lists it on hover. + const cell = page + .locator("tr") + .filter({ hasText: subdomain }) + .locator("[data-access-control-cell]"); + await expect(cell).toContainText("1", { timeout: 10_000 }); + + // Reopen the service: the selector reflects the persisted Enforce mode. + await openServiceEdit(page, subdomain); + await page.getByTestId("proxy-tab-access-control").click({ force: true }); + await expect(page.getByTestId("crowdsec-mode-trigger")).toContainText( + "Enforce", + { timeout: 10_000 }, + ); + await page.keyboard.press("Escape"); + }); + + test("Should clean up the CrowdSec service and network", async ({ + dashboardAsOwner: page, + }) => { + await forceCrowdSecSupport(page); + await gotoReverseProxyPage(page, "/reverse-proxy/services"); + await resetServiceFilters(page); + await deleteService(page, subdomain); + await deleteNetworksByPrefix(page, network); + await page.unroute(DOMAINS_GLOB); + }); +}); diff --git a/e2e/tests/reverse-proxy-custom-domains.spec.ts b/e2e/tests/reverse-proxy-custom-domains.spec.ts new file mode 100644 index 0000000..1ad918b --- /dev/null +++ b/e2e/tests/reverse-proxy-custom-domains.spec.ts @@ -0,0 +1,87 @@ +import { test, expect } from "../helpers/fixtures"; +import { navigateTo } from "../helpers/auth"; +import { applyRadioTableFilter, generateRandomName } from "../helpers/utils"; +import { gotoReverseProxyPage } from "../helpers/reverse-proxy-l4"; + +let domain = ""; +const TARGET_CLUSTER = "example.com"; + +test.describe.serial("Reverse Proxy - Custom Domains @reverse-proxy", () => { + test("Should validate domain input and add a custom domain", async ({ + dashboardAsOwner: page, + }) => { + await gotoReverseProxyPage(page, "/reverse-proxy/custom-domains"); + + await page.getByTestId("add-custom-domain").click(); + await expect(page.getByTestId("custom-domain-input")).toBeVisible(); + + // Invalid input should show error + await page.getByTestId("custom-domain-input").fill("mycustomdomain"); + await page.getByTestId("custom-domain-input").blur(); + await expect(page.getByText("Please enter a valid TLD domain")).toBeVisible(); + + // Fill valid domain — error should disappear + const prefix = generateRandomName("mycustomdomain-"); + domain = `${prefix}.com`; + await page.getByTestId("custom-domain-input").fill(domain); + await expect(page.getByText("Please enter a valid TLD domain")).toHaveCount(0); + + // Pick the target proxy cluster explicitly — with multiple clusters the + // dashboard does not auto-select. + const clusterSection = page.getByTestId("custom-domain-cluster-selector"); + await clusterSection.locator("button").first().click({ force: true }); + await page + .locator('[role="option"]') + .filter({ has: page.getByText(TARGET_CLUSTER, { exact: true }) }) + .first() + .click({ force: true }); + + const responsePromise = page.waitForResponse( + (resp) => + resp.url().includes("/api/reverse-proxies/domains") && + resp.request().method() === "POST", + { timeout: 30_000 }, + ); + await page.getByTestId("submit-custom-domain").click(); + const response = await responsePromise; + expect([200, 201]).toContain(response.status()); + + await expect(page.getByRole("heading", { name: "Verify Domain" })).toBeVisible(); + await expect(page.getByText(`*.${domain}`)).toBeVisible(); + + await page.getByTestId("verify-domain-later").click(); + + const row = page.locator("tr").filter({ hasText: domain }); + await expect(row).toBeVisible(); + await expect(row).toContainText("Pending Verification"); + await expect(row).toContainText(TARGET_CLUSTER); + }); + + test("Should filter domains by Pending and Active", async ({ dashboardAsOwner: page }) => { + await applyRadioTableFilter(page, "validated", "Pending"); + await expect(page.locator("tr").filter({ hasText: domain })).toBeVisible(); + + await applyRadioTableFilter(page, "validated", "Active"); + await expect(page.locator("tr").filter({ hasText: domain })).not.toBeVisible(); + + await applyRadioTableFilter(page, "validated", "All"); + await expect(page.locator("tr").filter({ hasText: domain })).toBeVisible(); + }); + + test("Should search for the domain", async ({ dashboardAsOwner: page }) => { + const searchInput = page.getByTestId("table-search-input"); + await searchInput.fill(domain); + await expect(page.locator("tr").filter({ hasText: domain })).toBeVisible(); + await searchInput.fill(""); + }); + + test("Should delete the custom domain", async ({ dashboardAsOwner: page }) => { + await page + .locator("tr") + .filter({ hasText: domain }) + .getByTestId("delete-custom-domain") + .click(); + await page.getByTestId("confirmation.confirm").click(); + await expect(page.locator("tr").filter({ hasText: domain })).not.toBeVisible(); + }); +}); diff --git a/e2e/tests/reverse-proxy-services-https.spec.ts b/e2e/tests/reverse-proxy-services-https.spec.ts new file mode 100644 index 0000000..33a403d --- /dev/null +++ b/e2e/tests/reverse-proxy-services-https.spec.ts @@ -0,0 +1,275 @@ +import { test, expect } from "../helpers/fixtures"; +import { navigateTo } from "../helpers/auth"; +import { generateRandomName } from "../helpers/utils"; +import { deleteNetworksByPrefix, deleteServicesByPrefix } from "../helpers/api"; +import { gotoReverseProxyPage, selectProxyDomain, CUSTOM_PORTS_DOMAIN } from "../helpers/reverse-proxy-l4"; + +let createdNetwork = ""; +let createdResource = ""; +let createdSubdomain = ""; + +test.describe.serial("Reverse Proxy - Services (HTTPS) @reverse-proxy", () => { + test("Should create a network with a resource", async ({ dashboardAsOwner: page }) => { + // Clean up leftovers from previous runs (unique prefix per protocol) + await deleteServicesByPrefix(page, "https-svc-"); + await deleteNetworksByPrefix(page, "rp-https-net-"); + await navigateTo(page, "/networks"); + const name = generateRandomName("rp-https-net-"); + createdNetwork = name; + await page.getByTestId("add-network").click(); + await page.getByTestId("network-name-input").fill(name); + await page.getByTestId("submit-network").click(); + await page.getByTestId("confirmation.confirm").click({ force: true }); + + // Add resource + const resName = generateRandomName("rp-resource-"); + createdResource = resName; + await page.getByTestId("resource-name-input").fill(resName); + await page.getByTestId("resource-address-input").fill("10.99.99.10"); + await page.getByTestId("resource-continue").click(); + + const responsePromise = page.waitForResponse( + (resp) => + resp.url().includes("/api/networks/") && + resp.url().includes("/resources") && + resp.request().method() === "POST", + { timeout: 30_000 }, + ); + await page.getByTestId("submit-resource").click(); + await page.getByTestId("confirmation.confirm").click({ force: true }); + await responsePromise; + await page.getByTestId("confirmation.cancel").click({ force: true }); + }); + + test("Should create an HTTPS reverse proxy service with full configuration", async ({ + dashboardAsOwner: page, + }) => { + test.setTimeout(60_000); + await gotoReverseProxyPage(page, "/reverse-proxy/services"); + const subdomain = generateRandomName("https-svc-"); + createdSubdomain = subdomain; + + await page.getByTestId("add-service").first().click(); + await expect(page.getByTestId("proxy-subdomain-input")).toBeVisible({ timeout: 10_000 }); + + // Step 1: Service + await page.getByTestId("proxy-subdomain-input").fill(subdomain); + await selectProxyDomain(page, CUSTOM_PORTS_DOMAIN); + + // Add 2 targets: http with options, https with port + await addTarget(page, { + resourceName: createdResource, + protocol: "http", + timeout: "10s", + customHeader: { name: "X-Custom-Header", value: "custom-value" }, + }); + await addTarget(page, { + resourceName: createdResource, + location: "/secure", + protocol: "https", + port: 4433, + }); + + const targetsSection = page.getByText("HTTPS Targets").locator(".."); + await expect(targetsSection.locator("table tbody tr")).toHaveCount(2); + + await page.getByTestId("proxy-continue").click(); + + // Step 2: Authentication + await page.getByTestId("auth-sso-card").click(); + await page.getByTestId("submit-sso").click(); + + await page.getByTestId("auth-password-card").click(); + await page.getByTestId("password-input").fill("super-secret-pass"); + await page.getByTestId("submit-password").click(); + + await page.getByTestId("auth-pin-card").click(); + const pinInputs = page.locator('input[inputmode="numeric"][maxlength="1"]'); + for (let i = 0; i < 6; i++) { + await pinInputs.nth(i).fill(String(i + 1), { force: true }); + } + await page.getByTestId("submit-pin").click(); + + await page.getByTestId("auth-header-card").click(); + await page.getByTestId("header-type-select").click(); + await page.locator("[cmdk-list]").getByText("Basic Auth").click({ force: true }); + await page.getByTestId("header-basic-username").fill("admin"); + await page.getByTestId("header-basic-password").fill("admin-pass"); + await page.getByTestId("submit-headers").click(); + + await page.getByTestId("proxy-continue").click(); + + // Step 3: Access Control + await page.getByTestId("add-access-rule").click(); + await page.getByTestId("access-rule-0").getByText("Select country...").click(); + await page.getByTestId("select-dropdown-search").fill("Germany"); + await page.getByText("Germany (DE)").click({ force: true }); + + await page.getByTestId("add-access-rule").click(); + await page.getByTestId("access-rule-1").getByTestId("access-rule-action").click(); + await page.getByText("Block Only").click({ force: true }); + await page.getByTestId("access-rule-1").getByTestId("access-rule-type").click(); + await page.locator('[role="option"]').filter({ hasText: "IP Address" }).click({ force: true }); + const ipInput = page.getByTestId("access-rule-1").getByTestId("access-rule-value"); + await expect(ipInput).toBeVisible(); + await ipInput.fill("85.203.15.42"); + + await page.getByTestId("proxy-continue").click(); + + // Step 4: Advanced Settings + await page.getByTestId("toggle-pass-host-header").click(); + await page.getByTestId("toggle-rewrite-redirects").click(); + await page.getByTestId("submit-service").click(); + + await expect(page.locator("tr").filter({ hasText: subdomain })).toBeVisible({ timeout: 30_000 }); + }); + + test("Should edit the service, remove auth and rules, then delete", async ({ + dashboardAsOwner: page, + }) => { + await resetServiceFilters(page); + await page.locator("tr").filter({ hasText: createdSubdomain }).getByTestId("service-actions").click({ force: true }); + await page.getByTestId("edit-service").click({ force: true }); + + // Edit first target + const targetsSection = page.getByText("HTTPS Targets").locator(".."); + await targetsSection.locator("table tbody tr").first().click({ force: true }); + await page.getByTestId("target-location-input").fill("/new-location"); + await page.getByTestId("submit-target").click(); + + // Remove second target + await targetsSection.locator("table tbody tr").filter({ hasText: "/secure" }).getByTestId("target-row-actions").click(); + await page.getByTestId("remove-target").click(); + await expect(targetsSection.locator("table tbody tr")).toHaveCount(1); + + // Remove all auth methods — click Edit on each card, then Remove in the modal + await page.getByTestId("proxy-tab-auth").click({ force: true }); + await removeAuthMethod(page, "auth-sso-card", "remove-sso"); + await removeAuthMethod(page, "auth-password-card", "remove-password"); + await removeAuthMethod(page, "auth-pin-card", "remove-pin"); + await removeAuthMethod(page, "auth-header-card", "remove-headers"); + + // Remove access control rules + await page.getByTestId("proxy-tab-access-control").click({ force: true }); + await page.getByTestId("remove-access-rule").last().click({ force: true }); + await page.getByTestId("remove-access-rule").first().click({ force: true }); + + // Toggle advanced settings back + await page.getByTestId("proxy-tab-settings").click({ force: true }); + await page.getByTestId("toggle-pass-host-header").click({ force: true }); + await page.getByTestId("toggle-rewrite-redirects").click({ force: true }); + + // Save and wait for API response + const saveResponse = page.waitForResponse( + (resp) => + resp.url().includes("/api/reverse-proxies/services") && + resp.request().method() === "PUT", + { timeout: 15_000 }, + ); + await page.getByTestId("proxy-save").click(); + const confirmBtn = page.getByTestId("confirmation.confirm"); + if (await confirmBtn.isVisible({ timeout: 3_000 }).catch(() => false)) { + await confirmBtn.click({ force: true }); + } + await saveResponse; + + // Verify no auth / no access rules: both cells now show a "0" count badge. + await resetServiceFilters(page); + const row = page.locator("tr").filter({ hasText: createdSubdomain }); + await expect(row.locator("[data-auth-cell]")).toContainText("0", { + timeout: 15_000, + }); + await expect( + row.locator("[data-access-control-cell]"), + ).toContainText("0", { timeout: 15_000 }); + + // Delete the service + await row.getByTestId("service-actions").click({ force: true }); + await page.getByTestId("delete-service").click({ force: true }); + await page.getByTestId("confirmation.confirm").click({ force: true }); + await expect(row).not.toBeVisible(); + }); + + test("Should delete the network", async ({ dashboardAsOwner: page }) => { + await deleteNetworksByPrefix(page, createdNetwork); + }); +}); + +async function resetServiceFilters(page: import("@playwright/test").Page) { + const resetBtn = page.getByTestId("reset-filters-and-search"); + if (await resetBtn.isVisible().catch(() => false)) { + await resetBtn.click(); + } +} + +type AddTargetOptions = { + resourceName: string; + location?: string; + protocol?: "http" | "https"; + port?: number; + timeout?: string; + customHeader?: { name: string; value: string }; +}; + +async function addTarget(page: import("@playwright/test").Page, opts: AddTargetOptions) { + await page.getByTestId("add-target").scrollIntoViewIfNeeded(); + await page.getByTestId("add-target").click(); + await expect(page.getByTestId("group-selector-dropdown")).toBeVisible({ timeout: 10_000 }); + + await page.getByTestId("group-selector-dropdown").click(); + await page.locator('[role="tab"]').filter({ hasText: "Resources" }).click({ force: true }); + const search = page.getByTestId("group-selector-dropdown-search"); + await expect(search).toBeVisible({ timeout: 5_000 }); + await search.fill(opts.resourceName); + await page.getByText(opts.resourceName).click({ force: true, timeout: 15_000 }); + await expect(page.getByTestId("target-port-input")).toBeVisible({ timeout: 10_000 }); + + if (opts.location) { + await expect(page.getByTestId("target-location-input")).toBeEnabled({ timeout: 5_000 }); + await page.getByTestId("target-location-input").fill(opts.location); + } + + if (opts.protocol === "https") { + await page.getByTestId("target-protocol-select").click(); + await page.locator("[cmdk-list]").getByText("https://").click({ force: true }); + } + + if (opts.port !== undefined) { + await page.getByTestId("target-port-input").fill(String(opts.port)); + } else { + await page.getByTestId("target-port-input").fill(""); + } + + if (opts.timeout || opts.customHeader) { + await page.getByTestId("target-optional-settings").click(); + if (opts.timeout) { + await page.getByTestId("target-timeout-input").fill(opts.timeout); + } + if (opts.customHeader) { + await page.getByTestId("add-custom-header").click(); + await page.getByTestId("custom-header-name-0").fill(opts.customHeader.name); + await page.getByTestId("custom-header-value-0").fill(opts.customHeader.value); + } + } + + await page.getByTestId("submit-target").click(); +} + +async function removeAuthMethod( + page: import("@playwright/test").Page, + cardTestId: string, + removeTestId: string, +) { + const card = page.getByTestId(cardTestId); + const removeBtn = page.getByTestId(removeTestId); + + // Click the card to open the auth modal + await card.click(); + await expect(removeBtn).toBeVisible(); + await removeBtn.click(); + + // Wait for the modal to fully close — the remove button must disappear + // and the "Enabled" badge on the card should also disappear + await expect(removeBtn).not.toBeVisible(); + await expect(card.getByText("Enabled")).not.toBeVisible(); +} diff --git a/e2e/tests/reverse-proxy-services-tcp.spec.ts b/e2e/tests/reverse-proxy-services-tcp.spec.ts new file mode 100644 index 0000000..5b54edd --- /dev/null +++ b/e2e/tests/reverse-proxy-services-tcp.spec.ts @@ -0,0 +1,115 @@ +import { test, expect } from "../helpers/fixtures"; +import { navigateTo } from "../helpers/auth"; +import { generateRandomName } from "../helpers/utils"; +import { deleteNetworksByPrefix, deleteServicesByPrefix } from "../helpers/api"; +import { + gotoReverseProxyPage, + selectL4Resource, + addAccessControlRules, + removeAllAccessControlRules, + resetServiceFilters, + openServiceEdit, + deleteService, + saveServiceEdit, + selectProxyDomain, + CUSTOM_PORTS_DOMAIN, +} from "../helpers/reverse-proxy-l4"; + +let tcpNetwork = ""; +let tcpResource = ""; +let tcpSubdomain = ""; + +test.describe.serial("Reverse Proxy - Services (TCP) @reverse-proxy", () => { + test("Should create a network with a resource", async ({ dashboardAsOwner: page }) => { + await deleteServicesByPrefix(page, "tcp-svc-"); + await deleteNetworksByPrefix(page, "rp-tcp-net-"); + await navigateTo(page, "/networks"); + + const name = generateRandomName("rp-tcp-net-"); + tcpNetwork = name; + await page.getByTestId("add-network").click(); + await page.getByTestId("network-name-input").fill(name); + await page.getByTestId("submit-network").click(); + await page.getByTestId("confirmation.confirm").click({ force: true }); + + const resName = generateRandomName("rp-resource-"); + tcpResource = resName; + await page.getByTestId("resource-name-input").fill(resName); + await page.getByTestId("resource-address-input").fill("10.99.99.30"); + await page.getByTestId("resource-continue").click(); + + const responsePromise = page.waitForResponse( + (resp) => + resp.url().includes("/api/networks/") && + resp.url().includes("/resources") && + resp.request().method() === "POST", + { timeout: 30_000 }, + ); + await page.getByTestId("submit-resource").click(); + await page.getByTestId("confirmation.confirm").click({ force: true }); + await responsePromise; + const cancelBtn = page.getByTestId("confirmation.cancel"); + if (await cancelBtn.isVisible({ timeout: 3000 }).catch(() => false)) { + await cancelBtn.click({ force: true }); + } + }); + + test("Should create a TCP service", async ({ dashboardAsOwner: page }) => { + await gotoReverseProxyPage(page, "/reverse-proxy/services"); + const subdomain = generateRandomName("tcp-svc-"); + tcpSubdomain = subdomain; + + await page.getByTestId("add-service").first().click(); + await expect(page.getByTestId("proxy-subdomain-input")).toBeVisible({ timeout: 10_000 }); + await page.getByTestId("proxy-subdomain-input").fill(subdomain); + await selectProxyDomain(page, CUSTOM_PORTS_DOMAIN); + await page.getByTestId("service-mode-select-button").click({ force: true }); + await page.getByTestId("service-mode-option-tcp").click({ force: true }); + await expect(page.getByTestId("group-selector-dropdown")).toBeVisible({ timeout: 10_000 }); + + await selectL4Resource(page, tcpResource); + await expect(page.getByTestId("listen-port-input")).toBeEnabled({ timeout: 10_000 }); + await page.getByTestId("listen-port-input").fill("3306"); + await page.getByTestId("destination-port-input").fill("3306"); + await page.getByTestId("proxy-continue").click(); + + await addAccessControlRules(page); + await page.getByTestId("proxy-continue").click(); + + await page.getByTestId("connection-timeout-input").fill("20s"); + await page.getByTestId("toggle-preserve-client-ip").click(); + await page.getByTestId("submit-service").click(); + + await resetServiceFilters(page); + await expect(page.locator("tr").filter({ hasText: subdomain }).getByText("TCP", { exact: true })).toBeVisible({ timeout: 30_000 }); + }); + + test("Should edit the TCP service and delete it", async ({ dashboardAsOwner: page }) => { + await openServiceEdit(page, tcpSubdomain); + + await page.getByTestId("listen-port-input").fill("5432"); + await page.getByTestId("destination-port-input").fill("5432"); + + await page.getByTestId("proxy-tab-access-control").click({ force: true }); + await removeAllAccessControlRules(page); + + await page.getByTestId("proxy-tab-settings").click({ force: true }); + await page.getByTestId("toggle-preserve-client-ip").click({ force: true }); + await page.getByTestId("connection-timeout-input").fill("15s"); + + await saveServiceEdit(page); + + await resetServiceFilters(page); + const row = page.locator("tr").filter({ hasText: tcpSubdomain }); + await expect(row.locator("[data-access-control-cell]")).toContainText( + "0", + { timeout: 10_000 }, + ); + + await deleteService(page, tcpSubdomain); + }); + + test("Should delete the network", async ({ dashboardAsOwner: page }) => { + await deleteNetworksByPrefix(page, tcpNetwork); + }); +}); diff --git a/e2e/tests/reverse-proxy-services-tls.spec.ts b/e2e/tests/reverse-proxy-services-tls.spec.ts new file mode 100644 index 0000000..f0389b1 --- /dev/null +++ b/e2e/tests/reverse-proxy-services-tls.spec.ts @@ -0,0 +1,117 @@ +import { test, expect } from "../helpers/fixtures"; +import { navigateTo } from "../helpers/auth"; +import { generateRandomName } from "../helpers/utils"; +import { deleteNetworksByPrefix, deleteServicesByPrefix } from "../helpers/api"; +import { + gotoReverseProxyPage, + selectL4Resource, + addAccessControlRules, + removeAllAccessControlRules, + resetServiceFilters, + openServiceEdit, + deleteService, + saveServiceEdit, + selectProxyDomain, + CUSTOM_PORTS_DOMAIN, +} from "../helpers/reverse-proxy-l4"; + +let tlsNetwork = ""; +let tlsResource = ""; +let tlsSubdomain = ""; + +test.describe.serial("Reverse Proxy - Services (TLS Passthrough) @reverse-proxy", () => { + test("Should create a network with a resource", async ({ dashboardAsOwner: page }) => { + // Clean up leftover networks + await deleteServicesByPrefix(page, "tls-svc-"); + await deleteNetworksByPrefix(page, "rp-tls-net-"); + await navigateTo(page, "/networks"); + + // Create network + const name = generateRandomName("rp-tls-net-"); + tlsNetwork = name; + await page.getByTestId("add-network").click(); + await page.getByTestId("network-name-input").fill(name); + await page.getByTestId("submit-network").click(); + await page.getByTestId("confirmation.confirm").click({ force: true }); + + // Add resource directly from the confirmation flow + const resName = generateRandomName("rp-resource-"); + tlsResource = resName; + await page.getByTestId("resource-name-input").fill(resName); + await page.getByTestId("resource-address-input").fill("10.99.99.20"); + await page.getByTestId("resource-continue").click(); + + const responsePromise = page.waitForResponse( + (resp) => + resp.url().includes("/api/networks/") && + resp.url().includes("/resources") && + resp.request().method() === "POST", + { timeout: 30_000 }, + ); + await page.getByTestId("submit-resource").click(); + await page.getByTestId("confirmation.confirm").click({ force: true }); + await responsePromise; + // "Add Routing Peer?" prompt may or may not appear + const cancelBtn = page.getByTestId("confirmation.cancel"); + if (await cancelBtn.isVisible({ timeout: 3000 }).catch(() => false)) { + await cancelBtn.click({ force: true }); + } + }); + + test("Should create a TLS Passthrough service", async ({ dashboardAsOwner: page }) => { + test.setTimeout(60_000); + await gotoReverseProxyPage(page, "/reverse-proxy/services"); + const subdomain = generateRandomName("tls-svc-"); + tlsSubdomain = subdomain; + + await page.getByTestId("add-service").first().click(); + await expect(page.getByTestId("proxy-subdomain-input")).toBeVisible({ timeout: 10_000 }); + await page.getByTestId("proxy-subdomain-input").fill(subdomain); + await selectProxyDomain(page, CUSTOM_PORTS_DOMAIN); + await page.getByTestId("service-mode-select-button").click({ force: true }); + await page.getByTestId("service-mode-option-tls").click({ force: true }); + await expect(page.getByTestId("group-selector-dropdown")).toBeVisible({ timeout: 10_000 }); + + await selectL4Resource(page, tlsResource); + await expect(page.getByTestId("listen-port-input")).toBeEnabled({ timeout: 10_000 }); + await page.getByTestId("listen-port-input").fill("8443"); + await page.getByTestId("destination-port-input").fill("443"); + await page.getByTestId("proxy-continue").click(); + + await addAccessControlRules(page); + await page.getByTestId("proxy-continue").click(); + + await page.getByTestId("toggle-preserve-client-ip").click(); + await page.getByTestId("connection-timeout-input").fill("20s"); + await page.getByTestId("submit-service").click(); + + await resetServiceFilters(page); + await expect(page.locator("tr").filter({ hasText: subdomain }).getByText("TLS Passthrough")).toBeVisible({ timeout: 30_000 }); + }); + + test("Should edit the TLS service and delete it", async ({ dashboardAsOwner: page }) => { + await openServiceEdit(page, tlsSubdomain); + + await page.getByTestId("listen-port-input").fill("9443"); + await page.getByTestId("destination-port-input").fill("8443"); + + await page.getByTestId("proxy-tab-access-control").click({ force: true }); + await removeAllAccessControlRules(page); + + await page.getByTestId("proxy-tab-settings").click({ force: true }); + await page.getByTestId("toggle-preserve-client-ip").click({ force: true }); + await page.getByTestId("connection-timeout-input").fill(""); + + await saveServiceEdit(page); + + await resetServiceFilters(page); + const row = page.locator("tr").filter({ hasText: tlsSubdomain }); + await expect(row.locator("[data-access-control-cell]")).toContainText("0"); + + await deleteService(page, tlsSubdomain); + }); + + test("Should delete the network", async ({ dashboardAsOwner: page }) => { + await deleteNetworksByPrefix(page, tlsNetwork); + }); +}); diff --git a/e2e/tests/reverse-proxy-services-udp-no-custom-ports.spec.ts b/e2e/tests/reverse-proxy-services-udp-no-custom-ports.spec.ts new file mode 100644 index 0000000..653cc02 --- /dev/null +++ b/e2e/tests/reverse-proxy-services-udp-no-custom-ports.spec.ts @@ -0,0 +1,119 @@ +import { test, expect } from "../helpers/fixtures"; +import { navigateTo } from "../helpers/auth"; +import { generateRandomName } from "../helpers/utils"; +import { deleteNetworksByPrefix, deleteServicesByPrefix } from "../helpers/api"; +import { + gotoReverseProxyPage, + selectL4Resource, + addAccessControlRules, + removeAllAccessControlRules, + resetServiceFilters, + openServiceEdit, + deleteService, + saveServiceEdit, + selectProxyDomain, + NO_CUSTOM_PORTS_DOMAIN, +} from "../helpers/reverse-proxy-l4"; + +let udpNetwork = ""; +let udpResource = ""; +let udpSubdomain = ""; + +test.describe.serial("Reverse Proxy - Services (UDP, no custom ports) @reverse-proxy", () => { + test("Should create a network with a resource", async ({ dashboardAsOwner: page }) => { + await deleteServicesByPrefix(page, "udp-np-svc-"); + await deleteNetworksByPrefix(page, "rp-udp-np-net-"); + await navigateTo(page, "/networks"); + + const name = generateRandomName("rp-udp-np-net-"); + udpNetwork = name; + await page.getByTestId("add-network").click(); + await page.getByTestId("network-name-input").fill(name); + await page.getByTestId("submit-network").click(); + await page.getByTestId("confirmation.confirm").click({ force: true }); + + const resName = generateRandomName("rp-resource-"); + udpResource = resName; + await page.getByTestId("resource-name-input").fill(resName); + await page.getByTestId("resource-address-input").fill("10.99.99.41"); + await page.getByTestId("resource-continue").click(); + + const responsePromise = page.waitForResponse( + (resp) => + resp.url().includes("/api/networks/") && + resp.url().includes("/resources") && + resp.request().method() === "POST", + { timeout: 30_000 }, + ); + await page.getByTestId("submit-resource").click(); + await page.getByTestId("confirmation.confirm").click({ force: true }); + await responsePromise; + const cancelBtn = page.getByTestId("confirmation.cancel"); + if (await cancelBtn.isVisible({ timeout: 3000 }).catch(() => false)) { + await cancelBtn.click({ force: true }); + } + }); + + test("Should create a UDP service on the no-custom-ports cluster", async ({ dashboardAsOwner: page }) => { + await gotoReverseProxyPage(page, "/reverse-proxy/services"); + const subdomain = generateRandomName("udp-np-svc-"); + udpSubdomain = subdomain; + + await page.getByTestId("add-service").first().click(); + await expect(page.getByTestId("proxy-subdomain-input")).toBeVisible({ timeout: 10_000 }); + await page.getByTestId("proxy-subdomain-input").fill(subdomain); + + await selectProxyDomain(page, NO_CUSTOM_PORTS_DOMAIN); + + await page.getByTestId("service-mode-select-button").click({ force: true }); + await page.getByTestId("service-mode-option-udp").click({ force: true }); + await expect(page.getByTestId("group-selector-dropdown")).toBeVisible({ timeout: 10_000 }); + + await selectL4Resource(page, udpResource); + + // Listen port is auto-assigned when the cluster has custom ports disabled + await expect(page.getByTestId("listen-port-input")).toBeDisabled({ timeout: 10_000 }); + await expect(page.getByTestId("listen-port-input")).toHaveAttribute("placeholder", "Auto"); + + await page.getByTestId("destination-port-input").fill("5060"); + await page.getByTestId("proxy-continue").click(); + + await addAccessControlRules(page); + await page.getByTestId("proxy-continue").click(); + + await page.getByTestId("connection-timeout-input").fill("30s"); + await page.getByTestId("submit-service").click(); + + await resetServiceFilters(page); + const row = page.locator("tr").filter({ hasText: subdomain }); + await expect(row.getByText("UDP", { exact: true })).toBeVisible({ timeout: 30_000 }); + await expect(row).toContainText(NO_CUSTOM_PORTS_DOMAIN); + }); + + test("Should edit the UDP service and delete it", async ({ dashboardAsOwner: page }) => { + await openServiceEdit(page, udpSubdomain); + + // Listen port must remain auto-assigned on this cluster + await expect(page.getByTestId("listen-port-input")).toBeDisabled(); + + await page.getByTestId("destination-port-input").fill("5061"); + + await page.getByTestId("proxy-tab-access-control").click({ force: true }); + await removeAllAccessControlRules(page); + + await page.getByTestId("proxy-tab-settings").click({ force: true }); + await page.getByTestId("connection-timeout-input").fill(""); + + await saveServiceEdit(page); + + await resetServiceFilters(page); + const row = page.locator("tr").filter({ hasText: udpSubdomain }); + await expect(row.locator("[data-access-control-cell]")).toContainText("0"); + + await deleteService(page, udpSubdomain); + }); + + test("Should delete the network", async ({ dashboardAsOwner: page }) => { + await deleteNetworksByPrefix(page, udpNetwork); + }); +}); diff --git a/e2e/tests/reverse-proxy-services-udp.spec.ts b/e2e/tests/reverse-proxy-services-udp.spec.ts new file mode 100644 index 0000000..7cc39cd --- /dev/null +++ b/e2e/tests/reverse-proxy-services-udp.spec.ts @@ -0,0 +1,111 @@ +import { test, expect } from "../helpers/fixtures"; +import { navigateTo } from "../helpers/auth"; +import { generateRandomName } from "../helpers/utils"; +import { deleteNetworksByPrefix, deleteServicesByPrefix } from "../helpers/api"; +import { + gotoReverseProxyPage, + selectL4Resource, + addAccessControlRules, + removeAllAccessControlRules, + resetServiceFilters, + openServiceEdit, + deleteService, + saveServiceEdit, + selectProxyDomain, + CUSTOM_PORTS_DOMAIN, +} from "../helpers/reverse-proxy-l4"; + +let udpNetwork = ""; +let udpResource = ""; +let udpSubdomain = ""; + +test.describe.serial("Reverse Proxy - Services (UDP) @reverse-proxy", () => { + test("Should create a network with a resource", async ({ dashboardAsOwner: page }) => { + await deleteServicesByPrefix(page, "udp-svc-"); + await deleteNetworksByPrefix(page, "rp-udp-net-"); + await navigateTo(page, "/networks"); + + const name = generateRandomName("rp-udp-net-"); + udpNetwork = name; + await page.getByTestId("add-network").click(); + await page.getByTestId("network-name-input").fill(name); + await page.getByTestId("submit-network").click(); + await page.getByTestId("confirmation.confirm").click({ force: true }); + + const resName = generateRandomName("rp-resource-"); + udpResource = resName; + await page.getByTestId("resource-name-input").fill(resName); + await page.getByTestId("resource-address-input").fill("10.99.99.40"); + await page.getByTestId("resource-continue").click(); + + const responsePromise = page.waitForResponse( + (resp) => + resp.url().includes("/api/networks/") && + resp.url().includes("/resources") && + resp.request().method() === "POST", + { timeout: 30_000 }, + ); + await page.getByTestId("submit-resource").click(); + await page.getByTestId("confirmation.confirm").click({ force: true }); + await responsePromise; + const cancelBtn = page.getByTestId("confirmation.cancel"); + if (await cancelBtn.isVisible({ timeout: 3000 }).catch(() => false)) { + await cancelBtn.click({ force: true }); + } + }); + + test("Should create a UDP service", async ({ dashboardAsOwner: page }) => { + await gotoReverseProxyPage(page, "/reverse-proxy/services"); + const subdomain = generateRandomName("udp-svc-"); + udpSubdomain = subdomain; + + await page.getByTestId("add-service").first().click(); + await expect(page.getByTestId("proxy-subdomain-input")).toBeVisible({ timeout: 10_000 }); + await page.getByTestId("proxy-subdomain-input").fill(subdomain); + await selectProxyDomain(page, CUSTOM_PORTS_DOMAIN); + await page.getByTestId("service-mode-select-button").click({ force: true }); + await page.getByTestId("service-mode-option-udp").click({ force: true }); + // Wait for mode switch to take effect + await expect(page.getByTestId("group-selector-dropdown")).toBeVisible({ timeout: 10_000 }); + + await selectL4Resource(page, udpResource); + await expect(page.getByTestId("listen-port-input")).toBeEnabled({ timeout: 10_000 }); + await page.getByTestId("listen-port-input").fill("5060"); + await page.getByTestId("destination-port-input").fill("5060"); + await page.getByTestId("proxy-continue").click(); + + await addAccessControlRules(page); + await page.getByTestId("proxy-continue").click(); + + await page.getByTestId("connection-timeout-input").fill("30s"); + await page.getByTestId("submit-service").click(); + + await resetServiceFilters(page); + await expect(page.locator("tr").filter({ hasText: subdomain }).getByText("UDP", { exact: true })).toBeVisible({ timeout: 30_000 }); + }); + + test("Should edit the UDP service and delete it", async ({ dashboardAsOwner: page }) => { + await openServiceEdit(page, udpSubdomain); + + await page.getByTestId("listen-port-input").fill("5061"); + await page.getByTestId("destination-port-input").fill("5061"); + + await page.getByTestId("proxy-tab-access-control").click({ force: true }); + await removeAllAccessControlRules(page); + + await page.getByTestId("proxy-tab-settings").click({ force: true }); + await page.getByTestId("connection-timeout-input").fill(""); + + await saveServiceEdit(page); + + await resetServiceFilters(page); + const row = page.locator("tr").filter({ hasText: udpSubdomain }); + await expect(row.locator("[data-access-control-cell]")).toContainText("0"); + + await deleteService(page, udpSubdomain); + }); + + test("Should delete the network", async ({ dashboardAsOwner: page }) => { + await deleteNetworksByPrefix(page, udpNetwork); + }); +}); diff --git a/e2e/tests/settings-authentication.spec.ts b/e2e/tests/settings-authentication.spec.ts new file mode 100644 index 0000000..d60bc60 --- /dev/null +++ b/e2e/tests/settings-authentication.spec.ts @@ -0,0 +1,80 @@ +import { test, expect } from "../helpers/fixtures"; +import { navigateTo } from "../helpers/auth"; + +test.describe.serial("Settings - Authentication @settings", () => { + test("Should toggle peer approval", async ({ dashboardAsOwner: page }) => { + await navigateTo(page, "/settings"); + await toggleAndSave(page, "peer-approval"); + }); + + test("Should toggle peer login expiration off and back on", async ({ + dashboardAsOwner: page, + }) => { + await toggleAndSave(page, "peer-login-expiration"); + await toggleAndSave(page, "peer-login-expiration"); + }); + + test("Should change peer login expiration time", async ({ dashboardAsOwner: page }) => { + await ensureToggleState(page, "peer-login-expiration", "checked"); + + // Use a value different from current to ensure the save button enables + const currentValue = await page.getByTestId("peer-login-expiration-input").inputValue(); + const hoursValue = currentValue === "17" ? "22" : "17"; + + await page.getByTestId("peer-login-expiration-input").fill(hoursValue); + await page.getByTestId("peer-login-expiration-select").click(); + await page + .getByTestId("peer-login-expiration-select-content") + .getByText("Hours") + .click(); + await save(page); + await expect(page.getByTestId("peer-login-expiration-input")).toHaveValue(hoursValue); + + // Change to a different days value + const currentDays = await page.getByTestId("peer-login-expiration-input").inputValue(); + const daysValue = currentDays === "180" ? "90" : "180"; + + await page.getByTestId("peer-login-expiration-input").fill(daysValue); + await page.getByTestId("peer-login-expiration-select").click(); + await page + .getByTestId("peer-login-expiration-select-content") + .getByText("Days") + .click(); + await save(page); + await expect(page.getByTestId("peer-login-expiration-input")).toHaveValue(daysValue); + await expect(page.getByTestId("peer-login-expiration-select-value")).toContainText("Days"); + }); + + test("Should toggle peer inactivity expiration", async ({ dashboardAsOwner: page }) => { + await toggleAndSave(page, "peer-inactivity-expiration"); + }); +}); + +async function save(page: import("@playwright/test").Page) { + await page.getByTestId("save-authentication-settings").click(); + await expect(page.getByText("successfully saved").first()).toBeVisible(); +} + +async function toggleAndSave( + page: import("@playwright/test").Page, + name: string, +) { + const toggle = page.getByTestId(name); + const initialState = await toggle.getAttribute("data-state"); + const expectedState = initialState === "checked" ? "unchecked" : "checked"; + await toggle.click(); + await expect(toggle).toHaveAttribute("data-state", expectedState); + await save(page); +} + +async function ensureToggleState( + page: import("@playwright/test").Page, + name: string, + desiredState: "checked" | "unchecked", +) { + const toggle = page.getByTestId(name); + const currentState = await toggle.getAttribute("data-state"); + if (currentState !== desiredState) { + await toggle.click(); + } +} diff --git a/e2e/tests/settings-clients.spec.ts b/e2e/tests/settings-clients.spec.ts new file mode 100644 index 0000000..b8c6592 --- /dev/null +++ b/e2e/tests/settings-clients.spec.ts @@ -0,0 +1,124 @@ +import { test, expect } from "../helpers/fixtures"; +import { navigateTo } from "../helpers/auth"; +import { generateRandomName } from "../helpers/utils"; +import { deleteGroupsByPrefix } from "../helpers/api"; + +let peerExposeGroup = ""; + +test.describe.serial("Settings - Clients @settings", () => { + test("Should set automatic updates to Latest Version with force updates", async ({ + dashboardAsOwner: page, + }) => { + await navigateTo(page, "/settings?tab=clients"); + + // Ensure we start from Disabled so the change to Latest Version is always detected + const currentMethod = await page.getByTestId("auto-update-method").textContent(); + if (currentMethod?.includes("Latest")) { + await selectAutoUpdateMethod(page, "Disabled"); + await save(page); + } + + await selectAutoUpdateMethod(page, "Latest Version"); + const forceToggle = page.getByTestId("force-auto-updates"); + if ((await forceToggle.getAttribute("data-state")) !== "checked") { + await forceToggle.click(); + } + await expect(forceToggle).toHaveAttribute("data-state", "checked"); + await save(page); + }); + + test("Should switch to Custom Version and disable force updates", async ({ + dashboardAsOwner: page, + }) => { + await page.getByTestId("force-auto-updates").click(); + await expect(page.getByTestId("force-auto-updates")).toHaveAttribute("data-state", "unchecked"); + + await selectAutoUpdateMethod(page, "Custom Version"); + await page.getByTestId("auto-update-version-input").fill("0.5"); + await save(page); + }); + + test("Should set automatic updates back to Disabled", async ({ dashboardAsOwner: page }) => { + await selectAutoUpdateMethod(page, "Disabled"); + await save(page); + await expect(page.getByTestId("auto-update-version-input")).toBeDisabled(); + }); + + test("Should enable peer expose with a group", async ({ dashboardAsOwner: page }) => { + // Ensure peer expose starts disabled for a clean test + const toggle = page.getByTestId("peer-expose"); + if ((await toggle.getAttribute("data-state")) === "checked") { + // Remove any existing groups first + const badges = page.getByTestId("group-badge"); + const count = await badges.count(); + for (let i = 0; i < count; i++) { + await badges.first().click(); + } + await toggle.click(); + await expect(toggle).toHaveAttribute("data-state", "unchecked"); + await save(page); + } + + // Now enable and add group + await toggle.click(); + await expect(toggle).toHaveAttribute("data-state", "checked"); + + const name = generateRandomName("expose-group-"); + peerExposeGroup = name; + await page.getByTestId("peer-expose-groups-selector").click(); + const search = page.getByTestId("peer-expose-groups-selector-search"); + await search.fill(name); + await search.press("Enter"); + await search.press("Escape"); + await save(page); + }); + + test("Should remove the group and disable peer expose", async ({ dashboardAsOwner: page }) => { + const toggle = page.getByTestId("peer-expose"); + + // Remove the group badge if it exists + const badge = page.getByTestId("group-badge").filter({ hasText: peerExposeGroup }); + if (await badge.first().isVisible().catch(() => false)) { + await badge.first().click(); + await expect(badge).not.toBeVisible({ timeout: 5_000 }); + } + + // Disable peer expose if enabled + if ((await toggle.getAttribute("data-state")) === "checked") { + await toggle.click(); + } + await expect(toggle).toHaveAttribute("data-state", "unchecked"); + await save(page); + + // Verify peer expose persisted after save + await page.reload(); + await expect(page.getByTestId("peer-expose")).toHaveAttribute("data-state", "unchecked", { timeout: 10_000 }); + await expect(page.getByTestId("peer-expose")).toHaveAttribute("data-state", "unchecked"); + }); + + test("Should toggle lazy connections on and off", async ({ dashboardAsOwner: page }) => { + const toggle = page.getByTestId("lazy-connections"); + await toggle.click(); + await expect(page.getByText("successfully").first()).toBeVisible(); + await toggle.click(); + await expect(page.getByText("successfully").first()).toBeVisible(); + }); + + test("Should delete the created group", async ({ dashboardAsOwner: page }) => { + if (!peerExposeGroup) return; + await deleteGroupsByPrefix(page, peerExposeGroup); + }); +}); + +async function selectAutoUpdateMethod( + page: import("@playwright/test").Page, + label: string, +) { + await page.getByTestId("auto-update-method").click({ force: true }); + await page.locator("[cmdk-list]").getByText(label).click(); +} + +async function save(page: import("@playwright/test").Page) { + await page.getByTestId("save-clients-settings").click(); + await expect(page.getByText("successfully updated").first()).toBeVisible(); +} diff --git a/e2e/tests/settings-groups.spec.ts b/e2e/tests/settings-groups.spec.ts new file mode 100644 index 0000000..8780929 --- /dev/null +++ b/e2e/tests/settings-groups.spec.ts @@ -0,0 +1,24 @@ +import { test, expect } from "../helpers/fixtures"; +import { navigateTo } from "../helpers/auth"; + +test.describe.serial("Settings - Groups @settings", () => { + test("Should toggle user group propagation", async ({ dashboardAsOwner: page }) => { + await navigateTo(page, "/settings?tab=groups"); + + const toggle = page.getByTestId("user-group-propagation"); + const initialState = await toggle.getAttribute("data-state"); + const expectedState = initialState === "checked" ? "unchecked" : "checked"; + + await toggle.click(); + await expect(toggle).toHaveAttribute("data-state", expectedState); + + await page.getByTestId("save-groups-settings").click(); + await expect(page.getByText("updated successfully").first()).toBeVisible(); + await expect(toggle).toHaveAttribute("data-state", expectedState); + + // Toggle back to restore original state + await page.getByTestId("user-group-propagation").click(); + await page.getByTestId("save-groups-settings").click(); + await expect(page.getByText("updated successfully").first()).toBeVisible(); + }); +}); diff --git a/e2e/tests/settings-networks.spec.ts b/e2e/tests/settings-networks.spec.ts new file mode 100644 index 0000000..25d3e4f --- /dev/null +++ b/e2e/tests/settings-networks.spec.ts @@ -0,0 +1,240 @@ +import { test, expect } from "../helpers/fixtures"; +import { navigateTo } from "../helpers/auth"; +import { generateRandomName } from "../helpers/utils"; +import { deleteGroupsByPrefix } from "../helpers/api"; + +let trafficGroup = ""; +let ipv6Group = ""; + +test.describe.serial("Settings - Networks @settings", () => { + test("Should update DNS domain and network range", async ({ dashboardAsOwner: page }) => { + await navigateTo(page, "/settings?tab=networks"); + + const origDomain = await page.getByTestId("dns-domain-input").inputValue(); + const origRange = await page.getByTestId("network-range-input").inputValue(); + + // Use values guaranteed to differ from current + const testDomain = origDomain === "test.internal" ? "test2.internal" : "test.internal"; + const testRange = origRange === "10.100.0.0/16" ? "10.200.0.0/16" : "10.100.0.0/16"; + + await page.getByTestId("dns-domain-input").fill(testDomain); + await page.getByTestId("network-range-input").fill(testRange); + await page.getByTestId("save-network-settings").click(); + await expect(page.getByText("successfully updated").first()).toBeVisible(); + + // Verify UI shows new values + await expect(page.getByTestId("dns-domain-input")).toHaveValue(testDomain); + await expect(page.getByTestId("network-range-input")).toHaveValue(testRange); + + // Revert + await page.getByTestId("dns-domain-input").fill(origDomain || "netbird.selfhosted"); + await page.getByTestId("network-range-input").fill(origRange || "100.64.0.0/10"); + await page.getByTestId("save-network-settings").click(); + await expect(page.getByText("successfully updated").first()).toBeVisible(); + }); + + test("Should toggle DNS wildcard routing", async ({ dashboardAsOwner: page }) => { + await toggleAndRevert(page, "dns-wildcard-routing"); + }); + + test("Should toggle traffic events", async ({ dashboardAsOwner: page }) => { + await toggleAndRevert(page, "traffic-events"); + }); + + test("Should toggle traffic reporting kernel", async ({ dashboardAsOwner: page }) => { + await ensureToggleState(page, "traffic-events", "checked"); + + const toggle = page.getByTestId("traffic-reporting-kernel"); + await expect(toggle).toBeVisible(); + + // Dispatch click via JS to bypass pointer-events interception from parent layout + await toggle.dispatchEvent("click"); + + // Confirmation dialog only appears when turning ON + const confirmBtn = page.getByTestId("confirmation.confirm"); + if (await confirmBtn.isVisible({ timeout: 2_000 }).catch(() => false)) { + await confirmBtn.click({ force: true }); + } + await expect(page.getByText("successfully").first()).toBeVisible(); + + // Toggle back + await page.getByTestId("traffic-reporting-kernel").dispatchEvent("click"); + if (await confirmBtn.isVisible({ timeout: 2_000 }).catch(() => false)) { + await confirmBtn.click({ force: true }); + } + await expect(page.getByText("successfully").first()).toBeVisible(); + }); + + test("Should add a group to traffic events and save", async ({ dashboardAsOwner: page }) => { + // Clean up stale groups from previous runs + await deleteGroupsByPrefix(page, "traffic-group-"); + await navigateTo(page, "/settings?tab=networks"); + + await ensureToggleState(page, "traffic-events", "checked"); + + // Scope to the traffic-events selector so we don't accidentally remove + // badges from other group selectors on the same page (e.g. IPv6 groups). + const trafficSelector = page.getByTestId("traffic-events-groups-selector"); + const existingBadges = trafficSelector.getByTestId("group-badge"); + const badgeCount = await existingBadges.count(); + for (let i = 0; i < badgeCount; i++) { + await existingBadges.first().click({ force: true }); + } + if (badgeCount > 0) { + await page.getByTestId("save-traffic-groups").click({ force: true }); + await expect(page.getByText("successfully updated").first()).toBeVisible(); + } + + const name = generateRandomName("traffic-group-"); + trafficGroup = name; + + await page.getByTestId("traffic-events-groups-selector-open-close").click({ force: true }); + const search = page.getByTestId("traffic-events-groups-selector-search"); + await expect(search).toBeVisible({ timeout: 5_000 }); + await search.fill(name); + await search.press("Enter"); + if (await search.isVisible().catch(() => false)) { + await search.press("Escape"); + } + + await page.getByTestId("save-traffic-groups").click({ force: true }); + await expect(page.getByText("successfully updated").first()).toBeVisible(); + + // Verify group is visible in UI within the traffic selector + await expect(trafficSelector.getByText(name).first()).toBeVisible(); + + // Remove the group (force needed due to parent pointer-events interception) + await trafficSelector.getByTestId("group-badge").filter({ hasText: name }).click({ force: true }); + await page.getByTestId("save-traffic-groups").click({ force: true }); + await expect(page.getByText("successfully updated").first()).toBeVisible(); + }); + + test("Should delete the created traffic group", async ({ dashboardAsOwner: page }) => { + await deleteGroupsByPrefix(page, trafficGroup); + }); + + test("Should update the IPv6 network range", async ({ dashboardAsOwner: page }) => { + await navigateTo(page, "/settings?tab=networks"); + + const input = page.getByTestId("network-range-v6-input"); + await expect(input).toBeVisible(); + const origRange = await input.inputValue(); + + // Pick a value guaranteed to differ from the current one + const testRange = + origRange === "fd00:1234::/64" ? "fd00:5678::/64" : "fd00:1234::/64"; + + await input.fill(testRange); + await page.getByTestId("save-network-settings").click(); + await expect(page.getByText("successfully updated").first()).toBeVisible(); + await expect(input).toHaveValue(testRange); + + // Revert + await input.fill(origRange); + await page.getByTestId("save-network-settings").click(); + await expect(page.getByText("successfully updated").first()).toBeVisible(); + await expect(input).toHaveValue(origRange); + }); + + test("Should reject an invalid IPv6 network range", async ({ dashboardAsOwner: page }) => { + await navigateTo(page, "/settings?tab=networks"); + + const input = page.getByTestId("network-range-v6-input"); + const origRange = await input.inputValue(); + + // Prefix length outside the allowed /48..../112 window + await input.fill("fd00:1234::/32"); + await expect(page.getByTestId("save-network-settings")).toBeDisabled(); + + // Non-IPv6 string + await input.fill("not-an-ip"); + await expect(page.getByTestId("save-network-settings")).toBeDisabled(); + + // Restore so subsequent tests start from a clean state + await input.fill(origRange); + }); + + test("Should add and remove a group from IPv6 enabled groups", async ({ dashboardAsOwner: page }) => { + await deleteGroupsByPrefix(page, "ipv6-group-"); + await navigateTo(page, "/settings?tab=networks"); + + const ipv6Selector = page.getByTestId("ipv6-enabled-groups-selector"); + await expect(ipv6Selector).toBeVisible(); + + // Start from a clean slate: remove any existing badges scoped to this selector + const existingBadges = ipv6Selector.getByTestId("group-badge"); + const badgeCount = await existingBadges.count(); + for (let i = 0; i < badgeCount; i++) { + await existingBadges.first().click({ force: true }); + } + if (badgeCount > 0) { + await page.getByTestId("save-network-settings").click(); + await expect(page.getByText("successfully updated").first()).toBeVisible(); + } + + const name = generateRandomName("ipv6-group-"); + ipv6Group = name; + + await page.getByTestId("ipv6-enabled-groups-selector-open-close").click({ force: true }); + const search = page.getByTestId("ipv6-enabled-groups-selector-search"); + await expect(search).toBeVisible({ timeout: 5_000 }); + await search.fill(name); + await search.press("Enter"); + if (await search.isVisible().catch(() => false)) { + await search.press("Escape"); + } + + await page.getByTestId("save-network-settings").click(); + await expect(page.getByText("successfully updated").first()).toBeVisible(); + + // Verify the new group appears as a badge in the IPv6 selector + await expect( + ipv6Selector.getByTestId("group-badge").filter({ hasText: name }), + ).toBeVisible(); + + // Remove the group via the badge and save again + await ipv6Selector + .getByTestId("group-badge") + .filter({ hasText: name }) + .click({ force: true }); + await page.getByTestId("save-network-settings").click(); + await expect(page.getByText("successfully updated").first()).toBeVisible(); + await expect( + ipv6Selector.getByTestId("group-badge").filter({ hasText: name }), + ).not.toBeVisible(); + }); + + test("Should delete the created IPv6 group", async ({ dashboardAsOwner: page }) => { + await deleteGroupsByPrefix(page, ipv6Group); + }); +}); + +async function toggleAndRevert( + page: import("@playwright/test").Page, + name: string, +) { + const toggle = page.getByTestId(name); + const initialState = await toggle.getAttribute("data-state"); + const expectedState = initialState === "checked" ? "unchecked" : "checked"; + + await toggle.click(); + await expect(page.getByText("successfully").first()).toBeVisible(); + await expect(toggle).toHaveAttribute("data-state", expectedState); + + // Toggle back + await toggle.click(); + await expect(page.getByText("successfully").first()).toBeVisible(); +} + +async function ensureToggleState( + page: import("@playwright/test").Page, + name: string, + desiredState: "checked" | "unchecked", +) { + const toggle = page.getByTestId(name); + const currentState = await toggle.getAttribute("data-state"); + if (currentState !== desiredState) { + await toggle.click(); + await expect(page.getByText("successfully").first()).toBeVisible(); + } +} diff --git a/e2e/tests/settings-notifications-email.spec.ts b/e2e/tests/settings-notifications-email.spec.ts new file mode 100644 index 0000000..55b145d --- /dev/null +++ b/e2e/tests/settings-notifications-email.spec.ts @@ -0,0 +1,75 @@ +import { test, expect } from "../helpers/fixtures"; +import { navigateTo } from "../helpers/auth"; +import { deleteNotificationChannelsByType } from "../helpers/api"; + +const TEST_EMAIL = "notify@example.test"; + +test.describe.serial("Settings - Notifications - Email @notifications", () => { + test("Should add an email recipient", async ({ dashboardAsOwner: page }) => { + await deleteNotificationChannelsByType(page, "email"); + await navigateTo(page, "/settings?tab=notifications"); + await expect(page.getByTestId("notification-channel-email")).toBeVisible({ timeout: 15_000 }); + await page.getByTestId("notification-channel-email").click(); + await expect(page.getByTestId("notification-email-input")).toBeVisible({ timeout: 15_000 }); + + await page.getByTestId("notification-email-input").fill(TEST_EMAIL); + await page.getByTestId("notification-email-add").click(); + await expect( + page.getByTestId("notification-email-recipient").filter({ hasText: TEST_EMAIL }), + ).toBeVisible(); + }); + + test("Should toggle email channel enabled and verify on overview", async ({ + dashboardAsOwner: page, + }) => { + const toggle = page.locator('[data-testid="notification-email-enabled"]'); + if ((await toggle.getAttribute("data-state")) !== "checked") { + await toggle.click(); + } + await expect(toggle).toHaveAttribute("data-state", "checked"); + + await backToOverview(page); + await expect(page.getByTestId("notification-channel-email")).toContainText("Enabled"); + + await page.getByTestId("notification-channel-email").click(); + await page.locator('[data-testid="notification-email-enabled"]').click(); + await backToOverview(page); + await expect(page.getByTestId("notification-channel-email")).toContainText("Disabled"); + }); + + test("Should toggle a notification event", async ({ dashboardAsOwner: page }) => { + await page.getByTestId("notification-channel-email").click(); + const toggle = page.getByTestId("notification-event-peer.pending.approval"); + const initial = await toggle.getAttribute("data-state"); + const expected = initial === "checked" ? "unchecked" : "checked"; + + await toggle.click(); + await expect(toggle).toHaveAttribute("data-state", expected); + + // Toggle back to restore + await toggle.click(); + await expect(toggle).toHaveAttribute("data-state", initial!); + }); + + test("Should remove the email recipient and leave channel disabled", async ({ + dashboardAsOwner: page, + }) => { + await page + .getByTestId("notification-email-recipient") + .filter({ hasText: TEST_EMAIL }) + .click({ force: true }); + await expect( + page.getByTestId("notification-email-recipient").filter({ hasText: TEST_EMAIL }), + ).not.toBeVisible(); + + const toggle = page.locator('[data-testid="notification-email-enabled"]'); + if ((await toggle.getAttribute("data-state")) === "checked") { + await toggle.click(); + } + await expect(toggle).toHaveAttribute("data-state", "unchecked"); + }); +}); + +async function backToOverview(page: import("@playwright/test").Page) { + await page.getByTestId("breadcrumb-item").filter({ hasText: "Notifications" }).click(); +} diff --git a/e2e/tests/settings-notifications-slack.spec.ts b/e2e/tests/settings-notifications-slack.spec.ts new file mode 100644 index 0000000..fd58448 --- /dev/null +++ b/e2e/tests/settings-notifications-slack.spec.ts @@ -0,0 +1,55 @@ +import { test, expect } from "../helpers/fixtures"; +import { navigateTo } from "../helpers/auth"; +import { deleteNotificationChannelsByType } from "../helpers/api"; + +test.describe.serial("Settings - Notifications - Slack @notifications", () => { + test("Should connect Slack through the 2-step wizard", async ({ dashboardAsOwner: page }) => { + await deleteNotificationChannelsByType(page, "slack"); + await navigateTo(page, "/settings?tab=notifications"); + await expect(page.getByTestId("notification-channel-slack")).toBeVisible({ timeout: 15_000 }); + await page.getByTestId("notification-channel-slack").click(); + await expect(page.getByTestId("slack-channel-connect")).toBeVisible({ timeout: 15_000 }); + + await page.getByTestId("slack-channel-connect").click(); + await expect(page.getByText("Create a Slack App")).toBeVisible(); + await page.getByTestId("slack-continue").click({ force: true }); + await expect(page.getByText("Configure Incoming Webhook")).toBeVisible(); + await page.getByTestId("slack-webhook-url-input").fill("https://hooks.slack.com/services/T000/B000/XXXX"); + await page.getByTestId("slack-connect").click(); + await expect(page.getByTestId("slack-actions")).toBeVisible(); + }); + + test("Should show Enabled on overview", async ({ dashboardAsOwner: page }) => { + await backToOverview(page); + await expect(page.getByTestId("notification-channel-slack")).toContainText("Enabled"); + }); + + test("Should toggle a notification event", async ({ dashboardAsOwner: page }) => { + await page.getByTestId("notification-channel-slack").click(); + const toggle = page.getByTestId("notification-event-peer.pending.approval"); + const initial = await toggle.getAttribute("data-state"); + const expected = initial === "checked" ? "unchecked" : "checked"; + + await toggle.click(); + await expect(toggle).toHaveAttribute("data-state", expected); + + await toggle.click(); + await expect(toggle).toHaveAttribute("data-state", initial!); + }); + + test("Should disconnect Slack and show Disabled on overview", async ({ + dashboardAsOwner: page, + }) => { + await page.getByTestId("slack-actions").click({ force: true }); + await page.getByTestId("slack-disconnect").click({ force: true }); + await page.getByTestId("confirmation.confirm").click({ force: true }); + await expect(page.getByTestId("slack-channel-connect")).toBeVisible(); + + await backToOverview(page); + await expect(page.getByTestId("notification-channel-slack")).toContainText("Disabled"); + }); +}); + +async function backToOverview(page: import("@playwright/test").Page) { + await page.getByTestId("breadcrumb-item").filter({ hasText: "Notifications" }).click(); +} \ No newline at end of file diff --git a/e2e/tests/settings-notifications-webhook.spec.ts b/e2e/tests/settings-notifications-webhook.spec.ts new file mode 100644 index 0000000..4c91e81 --- /dev/null +++ b/e2e/tests/settings-notifications-webhook.spec.ts @@ -0,0 +1,130 @@ +import { test, expect } from "../helpers/fixtures"; +import { navigateTo } from "../helpers/auth"; +import { deleteNotificationChannelsByType } from "../helpers/api"; + +test.describe.serial("Settings - Notifications - Webhook @notifications", () => { + test("Should connect a webhook with no authentication", async ({ dashboardAsOwner: page }) => { + await deleteNotificationChannelsByType(page, "webhook"); + await navigateTo(page, "/settings?tab=notifications"); + await expect(page.getByTestId("notification-channel-webhook")).toBeVisible({ timeout: 15_000 }); + await page.getByTestId("notification-channel-webhook").click(); + await expect(page.getByTestId("webhook-connect")).toBeVisible({ timeout: 15_000 }); + + await page.getByTestId("webhook-connect").click(); + await page.getByTestId("webhook-url-input").fill("https://webhook.example/test"); + await expect(page.getByTestId("webhook-auth-type")).toContainText("No Authentication"); + await page.getByTestId("webhook-continue").click(); + await page.getByTestId("webhook-save").click(); + await expect(page.getByTestId("webhook-actions")).toBeVisible(); + }); + + test("Should toggle a notification event", async ({ dashboardAsOwner: page }) => { + const toggle = page.getByTestId("notification-event-peer.pending.approval"); + const initial = await toggle.getAttribute("data-state"); + const expected = initial === "checked" ? "unchecked" : "checked"; + + await toggle.click(); + await expect(toggle).toHaveAttribute("data-state", expected); + + await toggle.click(); + await expect(toggle).toHaveAttribute("data-state", initial!); + }); + + test("Should edit webhook and cycle through auth types", async ({ dashboardAsOwner: page }) => { + // Basic Auth + await openWebhookEdit(page); + await selectWebhookAuth(page, "Basic Auth"); + await page.getByTestId("webhook-basic-username").fill("admin"); + await page.getByTestId("webhook-basic-password").fill("password"); + await page.getByTestId("webhook-save").click(); + + // Bearer Token + await openWebhookEdit(page); + await selectWebhookAuth(page, "Bearer Token"); + await page.getByTestId("webhook-bearer-token").fill("my-bearer-token"); + await page.getByTestId("webhook-save").click(); + + // Custom Auth + await openWebhookEdit(page); + await selectWebhookAuth(page, "Custom Authentication"); + await page.getByTestId("webhook-custom-auth-name").fill("X-API-Key"); + await page.getByTestId("webhook-custom-auth-value").fill("secret-api-key"); + await page.getByTestId("webhook-save").click(); + }); + + test("Should manage custom headers", async ({ dashboardAsOwner: page }) => { + await page.reload(); + // Ensure webhook exists (previous test may have failed) + if (await page.getByTestId("webhook-connect").isVisible().catch(() => false)) { + await page.getByTestId("webhook-connect").click(); + await page.getByTestId("webhook-url-input").fill("https://webhook.example/test"); + await page.getByTestId("webhook-continue").click(); + await page.getByTestId("webhook-save").click(); + await expect(page.getByTestId("webhook-actions")).toBeVisible(); + } + await openWebhookEdit(page); + await page.getByTestId("webhook-tab-headers").click({ force: true }); + + // Remove existing headers + const removeButtons = page.getByTestId("webhook-header-remove"); + const count = await removeButtons.count(); + for (let i = 0; i < count; i++) { + await page.getByTestId("webhook-header-remove").first().click({ force: true }); + } + + // Add new header + await page.getByTestId("webhook-add-header").click({ force: true }); + await page.getByTestId("webhook-header-name").last().fill("X-Custom-Header"); + await page.getByTestId("webhook-header-value").last().fill("my-custom-value"); + await page.getByTestId("webhook-save").click(); + + // Verify persistence + await page.reload(); + await openWebhookEdit(page); + await page.getByTestId("webhook-tab-headers").click({ force: true }); + // Verify the custom header exists (there may be auth headers with the same testid) + const headerNames = page.getByTestId("webhook-header-name"); + const headerCount = await headerNames.count(); + let found = false; + for (let i = 0; i < headerCount; i++) { + if ((await headerNames.nth(i).inputValue()) === "X-Custom-Header") { + found = true; + break; + } + } + expect(found).toBe(true); + await page.getByRole("button", { name: "Cancel" }).click({ force: true }); + }); + + test("Should delete the webhook", async ({ dashboardAsOwner: page }) => { + await page.reload(); + await page.getByTestId("webhook-actions").click({ force: true }); + await page.getByTestId("webhook-delete").click({ force: true }); + await page.getByTestId("confirmation.confirm").click({ force: true }); + await expect(page.getByTestId("webhook-connect")).toBeVisible(); + }); +}); + +async function openWebhookEdit(page: import("@playwright/test").Page) { + await expect(page.getByTestId("webhook-actions")).toBeVisible({ timeout: 10_000 }); + await page.getByTestId("webhook-actions").click({ force: true }); + await expect(page.getByTestId("webhook-edit")).toBeVisible({ timeout: 5_000 }); + await page.getByTestId("webhook-edit").click({ force: true }); +} + +async function selectWebhookAuth(page: import("@playwright/test").Page, label: string) { + await page.getByTestId("webhook-auth-type").click(); + await page.locator("[cmdk-list]").getByText(label).click(); +} + +async function ensureWebhookDisconnected(page: import("@playwright/test").Page) { + await expect( + page.getByTestId("webhook-connect").or(page.getByTestId("webhook-actions")), + ).toBeVisible(); + if (await page.getByTestId("webhook-actions").isVisible().catch(() => false)) { + await page.getByTestId("webhook-actions").click({ force: true }); + await page.getByTestId("webhook-delete").click({ force: true }); + await page.getByTestId("confirmation.confirm").click({ force: true }); + await expect(page.getByTestId("webhook-connect")).toBeVisible(); + } +} diff --git a/e2e/tests/settings-permissions.spec.ts b/e2e/tests/settings-permissions.spec.ts new file mode 100644 index 0000000..f22f0fa --- /dev/null +++ b/e2e/tests/settings-permissions.spec.ts @@ -0,0 +1,41 @@ +import { test, expect } from "../helpers/fixtures"; +import { navigateTo } from "../helpers/auth"; + +test.describe.serial("Settings - Permissions @settings", () => { + test("Should toggle restrict dashboard for regular users", async ({ + dashboardAsOwner: page, + }) => { + await navigateTo(page, "/settings?tab=permissions"); + + const toggle = page.getByTestId("restrict-regular-users"); + await expect(toggle).toBeVisible({ timeout: 15_000 }); + const initialState = await toggle.getAttribute("data-state"); + const expectedState = initialState === "checked" ? "unchecked" : "checked"; + + await toggle.click(); + await expect(toggle).toHaveAttribute("data-state", expectedState); + + await page.getByTestId("save-permissions-settings").click(); + await expect(page.getByText("updated successfully").first()).toBeVisible(); + + // Verify persistence — wait for settings API to load after reload + await Promise.all([ + page.waitForResponse( + (resp) => + resp.url().includes("/api/accounts") && + resp.request().method() === "GET", + ), + page.reload(), + ]); + await expect(page.getByTestId("restrict-regular-users")).toHaveAttribute( + "data-state", + expectedState, + { timeout: 15_000 }, + ); + + // Toggle back to restore original state + await page.getByTestId("restrict-regular-users").click(); + await page.getByTestId("save-permissions-settings").click(); + await expect(page.getByText("updated successfully").first()).toBeVisible(); + }); +}); diff --git a/e2e/tests/setup-keys.spec.ts b/e2e/tests/setup-keys.spec.ts new file mode 100644 index 0000000..aa48831 --- /dev/null +++ b/e2e/tests/setup-keys.spec.ts @@ -0,0 +1,161 @@ +import { test, expect } from "../helpers/fixtures"; +import { navigateTo } from "../helpers/auth"; +import { generateRandomName } from "../helpers/utils"; +import { deleteGroupsByPrefix, deleteSetupKeysByPrefix } from "../helpers/api"; + +let setupKeys: string[] = []; +let setupKeysCreatedGroups: string[] = []; + +test.describe.serial("Setup Keys @setup-keys", () => { + test("Should create a simple setup key", async ({ dashboardAsOwner: page }) => { + // Clean up leftovers from previous runs + await deleteSetupKeysByPrefix(page, "setup-key"); + await deleteGroupsByPrefix(page, "sk-group-"); + await navigateTo(page, "/setup-keys"); + const name = generateRandomName("setup-key"); + await createSetupKey(page, { name }); + setupKeys.push(name); + }); + + test("Should create a reusable setup key", async ({ dashboardAsOwner: page }) => { + const name = generateRandomName("setup-key"); + await createSetupKey(page, { name, reusable: true }); + setupKeys.push(name); + }); + + test("Should create a setup key with all options", async ({ dashboardAsOwner: page }) => { + const group1 = generateRandomName("sk-group-"); + const group2 = generateRandomName("sk-group-"); + setupKeysCreatedGroups.push(group1, group2); + + const name = generateRandomName("setup-key"); + await createSetupKey(page, { + name, + reusable: true, + usageLimit: "100", + expiration: "365", + ephemeral: true, + groups: [group1, group2], + }); + setupKeys.push(name); + }); + + test("Should revoke setup keys", async ({ dashboardAsOwner: page }) => { + for (const name of setupKeys) { + await revokeSetupKey(page, name); + } + }); + + test("Should delete setup keys", async ({ dashboardAsOwner: page }) => { + for (const name of setupKeys) { + await deleteSetupKey(page, name); + } + }); + + test("Should delete created groups", async ({ dashboardAsOwner: page }) => { + for (const prefix of setupKeysCreatedGroups) { + await deleteGroupsByPrefix(page, prefix); + } + setupKeysCreatedGroups = []; + }); +}); + +async function createSetupKey( + page: import("@playwright/test").Page, + opts: { + name: string; + reusable?: boolean; + usageLimit?: string; + expiration?: string; + ephemeral?: boolean; + groups?: string[]; + }, +) { + await page.getByTestId("open-create-setup-key").click(); + await page.getByTestId("setup-key-name").fill(opts.name); + + if (opts.reusable) { + await page.getByText("Make this key reusable").click(); + if (opts.usageLimit) { + await page.getByTestId("setup-key-usage-limit").fill(opts.usageLimit); + } + } + + if (opts.expiration) { + await page.getByTestId("setup-key-expire-in-days").fill(opts.expiration); + } + + if (opts.ephemeral) { + await page.getByText("Ephemeral Peers").click(); + } + + if (opts.groups && opts.groups.length > 0) { + await page.getByTestId("group-selector-dropdown").click(); + for (const group of opts.groups) { + const search = page.getByTestId("group-selector-dropdown-search"); + await expect(search).toBeVisible(); + await search.fill(group); + await search.press("Enter"); + } + await page.getByTestId("group-selector-dropdown-search").press("Escape"); + await expect( + page.getByTestId("group-selector-dropdown-search"), + ).not.toBeVisible(); + } + + const responsePromise = page.waitForResponse( + (resp) => resp.url().includes("/api/setup-keys") && resp.request().method() === "GET", + ); + await page.getByTestId("create-setup-key").click(); + + const copyInput = page.getByTestId("setup-key-copy-input"); + const keyValue = await copyInput.getAttribute("data-testid-setup-key-value"); + expect(keyValue!.length).toBeGreaterThan(10); + await page.getByTestId("setup-key-close").click(); + + await expect(copyInput).not.toBeVisible(); + await responsePromise; + await expect(page.getByText(opts.name)).toBeVisible(); +} + +async function revokeSetupKey( + page: import("@playwright/test").Page, + name: string, +) { + // Row actions are now behind a dropdown menu. + await page + .locator("tr") + .filter({ hasText: name }) + .getByTestId("setup-key-actions") + .click({ force: true }); + await page + .locator('[data-testid="revoke-setup-key"]:not([data-disabled])') + .click({ force: true }); + const responsePromise = page.waitForResponse( + (resp) => resp.url().includes("/api/setup-keys/") && resp.request().method() === "PUT", + { timeout: 10_000 }, + ); + await page.getByTestId("confirmation.confirm").click(); + await responsePromise; + await expect( + page + .locator("tr") + .filter({ hasText: name }) + .getByTestId("circle-icon-inactive"), + ).toBeVisible(); +} + +async function deleteSetupKey( + page: import("@playwright/test").Page, + name: string, +) { + // Row actions are now behind a dropdown menu. + await page + .locator("tr") + .filter({ hasText: name }) + .getByTestId("setup-key-actions") + .click({ force: true }); + await page.getByTestId("delete-setup-key").click({ force: true }); + await page.getByTestId("confirmation.confirm").click(); + await expect(page.locator("tr").filter({ hasText: name })).not.toBeVisible(); +} diff --git a/e2e/tests/team-service-users.spec.ts b/e2e/tests/team-service-users.spec.ts new file mode 100644 index 0000000..a2c422e --- /dev/null +++ b/e2e/tests/team-service-users.spec.ts @@ -0,0 +1,115 @@ +import { test, expect } from "../helpers/fixtures"; +import { navigateTo } from "../helpers/auth"; +import { generateRandomName } from "../helpers/utils"; + +let regularUser = ""; +let adminServiceUser = ""; + +test.describe.serial("Team - Service Users @team", () => { + test("Should create service users and verify roles", async ({ dashboardAsOwner: page }) => { + await navigateTo(page, "/team/service-users"); + + regularUser = generateRandomName("svc-user-"); + adminServiceUser = generateRandomName("svc-admin-"); + + await createServiceUser(page, regularUser, "User"); + await createServiceUser(page, adminServiceUser, "Admin"); + + await checkServiceUserRow(page, regularUser, "User"); + await checkServiceUserRow(page, adminServiceUser, "Admin"); + }); + + test("Should update role and manage access tokens", async ({ dashboardAsOwner: page }) => { + await page.locator("tr").getByText(regularUser).click(); + await changeRoleTo(page, "Admin"); + await page.getByTestId("save-changes").click(); + + // Create and delete access token + const tokenName = generateRandomName("tkn_"); + await page.getByTestId("access-token-open-modal").click(); + await page.getByTestId("access-token-name").fill(tokenName); + await page.getByTestId("access-token-expires-in").fill("30"); + await page.getByTestId("create-access-token").click(); + await expect(page.getByTestId("access-token-copy-close")).toBeVisible(); + await page.getByTestId("access-token-copy-close").click(); + + const tokenRow = page.locator("tr").filter({ hasText: tokenName }); + await tokenRow.getByTestId("access-token-delete").click(); + await page.getByTestId("confirmation.confirm").click(); + await expect(tokenRow).not.toBeVisible(); + + await page.getByText("Service Users").first().click(); + }); + + test("Should update admin user role and verify all changes persisted", async ({ + dashboardAsOwner: page, + }) => { + await page.locator("tr").getByText(adminServiceUser).click(); + await changeRoleTo(page, "User"); + const saveResponse = page.waitForResponse( + (resp) => resp.url().includes("/api/users/") && resp.request().method() === "PUT", + { timeout: 30_000 }, + ); + await page.getByTestId("save-changes").click(); + await saveResponse; + + await page.getByText("Service Users").first().click(); + await checkServiceUserRow(page, regularUser, "Admin"); + await checkServiceUserRow(page, adminServiceUser, "User"); + + // Single reload to verify all changes persisted + await page.reload(); + await checkServiceUserRow(page, regularUser, "Admin"); + await checkServiceUserRow(page, adminServiceUser, "User"); + }); + + test("Should delete service users", async ({ dashboardAsOwner: page }) => { + for (const name of [regularUser, adminServiceUser]) { + const row = page.locator("tr").filter({ hasText: name }); + // Row actions are now behind a dropdown menu; open it, then delete. + await row.getByTestId("user-actions").click({ force: true }); + await page.getByTestId("delete-user").click({ force: true }); + await page.getByTestId("confirmation.confirm").click(); + await expect(row).not.toBeVisible(); + } + }); +}); + +async function createServiceUser( + page: import("@playwright/test").Page, + name: string, + role: string, +) { + await page.getByTestId("open-service-user-modal").click(); + await expect(page.getByTestId("service-user-name")).toBeVisible({ timeout: 5_000 }); + await page.getByTestId("service-user-name").fill(name); + await page.getByTestId("user-role-selector").click({ force: true }); + await page + .getByTestId("user-role-selector-item") + .getByText(role, { exact: true }) + .click({ force: true }); + await page.getByTestId("create-service-user").click(); + // Wait for modal to close + await expect(page.getByTestId("service-user-name")).not.toBeVisible({ timeout: 5_000 }); +} + +async function checkServiceUserRow( + page: import("@playwright/test").Page, + name: string, + role: string, +) { + const row = page.locator("tr").filter({ hasText: name }); + await expect(row).toBeVisible({ timeout: 10_000 }); + await expect(row.getByText(role, { exact: true }).first()).toBeVisible({ timeout: 10_000 }); +} + +async function changeRoleTo( + page: import("@playwright/test").Page, + role: string, +) { + await page.getByTestId("user-role-selector").click(); + await page + .getByTestId("user-role-selector-item") + .getByText(role, { exact: true }) + .click(); +} diff --git a/e2e/tests/team-users-approval-and-billing.spec.ts b/e2e/tests/team-users-approval-and-billing.spec.ts new file mode 100644 index 0000000..7d03d44 --- /dev/null +++ b/e2e/tests/team-users-approval-and-billing.spec.ts @@ -0,0 +1,150 @@ +import { expect, test } from "../helpers/fixtures"; +import { loginToApp, navigateTo } from "../helpers/auth"; +import { deleteUserByEmail } from "../helpers/api"; + +test.setTimeout(60_000); + +test.describe.serial("User Approval & Billing Admin @team", () => { + // ── User Approval ──────────────────────────────────────────────────── + + test("Should show approval pending for the second user", async ({ + browser, + dashboardAsOwner: ownerPage, + }) => { + // Clean up user from previous runs so approval flow starts fresh + await deleteUserByEmail(ownerPage, "user@localhost.test"); + + const context = await browser.newContext({ + storageState: "e2e/fixtures/auth/user.json", + }); + const page = await context.newPage(); + await loginToApp(page, "user"); + await expect(page.getByText("User Approval Pending")).toBeVisible(); + await context.close(); + }); + + test("Should approve the pending user", async ({ + dashboardAsOwner: page, + }) => { + await navigateTo(page, "/team/users"); + + const pendingRow = page.locator("tr").filter({ hasText: "Pending" }); + await expect(pendingRow).toBeVisible(); + await pendingRow.getByRole("button", { name: "Approve" }).click(); + await expect(pendingRow).not.toBeVisible(); + }); + + test("Should delete the approved user", async ({ + dashboardAsOwner: page, + }) => { + const userRow = page + .locator("tr") + .filter({ hasText: "user@localhost.test" }); + await expect(userRow).toBeVisible(); + // Row actions are now behind a dropdown menu. + await userRow.getByTestId("user-actions").click({ force: true }); + await page.getByTestId("delete-user").click({ force: true }); + await page.getByTestId("confirmation.confirm").click(); + await expect(userRow).not.toBeVisible(); + }); + + // ── Billing Admin ──────────────────────────────────────────────────── + + test("Should login as second user to trigger registration", async ({ + browser, + }) => { + const context = await browser.newContext({ + storageState: "e2e/fixtures/auth/user.json", + }); + const page = await context.newPage(); + await loginToApp(page, "user"); + await context.close(); + }); + + test("Should approve user and assign Billing Admin role", async ({ + dashboardAsOwner: page, + }) => { + await navigateTo(page, "/team/users"); + + const pendingRow = page.locator("tr").filter({ hasText: "Pending" }); + if (await pendingRow.isVisible({ timeout: 5_000 }).catch(() => false)) { + await pendingRow.getByRole("button", { name: "Approve" }).click(); + await expect(pendingRow).not.toBeVisible(); + } + + const userRow = page + .locator("tr") + .filter({ hasText: "user@localhost.test" }); + await expect(userRow).toBeVisible(); + await userRow.getByTestId("user-name-cell").click(); + await expect( + page.getByTestId("breadcrumb-item").filter({ hasText: /^user/i }), + ).toBeVisible(); + + await expect(page.getByTestId("user-role-selector")).toBeEnabled({ + timeout: 15_000, + }); + const currentRole = await page + .getByTestId("user-role-selector") + .textContent(); + if (!currentRole?.includes("Billing Admin")) { + await page.getByTestId("user-role-selector").click(); + await page + .getByTestId("user-role-selector-item") + .filter({ hasText: "Billing Admin" }) + .click(); + await page.getByTestId("save-changes").click(); + } + }); + + test("Should show Plans & Billing and Invoices for the Billing Admin", async ({ + browser, + }) => { + const context = await browser.newContext({ + storageState: "e2e/fixtures/auth/user.json", + }); + const page = await context.newPage(); + await loginToApp(page, "user"); + + await expect(page.getByTestId("user-dropdown")).toBeVisible({ + timeout: 15_000, + }); + await page.getByTestId("user-dropdown").click({ force: true }); + await page.getByText("Plans & Billing").click(); + + await expect( + page.getByTestId("settings-tab-plans-and-billing"), + ).toBeVisible({ timeout: 10_000 }); + await expect(page.getByTestId("settings-tab-invoices")).toBeVisible(); + await expect( + page.getByTestId("settings-content-plans-and-billing"), + ).toBeVisible(); + + await page.getByTestId("settings-tab-invoices").click(); + await expect(page.getByTestId("settings-content-invoices")).toBeVisible(); + + await expect( + page.getByTestId("settings-tab-authentication"), + ).not.toBeVisible(); + await expect( + page.getByTestId("settings-tab-permissions"), + ).not.toBeVisible(); + await expect(page.getByTestId("settings-tab-clients")).not.toBeVisible(); + + await context.close(); + }); + + test("Should delete the second user", async ({ dashboardAsOwner: page }) => { + await navigateTo(page, "/team/users"); + + const userRow = page + .locator("tr") + .filter({ hasText: "user@localhost.test" }); + await expect(userRow).toBeVisible(); + // Row actions are now behind a dropdown menu. + await userRow.getByTestId("user-actions").click({ force: true }); + await page.getByTestId("delete-user").click({ force: true }); + await page.getByTestId("confirmation.confirm").click(); + await expect(userRow).not.toBeVisible(); + }); +}); diff --git a/e2e/tests/team-users.spec.ts b/e2e/tests/team-users.spec.ts new file mode 100644 index 0000000..24646c8 --- /dev/null +++ b/e2e/tests/team-users.spec.ts @@ -0,0 +1,105 @@ +import { test, expect } from "../helpers/fixtures"; +import { navigateTo } from "../helpers/auth"; +import { generateRandomName } from "../helpers/utils"; +import { deleteGroupsByPrefix } from "../helpers/api"; + +let createdGroupName = ""; + +test.describe.serial("Team - Users @team", () => { + test('Should show the owner with "You" badge and "Owner" role', async ({ + dashboardAsOwner: page, + }) => { + await navigateTo(page, "/team/users"); + + const ownerRow = page + .getByTestId("user-name-cell") + .filter({ hasText: "You" }) + .locator("xpath=ancestor::tr"); + await expect(ownerRow).toBeVisible(); + await expect(ownerRow.getByText("Owner", { exact: true })).toBeVisible(); + }); + + test("Should open the user detail page with Peers and Access Tokens tabs", async ({ + dashboardAsOwner: page, + }) => { + await openOwnerDetailPage(page); + + await expect(page.getByTestId("user-tab-peers")).toBeVisible(); + await expect(page.getByTestId("user-tab-access-tokens")).toBeVisible(); + + await page.getByTestId("user-tab-peers").click(); + await expect(page.getByText("View all peers registered by this user.")).toBeVisible(); + + await page.getByTestId("user-tab-access-tokens").click(); + await expect(page.getByText("Access tokens give access to NetBird API.")).toBeVisible(); + }); + + test("Should add an auto-assigned group, save, and verify persistence", async ({ + dashboardAsOwner: page, + }) => { + // Go back to users list via breadcrumb + await page.getByTestId("breadcrumb-item").filter({ hasText: "Users" }).click(); + await openOwnerDetailPage(page); + + const name = generateRandomName("user-group-"); + createdGroupName = name; + + await page.getByTestId("user-group-selector").click(); + const search = page.getByTestId("user-group-selector-search"); + await expect(search).toBeVisible(); + await search.fill(name); + await search.press("Enter"); + await expect( + page.getByTestId("user-group-selector").getByText(name), + ).toBeVisible(); + await search.press("Escape"); + + const saveResponse = page.waitForResponse( + (resp) => resp.url().includes("/api/users/") && resp.request().method() === "PUT", + { timeout: 30_000 }, + ); + await page.getByTestId("save-changes").click(); + await saveResponse; + await expect( + page.getByTestId("user-group-selector").getByText(name), + ).toBeVisible(); + + await page.reload(); + await expect( + page.getByTestId("user-group-selector").getByText(name), + ).toBeVisible(); + }); + + test("Should remove the auto-assigned group, save, and verify removal", async ({ + dashboardAsOwner: page, + }) => { + // Already on user detail page from previous test (after reload) + await page + .getByTestId("user-group-selector") + .getByTestId("group-badge") + .filter({ hasText: createdGroupName }) + .click(); + + await page.getByTestId("save-changes").click(); + await expect( + page.getByTestId("user-group-selector").getByText(createdGroupName), + ).not.toBeVisible(); + + await page.reload(); + await expect( + page.getByTestId("user-group-selector").getByText(createdGroupName), + ).not.toBeVisible(); + }); + + test("Should delete the created group", async ({ dashboardAsOwner: page }) => { + await deleteGroupsByPrefix(page, createdGroupName); + }); +}); + +async function openOwnerDetailPage(page: import("@playwright/test").Page) { + await page.getByTestId("user-name-cell").filter({ hasText: "You" }).click(); + await expect( + page.getByTestId("breadcrumb-item").filter({ hasText: "Users" }), + ).toBeVisible(); + await expect(page.getByText("Auto-assigned groups")).toBeVisible(); +} diff --git a/package-lock.json b/package-lock.json index 00a5a54..867d24a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,7 +32,7 @@ "@tanstack/react-table": "^8.10.7", "@types/crypto-js": "^4.2.2", "@types/d3": "^7.4.3", - "@types/lodash": "^4.14.200", + "@types/lodash": "4.17.24", "@types/node": "20.10.6", "@types/react": "^19", "@types/react-dom": "^19", @@ -47,6 +47,7 @@ "classnames": "^2.5.1", "clsx": "^2.0.0", "cmdk": "^1.1.1", + "cross-env": "^7.0.3", "crypto-js": "^4.2.0", "d3": "^7.9.0", "date-fns": "^2.30.0", @@ -55,15 +56,17 @@ "eslint-config-prettier": "^9.0.0", "eslint-plugin-simple-import-sort": "^10.0.0", "framer-motion": "^12.29.2", - "ip-address": "^10.1.0", + "ip-address": "^10.2.0", "ip-cidr": "^3.1.0", - "js-cookie": "^3.0.5", - "lodash": "^4.17.23", + "js-cookie": "^3.0.7", + "lodash": "4.18.1", "lucide-react": "^0.566.0", "next": "16.1.7", "next-themes": "^0.2.1", "punycode": "^2.3.1", "react": "^19.2.4", + "react-chartjs-2": "^5.3.0", + "react-confetti-explosion": "^3.0.3", "react-day-picker": "^9.13.0", "react-dom": "^19.2.4", "react-ga4": "^2.1.0", @@ -83,6 +86,7 @@ }, "devDependencies": { "@faker-js/faker": "^9.5.1", + "@playwright/test": "^1.52.0", "@types/chroma-js": "^3.1.1", "@types/js-cookie": "^3.0.6", "eslint": "^9.39.1", @@ -108,30 +112,30 @@ } }, "node_modules/@axa-fr/oidc-client": { - "version": "7.26.3", - "resolved": "https://registry.npmjs.org/@axa-fr/oidc-client/-/oidc-client-7.26.3.tgz", - "integrity": "sha512-QJ0TsicidN7ZGXqMYqWVhX29+2057V2UBkQizm5KxBJnH11LbH+wIY2XcR7ZwHLMvOWRB1IPS7MTM+Z4CHMjGg==", + "version": "7.26.7", + "resolved": "https://registry.npmjs.org/@axa-fr/oidc-client/-/oidc-client-7.26.7.tgz", + "integrity": "sha512-kAY7yKvULYtIK9X/R0+ksobcZ6DchQvhF8mwAlEl6uKGZpFETT3tqT9VxfBF2beG6rfWLmumBoliHa4h+fk2Gw==", "hasInstallScript": true, "license": "MIT", "dependencies": { - "@axa-fr/oidc-client-service-worker": "7.26.3" + "@axa-fr/oidc-client-service-worker": "7.26.7" } }, "node_modules/@axa-fr/oidc-client-service-worker": { - "version": "7.26.3", - "resolved": "https://registry.npmjs.org/@axa-fr/oidc-client-service-worker/-/oidc-client-service-worker-7.26.3.tgz", - "integrity": "sha512-Yg50TAGdGAeN1bd4b1lmWDmn5Llb18mkbYu+76xawJvCKWyOIiYkcru8R5XmZYX2hfX8pQ76sDOP4TAHgmP+IQ==", + "version": "7.26.7", + "resolved": "https://registry.npmjs.org/@axa-fr/oidc-client-service-worker/-/oidc-client-service-worker-7.26.7.tgz", + "integrity": "sha512-4frVu/UjrDDM1UbYF7hJGeou+qGQ3h0oqZq4jpj4pIM8qhnC1NYpFHdBN0Cecen9yfMQpwavLIM/ISVJFXv1dg==", "license": "MIT" }, "node_modules/@axa-fr/react-oidc": { - "version": "7.26.3", - "resolved": "https://registry.npmjs.org/@axa-fr/react-oidc/-/react-oidc-7.26.3.tgz", - "integrity": "sha512-4PqEYVi7kjfN8bU7U4q/4qmu+5OqVwJ6+/0d6VAh62UweRDa4cbxS2lYoq7h1/5O8M7Wer6BxL7IaDCrJUWNcQ==", + "version": "7.26.7", + "resolved": "https://registry.npmjs.org/@axa-fr/react-oidc/-/react-oidc-7.26.7.tgz", + "integrity": "sha512-aIJsoTv9qHdZImiSz2cwkcUEEgJ0w2TveKCMMK1Eokr6uywLgNYBf4wfpy/lzFMCtY+dUl53kjngIbIZ6CNa1g==", "hasInstallScript": true, "license": "MIT", "dependencies": { - "@axa-fr/oidc-client": "7.26.3", - "@axa-fr/oidc-client-service-worker": "7.26.3" + "@axa-fr/oidc-client": "7.26.7", + "@axa-fr/oidc-client-service-worker": "7.26.7" }, "peerDependencies": { "react": "^17.0.0 || ^18.0.0 || ^19.0.0" @@ -194,10 +198,33 @@ "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.29.0", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.0.tgz", - "integrity": "sha512-vSH118/wwM/pLR38g/Sgk05sNtro6TlTJKuiMXDaZqPUfjTFcudpCOt00IhOfj+1BFAX+UFAlzCU+6WXr3GLFQ==", + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", "dev": true, "license": "MIT", "dependencies": { @@ -228,6 +255,16 @@ "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", @@ -522,19 +559,19 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", - "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.4.tgz", + "integrity": "sha512-4h4MVF8pmBsncB60r0wSJiIeUKTSD4m7FmTFThG8RHlsg9ajqckLm9OraguFGZE4vVdpiI1Q4+hFnisopmG6gQ==", "license": "MIT", "dependencies": { - "ajv": "^6.12.4", + "ajv": "^6.14.0", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.1", - "minimatch": "^3.1.2", + "minimatch": "^3.1.3", "strip-json-comments": "^3.1.1" }, "engines": { @@ -544,10 +581,22 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@eslint/js": { - "version": "9.39.2", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", - "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", + "version": "9.39.3", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.3.tgz", + "integrity": "sha512-1B1VkCq6FuUNlQvlBYb+1jDu/gV297TIs/OeiaSR9l1H27SVW55ONE1e1Vp16NqP683+xEGzxYtv4XCiDPaQiw==", "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1401,6 +1450,23 @@ "node": ">=12.4.0" } }, + "node_modules/@playwright/test": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", + "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", + "devOptional": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "playwright": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@radix-ui/number": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", @@ -2628,9 +2694,9 @@ } }, "node_modules/@tabler/icons": { - "version": "3.36.1", - "resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.36.1.tgz", - "integrity": "sha512-f4Jg3Fof/Vru5ioix/UO4GX+sdDsF9wQo47FbtvG+utIYYVQ/QVAC0QYgcBbAjQGfbdOh2CCf0BgiFOF9Ixtjw==", + "version": "3.37.1", + "resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.37.1.tgz", + "integrity": "sha512-neLCWkuyNHEPXCyYu6nbN4S3g/59BTa4qyITAugYVpq1YzYNDOZooW7/vRWH98ZItXAudxdKU8muFT7y1PqzuA==", "license": "MIT", "funding": { "type": "github", @@ -2638,9 +2704,9 @@ } }, "node_modules/@tabler/icons-react": { - "version": "3.36.1", - "resolved": "https://registry.npmjs.org/@tabler/icons-react/-/icons-react-3.36.1.tgz", - "integrity": "sha512-/8nOXeNeMoze9xY/QyEKG65wuvRhkT3q9aytaur6Gj8bYU2A98YVJyLc9MRmc5nVvpy+bRlrrwK/Ykr8WGyUWg==", + "version": "3.37.1", + "resolved": "https://registry.npmjs.org/@tabler/icons-react/-/icons-react-3.37.1.tgz", + "integrity": "sha512-R7UE71Jji7i4Su56Y9zU1uYEBakUejuDJvyuYVmBuUoqp/x3Pn4cv2huarexR3P0GJ2eHg4rUj9l5zccqS6K/Q==", "license": "MIT", "dependencies": { "@tabler/icons": "" @@ -3012,9 +3078,9 @@ "license": "MIT" }, "node_modules/@types/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA==", + "version": "4.17.24", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz", + "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", "license": "MIT" }, "node_modules/@types/node": { @@ -3027,9 +3093,9 @@ } }, "node_modules/@types/react": { - "version": "19.2.10", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.10.tgz", - "integrity": "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw==", + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "license": "MIT", "peer": true, "dependencies": { @@ -3056,17 +3122,17 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.54.0.tgz", - "integrity": "sha512-hAAP5io/7csFStuOmR782YmTthKBJ9ND3WVL60hcOjvtGFb+HJxH4O5huAcmcZ9v9G8P+JETiZ/G1B8MALnWZQ==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz", + "integrity": "sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.54.0", - "@typescript-eslint/type-utils": "8.54.0", - "@typescript-eslint/utils": "8.54.0", - "@typescript-eslint/visitor-keys": "8.54.0", + "@typescript-eslint/scope-manager": "8.56.1", + "@typescript-eslint/type-utils": "8.56.1", + "@typescript-eslint/utils": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" @@ -3079,8 +3145,8 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.54.0", - "eslint": "^8.57.0 || ^9.0.0", + "@typescript-eslint/parser": "^8.56.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, @@ -3095,17 +3161,17 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.54.0.tgz", - "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.1.tgz", + "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@typescript-eslint/scope-manager": "8.54.0", - "@typescript-eslint/types": "8.54.0", - "@typescript-eslint/typescript-estree": "8.54.0", - "@typescript-eslint/visitor-keys": "8.54.0", + "@typescript-eslint/scope-manager": "8.56.1", + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1", "debug": "^4.4.3" }, "engines": { @@ -3116,19 +3182,19 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.54.0.tgz", - "integrity": "sha512-YPf+rvJ1s7MyiWM4uTRhE4DvBXrEV+d8oC3P9Y2eT7S+HBS0clybdMIPnhiATi9vZOYDc7OQ1L/i6ga6NFYK/g==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.1.tgz", + "integrity": "sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.54.0", - "@typescript-eslint/types": "^8.54.0", + "@typescript-eslint/tsconfig-utils": "^8.56.1", + "@typescript-eslint/types": "^8.56.1", "debug": "^4.4.3" }, "engines": { @@ -3143,14 +3209,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.54.0.tgz", - "integrity": "sha512-27rYVQku26j/PbHYcVfRPonmOlVI6gihHtXFbTdB5sb6qA0wdAQAbyXFVarQ5t4HRojIz64IV90YtsjQSSGlQg==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.56.1.tgz", + "integrity": "sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.54.0", - "@typescript-eslint/visitor-keys": "8.54.0" + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3161,9 +3227,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.54.0.tgz", - "integrity": "sha512-dRgOyT2hPk/JwxNMZDsIXDgyl9axdJI3ogZ2XWhBPsnZUv+hPesa5iuhdYt2gzwA9t8RE5ytOJ6xB0moV0Ujvw==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.1.tgz", + "integrity": "sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ==", "dev": true, "license": "MIT", "engines": { @@ -3178,15 +3244,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.54.0.tgz", - "integrity": "sha512-hiLguxJWHjjwL6xMBwD903ciAwd7DmK30Y9Axs/etOkftC3ZNN9K44IuRD/EB08amu+Zw6W37x9RecLkOo3pMA==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.56.1.tgz", + "integrity": "sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.54.0", - "@typescript-eslint/typescript-estree": "8.54.0", - "@typescript-eslint/utils": "8.54.0", + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1", + "@typescript-eslint/utils": "8.56.1", "debug": "^4.4.3", "ts-api-utils": "^2.4.0" }, @@ -3198,14 +3264,14 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/types": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.54.0.tgz", - "integrity": "sha512-PDUI9R1BVjqu7AUDsRBbKMtwmjWcn4J3le+5LpcFgWULN3LvHC5rkc9gCVxbrsrGmO1jfPybN5s6h4Jy+OnkAA==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.1.tgz", + "integrity": "sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==", "dev": true, "license": "MIT", "engines": { @@ -3217,18 +3283,18 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.54.0.tgz", - "integrity": "sha512-BUwcskRaPvTk6fzVWgDPdUndLjB87KYDrN5EYGetnktoeAvPtO4ONHlAZDnj5VFnUANg0Sjm7j4usBlnoVMHwA==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.1.tgz", + "integrity": "sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.54.0", - "@typescript-eslint/tsconfig-utils": "8.54.0", - "@typescript-eslint/types": "8.54.0", - "@typescript-eslint/visitor-keys": "8.54.0", + "@typescript-eslint/project-service": "8.56.1", + "@typescript-eslint/tsconfig-utils": "8.56.1", + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1", "debug": "^4.4.3", - "minimatch": "^9.0.5", + "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.4.0" @@ -3244,30 +3310,17 @@ "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/@typescript-eslint/utils": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.54.0.tgz", - "integrity": "sha512-9Cnda8GS57AQakvRyG0PTejJNlA2xhvyNtEVIMlDWOOeEyBkYWhGPnfrIAnqxLMTSTo6q8g12XVjjev5l1NvMA==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.56.1.tgz", + "integrity": "sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.54.0", - "@typescript-eslint/types": "8.54.0", - "@typescript-eslint/typescript-estree": "8.54.0" + "@typescript-eslint/scope-manager": "8.56.1", + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3277,19 +3330,19 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.54.0.tgz", - "integrity": "sha512-VFlhGSl4opC0bprJiItPQ1RfUhGDIBokcPwaFH4yiBCaNPeld/9VeXbiPO1cLyorQi1G1vL+ecBk1x8o1axORA==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.1.tgz", + "integrity": "sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.54.0", - "eslint-visitor-keys": "^4.2.1" + "@typescript-eslint/types": "8.56.1", + "eslint-visitor-keys": "^5.0.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3585,12 +3638,12 @@ "peer": true }, "node_modules/@xyflow/react": { - "version": "12.10.0", - "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.0.tgz", - "integrity": "sha512-eOtz3whDMWrB4KWVatIBrKuxECHqip6PfA8fTpaS2RUGVpiEAe+nqDKsLqkViVWxDGreq0lWX71Xth/SPAzXiw==", + "version": "12.10.1", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.1.tgz", + "integrity": "sha512-5eSWtIK/+rkldOuFbOOz44CRgQRjtS9v5nufk77DV+XBnfCGL9HAQ8PG00o2ZYKqkEU/Ak6wrKC95Tu+2zuK3Q==", "license": "MIT", "dependencies": { - "@xyflow/system": "0.0.74", + "@xyflow/system": "0.0.75", "classcat": "^5.0.3", "zustand": "^4.4.0" }, @@ -3600,9 +3653,9 @@ } }, "node_modules/@xyflow/system": { - "version": "0.0.74", - "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.74.tgz", - "integrity": "sha512-7v7B/PkiVrkdZzSbL+inGAo6tkR/WQHHG0/jhSvLQToCsfa8YubOGmBYd1s08tpKpihdHDZFwzQZeR69QSBb4Q==", + "version": "0.0.75", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.75.tgz", + "integrity": "sha512-iXs+AGFLi8w/VlAoc/iSxk+CxfT6o64Uw/k0CKASOPqjqz6E0rb5jFZgJtXGZCpfQI6OQpu5EnumP5fGxQheaQ==", "license": "MIT", "dependencies": { "@types/d3-drag": "^3.0.7", @@ -3617,9 +3670,9 @@ } }, "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", "peer": true, "bin": { @@ -3981,12 +4034,15 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.9.19", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", - "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", + "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", "license": "Apache-2.0", "bin": { - "baseline-browser-mapping": "dist/cli.js" + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" } }, "node_modules/binary-extensions": { @@ -4002,9 +4058,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -4128,9 +4184,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001767", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001767.tgz", - "integrity": "sha512-34+zUAMhSH+r+9eKmYG+k2Rpt8XttfE4yXAjoZvkAPs15xcYQhyBYdalJ65BzivAvGRMViEjy6oKr/S91loekQ==", + "version": "1.0.30001774", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001774.tgz", + "integrity": "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA==", "funding": [ { "type": "opencollective", @@ -4163,11 +4219,24 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/chart.js": { "version": "4.5.1", "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", "license": "MIT", + "peer": true, "dependencies": { "@kurkle/color": "^0.3.0" }, @@ -4290,15 +4359,6 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, - "node_modules/commander": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", - "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", - "license": "MIT", - "engines": { - "node": ">= 10" - } - }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -4306,6 +4366,24 @@ "dev": true, "license": "MIT" }, + "node_modules/cross-env": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", + "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "bin": { + "cross-env": "src/bin/cross-env.js", + "cross-env-shell": "src/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=10.14", + "npm": ">=6", + "yarn": ">=1" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -4520,6 +4598,15 @@ "node": ">=12" } }, + "node_modules/d3-dsv/node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, "node_modules/d3-ease": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", @@ -4975,9 +5062,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.283", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.283.tgz", - "integrity": "sha512-3vifjt1HgrGW/h76UEeny+adYApveS9dH2h3p57JYzBSXJIKUJAvtmIytDKjcSCt9xHfrNCFJ7gts6vkhuq++w==", + "version": "1.5.302", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz", + "integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==", "license": "ISC" }, "node_modules/elkjs": { @@ -4986,13 +5073,6 @@ "integrity": "sha512-Yx3ORtbAFrXelYkAy2g0eYyVY8QG0XEmGdQXmy0eithKKjbWRfl3Xe884lfkszfBF6UKyIy4LwfcZ3AZc8oxFw==", "license": "EPL-2.0" }, - "node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true, - "license": "MIT" - }, "node_modules/es-abstract": { "version": "1.24.1", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", @@ -5192,9 +5272,9 @@ } }, "node_modules/eslint": { - "version": "9.39.2", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", - "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", + "version": "9.39.3", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.3.tgz", + "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", "license": "MIT", "peer": true, "dependencies": { @@ -5204,7 +5284,7 @@ "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.39.2", + "@eslint/js": "9.39.3", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", @@ -5278,17 +5358,208 @@ } } }, - "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==", + "node_modules/eslint-config-next/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint-config-next/node_modules/eslint-import-resolver-typescript": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.10.1.tgz", + "integrity": "sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@nolyfill/is-core-module": "1.0.39", + "debug": "^4.4.0", + "get-tsconfig": "^4.10.0", + "is-bun-module": "^2.0.0", + "stable-hash": "^0.0.5", + "tinyglobby": "^0.2.13", + "unrs-resolver": "^1.6.2" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-resolver-typescript" + }, + "peerDependencies": { + "eslint": "*", + "eslint-plugin-import": "*", + "eslint-plugin-import-x": "*" + }, + "peerDependenciesMeta": { + "eslint-plugin-import": { + "optional": true + }, + "eslint-plugin-import-x": { + "optional": true + } + } + }, + "node_modules/eslint-config-next/node_modules/eslint-plugin-import": { + "version": "2.32.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", + "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, + "dependencies": { + "@rtsao/scc": "^1.1.0", + "array-includes": "^3.1.9", + "array.prototype.findlastindex": "^1.2.6", + "array.prototype.flat": "^1.3.3", + "array.prototype.flatmap": "^1.3.3", + "debug": "^3.2.7", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.12.1", + "hasown": "^2.0.2", + "is-core-module": "^2.16.1", + "is-glob": "^4.0.3", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "object.groupby": "^1.0.3", + "object.values": "^1.2.1", + "semver": "^6.3.1", + "string.prototype.trimend": "^1.0.9", + "tsconfig-paths": "^3.15.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" + } + }, + "node_modules/eslint-config-next/node_modules/eslint-plugin-import/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-config-next/node_modules/eslint-plugin-jsx-a11y": { + "version": "6.10.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz", + "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "aria-query": "^5.3.2", + "array-includes": "^3.1.8", + "array.prototype.flatmap": "^1.3.2", + "ast-types-flow": "^0.0.8", + "axe-core": "^4.10.0", + "axobject-query": "^4.1.0", + "damerau-levenshtein": "^1.0.8", + "emoji-regex": "^9.2.2", + "hasown": "^2.0.2", + "jsx-ast-utils": "^3.3.5", + "language-tags": "^1.0.9", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "safe-regex-test": "^1.0.3", + "string.prototype.includes": "^2.0.1" + }, + "engines": { + "node": ">=4.0" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" + } + }, + "node_modules/eslint-config-next/node_modules/eslint-plugin-react": { + "version": "7.37.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.9", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, + "node_modules/eslint-config-next/node_modules/eslint-plugin-react-hooks": { + "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": ">=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" + } + }, + "node_modules/eslint-config-next/node_modules/resolve": { + "version": "2.0.0-next.6", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz", + "integrity": "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "node-exports-info": "^1.6.0", + "object-keys": "^1.1.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/eslint-config-next/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/eslint-config-prettier": { @@ -5325,41 +5596,6 @@ "ms": "^2.1.1" } }, - "node_modules/eslint-import-resolver-typescript": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.10.1.tgz", - "integrity": "sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "@nolyfill/is-core-module": "1.0.39", - "debug": "^4.4.0", - "get-tsconfig": "^4.10.0", - "is-bun-module": "^2.0.0", - "stable-hash": "^0.0.5", - "tinyglobby": "^0.2.13", - "unrs-resolver": "^1.6.2" - }, - "engines": { - "node": "^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint-import-resolver-typescript" - }, - "peerDependencies": { - "eslint": "*", - "eslint-plugin-import": "*", - "eslint-plugin-import-x": "*" - }, - "peerDependenciesMeta": { - "eslint-plugin-import": { - "optional": true - }, - "eslint-plugin-import-x": { - "optional": true - } - } - }, "node_modules/eslint-module-utils": { "version": "2.12.1", "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", @@ -5388,152 +5624,6 @@ "ms": "^2.1.1" } }, - "node_modules/eslint-plugin-import": { - "version": "2.32.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", - "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@rtsao/scc": "^1.1.0", - "array-includes": "^3.1.9", - "array.prototype.findlastindex": "^1.2.6", - "array.prototype.flat": "^1.3.3", - "array.prototype.flatmap": "^1.3.3", - "debug": "^3.2.7", - "doctrine": "^2.1.0", - "eslint-import-resolver-node": "^0.3.9", - "eslint-module-utils": "^2.12.1", - "hasown": "^2.0.2", - "is-core-module": "^2.16.1", - "is-glob": "^4.0.3", - "minimatch": "^3.1.2", - "object.fromentries": "^2.0.8", - "object.groupby": "^1.0.3", - "object.values": "^1.2.1", - "semver": "^6.3.1", - "string.prototype.trimend": "^1.0.9", - "tsconfig-paths": "^3.15.0" - }, - "engines": { - "node": ">=4" - }, - "peerDependencies": { - "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" - } - }, - "node_modules/eslint-plugin-import/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/eslint-plugin-jsx-a11y": { - "version": "6.10.2", - "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz", - "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "aria-query": "^5.3.2", - "array-includes": "^3.1.8", - "array.prototype.flatmap": "^1.3.2", - "ast-types-flow": "^0.0.8", - "axe-core": "^4.10.0", - "axobject-query": "^4.1.0", - "damerau-levenshtein": "^1.0.8", - "emoji-regex": "^9.2.2", - "hasown": "^2.0.2", - "jsx-ast-utils": "^3.3.5", - "language-tags": "^1.0.9", - "minimatch": "^3.1.2", - "object.fromentries": "^2.0.8", - "safe-regex-test": "^1.0.3", - "string.prototype.includes": "^2.0.1" - }, - "engines": { - "node": ">=4.0" - }, - "peerDependencies": { - "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" - } - }, - "node_modules/eslint-plugin-react": { - "version": "7.37.5", - "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", - "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-includes": "^3.1.8", - "array.prototype.findlast": "^1.2.5", - "array.prototype.flatmap": "^1.3.3", - "array.prototype.tosorted": "^1.1.4", - "doctrine": "^2.1.0", - "es-iterator-helpers": "^1.2.1", - "estraverse": "^5.3.0", - "hasown": "^2.0.2", - "jsx-ast-utils": "^2.4.1 || ^3.0.0", - "minimatch": "^3.1.2", - "object.entries": "^1.1.9", - "object.fromentries": "^2.0.8", - "object.values": "^1.2.1", - "prop-types": "^15.8.1", - "resolve": "^2.0.0-next.5", - "semver": "^6.3.1", - "string.prototype.matchall": "^4.0.12", - "string.prototype.repeat": "^1.0.0" - }, - "engines": { - "node": ">=4" - }, - "peerDependencies": { - "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" - } - }, - "node_modules/eslint-plugin-react-hooks": { - "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": ">=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" - } - }, - "node_modules/eslint-plugin-react/node_modules/resolve": { - "version": "2.0.0-next.5", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", - "integrity": "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-core-module": "^2.13.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/eslint-plugin-simple-import-sort": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/eslint-plugin-simple-import-sort/-/eslint-plugin-simple-import-sort-10.0.0.tgz", @@ -5560,6 +5650,19 @@ } }, "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", @@ -5588,6 +5691,18 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/esquery": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", @@ -5776,12 +5891,12 @@ } }, "node_modules/framer-motion": { - "version": "12.29.3", - "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.29.3.tgz", - "integrity": "sha512-naVvtFA7IiRTU5n7w7Nj51QFVWu955bU0p9H0gGC4AbhHDQR0TcohoEYwdOZXWEkXrEYNTl95EDOxsjDqn1AvQ==", + "version": "12.34.3", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.34.3.tgz", + "integrity": "sha512-v81ecyZKYO/DfpTwHivqkxSUBzvceOpoI+wLfgCgoUIKxlFKEXdg0oR9imxwXumT4SFy8vRk9xzJ5l3/Du/55Q==", "license": "MIT", "dependencies": { - "motion-dom": "^12.29.2", + "motion-dom": "^12.34.3", "motion-utils": "^12.29.2", "tslib": "^2.4.0" }, @@ -5943,9 +6058,9 @@ } }, "node_modules/get-tsconfig": { - "version": "4.13.1", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.1.tgz", - "integrity": "sha512-EoY1N2xCn44xU6750Sx7OjOIT59FkmstNc3X6y5xpz7D5cBtZRe/3pSlTkDJgqsOk3WwZPkWfonhhUJfttQo3w==", + "version": "4.13.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", + "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", "dev": true, "license": "MIT", "dependencies": { @@ -5968,9 +6083,10 @@ } }, "node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "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" @@ -6195,9 +6311,9 @@ } }, "node_modules/ip-address": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", - "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", "license": "MIT", "engines": { "node": ">= 12" @@ -6322,19 +6438,6 @@ "semver": "^7.7.1" } }, - "node_modules/is-bun-module/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/is-callable": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", @@ -6694,12 +6797,12 @@ } }, "node_modules/js-cookie": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", - "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.7.tgz", + "integrity": "sha512-z/wZZgDrkNV1eA0ULjM/F9/50Ya8fbzgKneSpoPsXSGd0KnpdtHfOZWK+GcwLk+EZbS4F9RBhU+K2RgzuDaItw==", "license": "MIT", "engines": { - "node": ">=14" + "node": ">=20" } }, "node_modules/js-tokens": { @@ -6758,16 +6861,16 @@ "license": "MIT" }, "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", "dev": true, "license": "MIT", + "dependencies": { + "minimist": "^1.2.0" + }, "bin": { "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" } }, "node_modules/jsx-ast-utils": { @@ -6946,12 +7049,12 @@ } }, "node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.5" + "brace-expansion": "^5.0.2" }, "engines": { "node": "18 || 20 || >=22" @@ -6971,9 +7074,9 @@ } }, "node_modules/motion-dom": { - "version": "12.29.2", - "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.29.2.tgz", - "integrity": "sha512-/k+NuycVV8pykxyiTCoFzIVLA95Nb1BFIVvfSu9L50/6K6qNeAYtkxXILy/LRutt7AzaYDc2myj0wkCVVYAPPA==", + "version": "12.34.3", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.34.3.tgz", + "integrity": "sha512-sYgFe+pR9aIM7o4fhs2aXtOI+oqlUd33N9Yoxcgo1Fv7M20sRkHtCmzE/VRNIcq7uNJ+qio+Xubt1FXH3pQ+eQ==", "license": "MIT", "dependencies": { "motion-utils": "^12.29.2" @@ -7003,9 +7106,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", "funding": [ { "type": "github", @@ -7134,6 +7237,35 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/node-exports-info": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", + "integrity": "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array.prototype.flatmap": "^1.3.3", + "es-errors": "^1.3.0", + "object.entries": "^1.1.9", + "semver": "^6.3.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/node-exports-info/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/node-releases": { "version": "2.0.27", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", @@ -7417,6 +7549,53 @@ "node": ">= 6" } }, + "node_modules/playwright": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.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", @@ -7428,9 +7607,9 @@ } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "funding": [ { "type": "opencollective", @@ -7448,7 +7627,7 @@ "license": "MIT", "peer": true, "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -7659,10 +7838,30 @@ "node": ">=0.10.0" } }, + "node_modules/react-chartjs-2": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.3.1.tgz", + "integrity": "sha512-h5IPXKg9EXpjoBzUfyWJvllMjG2mQ4EiuHQFhms/AjUm0XSZHhyRy2xVmLXHKrtcdrPO4mnGqRtYoD0vp95A0A==", + "license": "MIT", + "peerDependencies": { + "chart.js": "^4.1.1", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-confetti-explosion": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/react-confetti-explosion/-/react-confetti-explosion-3.0.3.tgz", + "integrity": "sha512-ow5ns/1ttzXsIlbbfJmWJNiyQK8lTHBL6lRSUXGaK44K/3NIMngR57Ja96l+D6txTeFhfe0BfXGvORMxhtRDng==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, "node_modules/react-day-picker": { - "version": "9.13.0", - "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-9.13.0.tgz", - "integrity": "sha512-euzj5Hlq+lOHqI53NiuNhCP8HWgsPf/bBAVijR50hNaY1XwjKjShAnIe8jm8RD2W9IJUvihDIZ+KrmqfFzNhFQ==", + "version": "9.13.2", + "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-9.13.2.tgz", + "integrity": "sha512-IMPiXfXVIAuR5Yk58DDPBC8QKClrhdXV+Tr/alBrwrHUw0qDDYB1m5zPNuTnnPIr/gmJ4ChMxmtqPdxm8+R4Eg==", "license": "MIT", "dependencies": { "@date-fns/tz": "^1.4.1", @@ -8084,13 +8283,16 @@ "license": "MIT" }, "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, + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "devOptional": true, "license": "ISC", "bin": { "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" } }, "node_modules/set-function-length": { @@ -8193,19 +8395,6 @@ "@img/sharp-win32-x64": "0.34.5" } }, - "node_modules/sharp/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "license": "ISC", - "optional": true, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -8538,18 +8727,6 @@ "node": ">= 6" } }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", @@ -8809,19 +8986,6 @@ "strip-bom": "^3.0.0" } }, - "node_modules/tsconfig-paths/node_modules/json5": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", - "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", - "dev": true, - "license": "MIT", - "dependencies": { - "minimist": "^1.2.0" - }, - "bin": { - "json5": "lib/cli.js" - } - }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -8933,16 +9097,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.54.0.tgz", - "integrity": "sha512-CKsJ+g53QpsNPqbzUsfKVgd3Lny4yKZ1pP4qN3jdMOg/sisIDLGyDMezycquXLE5JsEU0wp3dGNdzig0/fmSVQ==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.56.1.tgz", + "integrity": "sha512-U4lM6pjmBX7J5wk4szltF7I1cGBHXZopnAXCMXb3+fZ3B/0Z3hq3wS/CCUB2NZBNAExK92mCU2tEohWuwVMsDQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.54.0", - "@typescript-eslint/parser": "8.54.0", - "@typescript-eslint/typescript-estree": "8.54.0", - "@typescript-eslint/utils": "8.54.0" + "@typescript-eslint/eslint-plugin": "8.56.1", + "@typescript-eslint/parser": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1", + "@typescript-eslint/utils": "8.56.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -8952,7 +9116,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, diff --git a/package.json b/package.json index 9523e5a..f854a09 100644 --- a/package.json +++ b/package.json @@ -11,9 +11,15 @@ "dev": "next dev -p 3000", "turbo": "next dev -p 3000 --turbo", "build": "next build", + "postbuild": "node postbuild.js", "start": "next start", "lint": "next lint", - "cypress:open": "cypress open" + "test:setup": "cd ./e2e/environment && sh create-test-env.sh", + "test:clean": "cd ./e2e/environment && sh clean-test-env.sh", + "test:dev": "cross-env APP_ENV=test next dev -p 1337", + "test": "npx playwright test --config=e2e/playwright.config.ts", + "test:ui": "npx playwright test --config=e2e/playwright.config.ts --ui", + "test:ci": "cross-env APP_ENV=test next build && npx playwright test --config=e2e/playwright.config.ts" }, "dependencies": { "@axa-fr/react-oidc": "^7.26.3", @@ -40,7 +46,7 @@ "@tanstack/react-table": "^8.10.7", "@types/crypto-js": "^4.2.2", "@types/d3": "^7.4.3", - "@types/lodash": "^4.14.200", + "@types/lodash": "4.17.24", "@types/node": "20.10.6", "@types/react": "^19", "@types/react-dom": "^19", @@ -55,6 +61,7 @@ "classnames": "^2.5.1", "clsx": "^2.0.0", "cmdk": "^1.1.1", + "cross-env": "^7.0.3", "crypto-js": "^4.2.0", "d3": "^7.9.0", "date-fns": "^2.30.0", @@ -63,15 +70,17 @@ "eslint-config-prettier": "^9.0.0", "eslint-plugin-simple-import-sort": "^10.0.0", "framer-motion": "^12.29.2", - "ip-address": "^10.1.0", + "ip-address": "^10.2.0", "ip-cidr": "^3.1.0", - "js-cookie": "^3.0.5", - "lodash": "^4.17.23", + "js-cookie": "^3.0.7", + "lodash": "4.18.1", "lucide-react": "^0.566.0", "next": "16.1.7", "next-themes": "^0.2.1", "punycode": "^2.3.1", "react": "^19.2.4", + "react-chartjs-2": "^5.3.0", + "react-confetti-explosion": "^3.0.3", "react-day-picker": "^9.13.0", "react-dom": "^19.2.4", "react-ga4": "^2.1.0", @@ -96,6 +105,7 @@ "@faker-js/faker": "^9.5.1", "@types/chroma-js": "^3.1.1", "@types/js-cookie": "^3.0.6", + "@playwright/test": "^1.52.0", "eslint": "^9.39.1", "eslint-config-next": "^16.1.6", "postcss": "^8", diff --git a/postbuild.js b/postbuild.js new file mode 100644 index 0000000..036348a --- /dev/null +++ b/postbuild.js @@ -0,0 +1,96 @@ +const { resolve, join } = require("path"); +const { createHash } = require("crypto"); +const { + readFileSync, + writeFileSync, + mkdirSync, + readdirSync, + statSync, +} = require("fs"); + +process.env.NODE_ENV = "production"; +const PLACEHOLDER = "NB_INLINE_SCRIPT_PLACEHOLDER"; +console.log("Starting post-build script to extract inline scripts..."); + +// Function to find HTML files recursively +function findHtmlFiles(dir) { + const files = []; + const entries = readdirSync(dir); + + for (const entry of entries) { + const fullPath = join(dir, entry); + const stat = statSync(fullPath); + + if (stat.isDirectory()) { + files.push(...findHtmlFiles(fullPath)); + } else if (entry.endsWith(".html")) { + files.push(fullPath); + } + } + + return files; +} + +// For Next.js export output, the files are in the 'out' directory +const baseDir = resolve("out"); +const htmlFiles = findHtmlFiles(baseDir); + +console.log(`Found ${htmlFiles.length} .html files to process`); + +// Ensure assets directory exists +const assetsDir = `${baseDir}/assets`; +mkdirSync(assetsDir, { recursive: true }); + +htmlFiles.forEach((file) => { + // Read file contents + const contents = readFileSync(file, "utf8"); + const scripts = []; + + // Extract inline scripts + const newFile = contents.replace( + /]*src)([^>]*)>(.+?)<\/script>/gs, + (match, attributes, scriptContent) => { + // Skip if script has src attribute (external script) + if (attributes.includes("src=")) { + return match; + } + + const addPlaceholderString = scripts.length === 0; + const cleanedScript = scriptContent.trim(); + + if (cleanedScript) { + scripts.push( + `${cleanedScript}${cleanedScript.endsWith(";") ? "" : ";"}`, + ); + } + + return addPlaceholderString ? PLACEHOLDER : ""; + }, + ); + + // Early exit if no inline scripts found + if (!scripts.length) { + console.log(`No inline scripts found`); + return; + } + + // Combine scripts and create hash + const chunk = scripts.join("\n"); + const hash = createHash("md5").update(chunk).digest("hex").slice(0, 8); + const chunkFileName = `chunk.${hash}.js`; + const chunkPath = `${assetsDir}/${chunkFileName}`; + + // Write the chunk file + writeFileSync(chunkPath, chunk, "utf8"); + + // Replace placeholder string with script tag + const updatedFile = newFile.replace( + PLACEHOLDER, + ``, + ); + + // Write updated HTML file + writeFileSync(file, updatedFile, "utf8"); +}); + +console.log("Post-build script completed successfully!"); diff --git a/src/app/(dashboard)/(cloud)/customers/layout.tsx b/src/app/(dashboard)/(cloud)/customers/layout.tsx new file mode 100644 index 0000000..db3d04d --- /dev/null +++ b/src/app/(dashboard)/(cloud)/customers/layout.tsx @@ -0,0 +1,8 @@ +import { globalMetaTitle } from "@utils/meta"; +import type { Metadata } from "next"; +import BlankLayout from "@/layouts/BlankLayout"; + +export const metadata: Metadata = { + title: `Customers - ${globalMetaTitle}`, +}; +export default BlankLayout; diff --git a/src/app/(dashboard)/(cloud)/customers/page.tsx b/src/app/(dashboard)/(cloud)/customers/page.tsx new file mode 100644 index 0000000..3aaf37a --- /dev/null +++ b/src/app/(dashboard)/(cloud)/customers/page.tsx @@ -0,0 +1,69 @@ +"use client"; + +import Breadcrumbs from "@components/Breadcrumbs"; +import Paragraph from "@components/Paragraph"; +import SkeletonTable from "@components/skeletons/SkeletonTable"; +import FullScreenLoading from "@components/ui/FullScreenLoading"; +import { usePortalElement } from "@hooks/usePortalElement"; +import useFetchApi from "@utils/api"; +import React, { Suspense } from "react"; +import MSPIcon from "@/assets/icons/MSPIcon"; +import { CustomersProvider } from "@/cloud/distributor/contexts/CustomersProvider"; +import DistributorCustomersTable from "@/cloud/distributor/table/DistributorCustomersTable"; +import { DistributorDocsLink } from "@/cloud/distributor/DistributorDocsLink"; +import { useDistributor } from "@/cloud/distributor/contexts/DistributorProvider"; +import { DistributorCustomer } from "@/cloud/distributor/interfaces/Distributor"; +import PageContainer from "@/layouts/PageContainer"; +import { RestrictedAccess } from "@components/ui/RestrictedAccess"; +import { usePermissions } from "@/contexts/PermissionsProvider"; + +export default function CustomersPage() { + const { isDistributorInfoLoading } = useDistributor(); + if (isDistributorInfoLoading) return ; + return ; +} + +const CustomersPageContent = () => { + const { permission } = usePermissions(); + const { data: customers, isLoading } = useFetchApi( + "/integrations/msp/reseller/msps", + ); + const { ref: headingRef, portalTarget } = + usePortalElement(); + + return ( + +
+ + } + /> + +

Customers

+ + Use this view to manage customer accounts and their plans. + + + + in our documentation. + +
+ + }> + + + + + +
+ ); +}; diff --git a/src/app/(dashboard)/(cloud)/integrations/layout.tsx b/src/app/(dashboard)/(cloud)/integrations/layout.tsx new file mode 100644 index 0000000..a41667c --- /dev/null +++ b/src/app/(dashboard)/(cloud)/integrations/layout.tsx @@ -0,0 +1,8 @@ +import { globalMetaTitle } from "@utils/meta"; +import type { Metadata } from "next"; +import BlankLayout from "@/layouts/BlankLayout"; + +export const metadata: Metadata = { + title: `Integrations - ${globalMetaTitle}`, +}; +export default BlankLayout; diff --git a/src/app/(dashboard)/(cloud)/integrations/page.tsx b/src/app/(dashboard)/(cloud)/integrations/page.tsx new file mode 100644 index 0000000..d33e956 --- /dev/null +++ b/src/app/(dashboard)/(cloud)/integrations/page.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { RestrictedAccess } from "@components/ui/RestrictedAccess"; +import { VerticalTabs } from "@components/VerticalTabs"; +import { + FileText, + FingerprintIcon, + KeyRoundIcon, + ShieldCheckIcon, +} from "lucide-react"; +import { useSearchParams } from "next/navigation"; +import React, { useState } from "react"; +import { usePermissions } from "@/contexts/PermissionsProvider"; +import PageContainer from "@/layouts/PageContainer"; +import { useAccount } from "@/modules/account/useAccount"; +import EDRTab from "@/modules/integrations/edr/EDRTab"; +import EventStreamingTab from "@/modules/integrations/event-streaming/EventStreamingTab"; +import IdentityProviderTab from "@/modules/integrations/idp-sync/IdentityProviderTab"; +import SSOTab from "@/modules/integrations/sso/SSOTab"; +import { isNetBirdCloud } from "@utils/netbird"; + +export default function Integrations() { + const searchParams = useSearchParams(); + const currentTab = searchParams.get("tab"); + const [tab, setTab] = useState(currentTab || "identity-provider"); + const account = useAccount(); + const { permission } = usePermissions(); + + return ( + + + + + + Identity Provider Sync + + + {isNetBirdCloud() && ( + + + Single Sign-On + + )} + + + + Event Streaming + + + + MDM & EDR + + + +
+ + + + {account && } +
+
+
+
+ ); +} diff --git a/src/app/(dashboard)/(cloud)/msp/page.tsx b/src/app/(dashboard)/(cloud)/msp/page.tsx new file mode 100644 index 0000000..b5f4ec4 --- /dev/null +++ b/src/app/(dashboard)/(cloud)/msp/page.tsx @@ -0,0 +1,150 @@ +"use client"; + +import Button from "@components/Button"; +import { Callout } from "@components/Callout"; +import { Modal, ModalContent, ModalFooter } from "@components/modal/Modal"; +import { notify } from "@components/Notification"; +import FullScreenLoading from "@components/ui/FullScreenLoading"; +import { GradientFadedBackground } from "@components/ui/GradientFadedBackground"; +import useRedirect from "@hooks/useRedirect"; +import { useApiCall } from "@utils/api"; +import { LockIcon } from "lucide-react"; +import { useRouter, useSearchParams } from "next/navigation"; +import React, { useMemo, useState } from "react"; +import { useSWRConfig } from "swr"; +import NetBirdIcon from "@/assets/icons/NetBirdIcon"; +import { useMSP } from "@/cloud/msp/contexts/MSPProvider"; +import { useLoggedInUser } from "@/contexts/UsersProvider"; + +export default function JoinMspPage() { + const searchParams = useSearchParams(); + const inviteCode = searchParams.get("invite"); + + const { mutate } = useSWRConfig(); + const router = useRouter(); + const { isMspInfoLoading, mspInfo, isActive, isAccountWithMSPParent } = + useMSP(); + const [open, setOpen] = useState(true); + const { isOwner } = useLoggedInUser(); + const [isAccepting, setIsAccepting] = useState(false); + const [calledOnce, setCalledOnce] = useState(false); + const isMSPAccount = !!mspInfo && isActive; + + const mspRequest = useApiCall("/integrations/msp", true, { + ignoreGlobalParams: true, + }); + + const declineButtonText = useMemo(() => { + if (isMSPAccount && !calledOnce) return "Go to Tenants"; + if (isOwner) return "Decline"; + return "Go to Peers"; + }, [isMSPAccount, calledOnce, isOwner]); + + if (isAccountWithMSPParent || !inviteCode) return ; + + const acceptInvitation = async () => { + if (isAccepting) return; + setCalledOnce(true); + setIsAccepting(true); + const promise = mspRequest + .post({ + invite: inviteCode, + }) + .then(() => { + mutate("/integrations/msp"); + mutate("/integrations/msp/tenants"); + router.push("/tenants"); + }) + .finally(() => setIsAccepting(false)); + + notify({ + title: `NetBird Managed Service Provider`, + description: `Successfully joined as an Managed Service Provider`, + loadingMessage: `Processing your invitation...`, + promise, + }); + return promise; + }; + + const redirectTo = () => { + if (isMSPAccount) { + router.push("/tenants"); + } else { + router.push("/peers"); + } + }; + + const isDisabled = !isOwner || isMspInfoLoading || isMSPAccount; + + return ( + + e.preventDefault()} + onInteractOutside={(e) => e.preventDefault()} + onPointerDownOutside={(e) => e.preventDefault()} + > + +
+
+ +
+ +
+ NetBird invites you to join as an Managed Service Provider (MSP) +
+
+ You will get access to the NetBird MSP portal where you can manage + multiple customers and their networks from a single place. +
+ {!isOwner && !isMSPAccount && ( + + } + className={"text-xs mt-3"} + > + Only the owner of the account can accept this invitation. Please + contact the owner of the account to accept the invitation. + + )} + {isMSPAccount && !calledOnce && ( + + The invitation has already been accepted + + )} +
+ + + + + + +
+
+ ); +} + +const Redirect = () => { + useRedirect("/peers"); + return ; +}; diff --git a/src/app/(dashboard)/(cloud)/plans/cancel/layout.tsx b/src/app/(dashboard)/(cloud)/plans/cancel/layout.tsx new file mode 100644 index 0000000..708ad1e --- /dev/null +++ b/src/app/(dashboard)/(cloud)/plans/cancel/layout.tsx @@ -0,0 +1,8 @@ +import { globalMetaTitle } from "@utils/meta"; +import type { Metadata } from "next"; +import BlankLayout from "@/layouts/BlankLayout"; + +export const metadata: Metadata = { + title: `Plans - ${globalMetaTitle}`, +}; +export default BlankLayout; diff --git a/src/app/(dashboard)/(cloud)/plans/cancel/page.tsx b/src/app/(dashboard)/(cloud)/plans/cancel/page.tsx new file mode 100644 index 0000000..22da5ed --- /dev/null +++ b/src/app/(dashboard)/(cloud)/plans/cancel/page.tsx @@ -0,0 +1,9 @@ +"use client"; + +import FullScreenLoading from "@components/ui/FullScreenLoading"; +import { useRedirect } from "@hooks/useRedirect"; + +export default function PlanCancel() { + useRedirect("/settings?tab=plans-and-billing"); + return ; +} diff --git a/src/app/(dashboard)/(cloud)/plans/layout.tsx b/src/app/(dashboard)/(cloud)/plans/layout.tsx new file mode 100644 index 0000000..708ad1e --- /dev/null +++ b/src/app/(dashboard)/(cloud)/plans/layout.tsx @@ -0,0 +1,8 @@ +import { globalMetaTitle } from "@utils/meta"; +import type { Metadata } from "next"; +import BlankLayout from "@/layouts/BlankLayout"; + +export const metadata: Metadata = { + title: `Plans - ${globalMetaTitle}`, +}; +export default BlankLayout; diff --git a/src/app/(dashboard)/(cloud)/plans/page.tsx b/src/app/(dashboard)/(cloud)/plans/page.tsx new file mode 100644 index 0000000..2c8f81d --- /dev/null +++ b/src/app/(dashboard)/(cloud)/plans/page.tsx @@ -0,0 +1,10 @@ +"use client"; + +import FullScreenLoading from "@components/ui/FullScreenLoading"; +import { useRedirect } from "@hooks/useRedirect"; +import React from "react"; + +export default function PlanSuccess() { + useRedirect("/settings?tab=plans-and-billing"); + return ; +} diff --git a/src/app/(dashboard)/(cloud)/plans/success/layout.tsx b/src/app/(dashboard)/(cloud)/plans/success/layout.tsx new file mode 100644 index 0000000..708ad1e --- /dev/null +++ b/src/app/(dashboard)/(cloud)/plans/success/layout.tsx @@ -0,0 +1,8 @@ +import { globalMetaTitle } from "@utils/meta"; +import type { Metadata } from "next"; +import BlankLayout from "@/layouts/BlankLayout"; + +export const metadata: Metadata = { + title: `Plans - ${globalMetaTitle}`, +}; +export default BlankLayout; diff --git a/src/app/(dashboard)/(cloud)/plans/success/page.tsx b/src/app/(dashboard)/(cloud)/plans/success/page.tsx new file mode 100644 index 0000000..41311dc --- /dev/null +++ b/src/app/(dashboard)/(cloud)/plans/success/page.tsx @@ -0,0 +1,10 @@ +"use client"; + +import FullScreenLoading from "@components/ui/FullScreenLoading"; +import { useRedirect } from "@hooks/useRedirect"; +import React from "react"; + +export default function PlanSuccess() { + useRedirect("/settings?tab=plans-and-billing&success=true"); + return ; +} diff --git a/src/app/(dashboard)/(cloud)/tenants/layout.tsx b/src/app/(dashboard)/(cloud)/tenants/layout.tsx new file mode 100644 index 0000000..8ad72fe --- /dev/null +++ b/src/app/(dashboard)/(cloud)/tenants/layout.tsx @@ -0,0 +1,8 @@ +import { globalMetaTitle } from "@utils/meta"; +import type { Metadata } from "next"; +import BlankLayout from "@/layouts/BlankLayout"; + +export const metadata: Metadata = { + title: `Tenants - ${globalMetaTitle}`, +}; +export default BlankLayout; diff --git a/src/app/(dashboard)/(cloud)/tenants/page.tsx b/src/app/(dashboard)/(cloud)/tenants/page.tsx new file mode 100644 index 0000000..6c8e0ed --- /dev/null +++ b/src/app/(dashboard)/(cloud)/tenants/page.tsx @@ -0,0 +1,85 @@ +"use client"; + +import Breadcrumbs from "@components/Breadcrumbs"; +import Paragraph from "@components/Paragraph"; +import SkeletonTable from "@components/skeletons/SkeletonTable"; +import FullScreenLoading from "@components/ui/FullScreenLoading"; +import { RestrictedAccess } from "@components/ui/RestrictedAccess"; +import { usePortalElement } from "@hooks/usePortalElement"; +import useRedirect from "@hooks/useRedirect"; +import useFetchApi from "@utils/api"; +import React, { Suspense, useMemo } from "react"; +import MSPIcon from "@/assets/icons/MSPIcon"; +import { useMSP } from "@/cloud/msp/contexts/MSPProvider"; +import { TenantsProvider } from "@/cloud/msp/contexts/TenantsProvider"; +import { Tenant } from "@/cloud/msp/interfaces/Tenant"; +import { MSPTenantDocsLink } from "@/cloud/msp/MSPTenantDocsLink"; +import MSPTenantsTable from "@/cloud/msp/MSPTenantsTable"; +import { usePermissions } from "@/contexts/PermissionsProvider"; +import { useLoggedInUser } from "@/contexts/UsersProvider"; +import { User } from "@/interfaces/User"; +import PageContainer from "@/layouts/PageContainer"; + +export default function TenantsPage() { + const { isActive, isMSPInMSPContext, isMspInfoLoading } = useMSP(); + const { isOwnerOrAdmin } = useLoggedInUser(); + + const show = useMemo(() => { + if (!isActive) return false; + return isMSPInMSPContext && isOwnerOrAdmin; + }, [isActive, isMSPInMSPContext, isOwnerOrAdmin]); + + if (isMspInfoLoading) return ; + if (!show) return ; + return ; +} + +const Redirect = () => { + useRedirect("/peers"); + return ; +}; + +const TenantsPageContent = () => { + const { permission } = usePermissions(); + const { data: tenants, isLoading } = useFetchApi( + "/integrations/msp/tenants", + ); + const { ref: headingRef, portalTarget } = + usePortalElement(); + + useFetchApi("/users", true); + + return ( + +
+ + } + /> + +

Tenants

+ + A list of all tenants and their subscription details. Use this view to + manage accounts, plans and permissions. + + + + in our documentation. + +
+ + }> + + + + + +
+ ); +}; diff --git a/src/app/(dashboard)/control-center/page.tsx b/src/app/(dashboard)/control-center/page.tsx index d4aa559..19ca535 100644 --- a/src/app/(dashboard)/control-center/page.tsx +++ b/src/app/(dashboard)/control-center/page.tsx @@ -4,10 +4,7 @@ import "@xyflow/react/dist/style.css"; import Button from "@components/Button"; import InlineLink from "@components/InlineLink"; import { NoPeersGettingStarted } from "@components/NoPeersGettingStarted"; -import { - SelectDropdown, - SelectOption, -} from "@components/select/SelectDropdown"; +import { SelectDropdown, SelectOption } from "@components/select/SelectDropdown"; import SquareIcon from "@components/SquareIcon"; import GetStartedTest from "@components/ui/GetStartedTest"; import { SmallBadge } from "@components/ui/SmallBadge"; @@ -22,16 +19,10 @@ import { ReactFlowProvider, useEdgesState, useNodesState, - useReactFlow, + useReactFlow } from "@xyflow/react"; import { forEach, orderBy, sortBy } from "lodash"; -import { - ArrowLeftIcon, - ExternalLinkIcon, - LayoutGridIcon, - MessageSquareShareIcon, - NetworkIcon, -} from "lucide-react"; +import { ArrowLeftIcon, ExternalLinkIcon, LayoutGridIcon, MessageSquareShareIcon, NetworkIcon } from "lucide-react"; import { useRouter, useSearchParams } from "next/navigation"; import React, { useCallback, useEffect, useMemo, useState } from "react"; import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon"; @@ -53,13 +44,13 @@ import { EDGE_TYPES } from "@/modules/control-center/utils/edges"; import { getFirstGroup, getPolicyProtocolAndPortText, - getResourcePolicyByGroups, + getResourcePolicyByGroups } from "@/modules/control-center/utils/helpers"; import { applyD3ForceLayout, applyD3HierarchicalLayout, DEFAULT_MAX_ZOOM, - DEFAULT_MIN_ZOOM, + DEFAULT_MIN_ZOOM } from "@/modules/control-center/utils/layouts"; import { NODE_TYPES } from "@/modules/control-center/utils/nodes"; diff --git a/src/app/(dashboard)/dns/settings/page.tsx b/src/app/(dashboard)/dns/settings/page.tsx index e90975b..8848ab4 100644 --- a/src/app/(dashboard)/dns/settings/page.tsx +++ b/src/app/(dashboard)/dns/settings/page.tsx @@ -123,7 +123,7 @@ const SettingDisabledManagementGroups = ({ Peers in these groups will require manual domain name resolution Save Changes diff --git a/src/app/(dashboard)/events/audit/page.tsx b/src/app/(dashboard)/events/audit/page.tsx index 7733ab9..2c6d237 100644 --- a/src/app/(dashboard)/events/audit/page.tsx +++ b/src/app/(dashboard)/events/audit/page.tsx @@ -13,6 +13,7 @@ import { usePermissions } from "@/contexts/PermissionsProvider"; import { ActivityEvent } from "@/interfaces/ActivityEvent"; import PageContainer from "@/layouts/PageContainer"; import ActivityTable from "@/modules/activity/ActivityTable"; +import { EventStreamingCard } from "@/modules/integrations/event-streaming/EventStreamingCard"; export default function Activity() { const { permission } = usePermissions(); @@ -52,6 +53,7 @@ export default function Activity() { + (() => { + if (dateFrom || dateTo) { + return { + from: dateFrom ? dayjs(dateFrom).toDate() : undefined, + to: dateTo ? dayjs(dateTo).toDate() : undefined, + }; + } + return undefined; + }); + + useEffect(() => { + localStorage.removeItem(`netbird-table-pagination${pathname}`); + localStorage.removeItem(`netbird-table-range${pathname}`); + localStorage.removeItem(`netbird-table-search${pathname}`); + + const keysToRemove = []; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key && key.includes(pathname)) { + keysToRemove.push(key); + } + } + + keysToRemove.forEach((key) => { + localStorage.removeItem(key); + }); + }, [pathname]); + + const baseUrl = `/events/network-traffic`; + const [apiUrl, setApiUrl] = useState(() => { + let url = `${baseUrl}?page=${currentPage}&page_size=${currentPageSize}`; + if (searchTerm) { + url += `&search=${encodeURIComponent(searchTerm)}`; + } + if (formattedDateFrom) { + url += `&start_date=${formattedDateFrom}`; + } + if (formattedDateTo) { + url += `&end_date=${formattedDateTo}`; + } + if (typeFilter) { + typeFilter.forEach((t) => { + url += `&type=${encodeURIComponent(t)}`; + }); + } + if (connectionTypeFilter) { + url += `&connection_type=${encodeURIComponent(connectionTypeFilter)}`; + } + if (directionFilter) { + protocolFilter.forEach((d) => { + url += `&direction=${encodeURIComponent(d)}`; + }); + } + if (formattedUserId) { + url += `&user_id=${formattedUserId}`; + } + if (protocolFilter) { + protocolFilter.forEach((protocol) => { + url += `&protocol=${encodeURIComponent(protocol)}`; + }); + } + return url; + }); + + const isTrafficEventsLocked = useIsFeatureLocked("TRAFFIC_EVENTS"); + + const { + data: events, + isLoading, + mutate, + } = useFetchApi>( + apiUrl, + false, + true, + !isTrafficEventsLocked, + ); + + // Fetch is suppressed while the feature lock resolves, refetch once unlocked. + useEffect(() => { + if (!isTrafficEventsLocked) { + mutate(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isTrafficEventsLocked]); + + const updateURL = useCallback( + (newParams: Record) => { + const params = new URLSearchParams(searchParams.toString()); + + const isFilterReset = Object.values(newParams).every( + (value) => value === "", + ); + + params.set("page", currentPage); + params.set("page_size", currentPageSize); + + if (isFilterReset) { + params.delete("search"); + params.delete("start_date"); + params.delete("end_date"); + params.delete("type"); + params.delete("connection_type"); + params.delete("direction"); + params.delete("protocol"); + params.delete("user_id"); + params.set("page", "1"); + params.set("page_size", "10"); + } else { + Object.entries(newParams).forEach(([key, value]) => { + if (value === "") { + if (key === "page_size" || key === "page") return; + params.delete(key); + } else if (Array.isArray(value)) { + value.forEach((v, i) => { + if (i === 0) { + params.set(key, v); + return; + } + params.append(key, v); + }); + } else { + params.set(key, value); + } + }); + } + + setApiUrl(`${baseUrl}?${params.toString()}`); + + if (typeof window !== "undefined") { + window.history.replaceState( + null, + "", + `${pathname}?${params.toString()}`, + ); + } + }, + [baseUrl, pathname, searchParams], + ); + + const handlePaginationChange = useCallback( + ({ pageIndex, pageSize }: { pageIndex: number; pageSize: number }) => { + updateURL({ + page: String(pageIndex + 1), + page_size: String(pageSize), + }); + }, + [updateURL], + ); + + const handleGlobalFilterChange = useCallback( + (value: string) => { + if (value === "" && currentPage !== "1") return; + updateURL({ + search: value, + page: "1", + }); + }, + [updateURL], + ); + + const handleDateFilterChange = useCallback( + (from?: Date, to?: Date) => { + const params: Record = {}; + + if (from) { + params.start_date = dayjs(from).toISOString(); + } else { + params.start_date = ""; + } + + if (to) { + params.end_date = dayjs(to).toISOString(); + } else { + params.end_date = ""; + } + + params.page = "1"; + updateURL(params); + }, + [updateURL], + ); + + const handleFilterChange = useCallback( + (filters: { + type?: string[]; + direction?: string[]; + protocol?: string[]; + }) => { + const params: Record = {}; + + if (filters.type && filters.type.length > 0) { + params.type = filters.type; + } else { + params.type = ""; + } + + if (filters.direction && filters.direction.length > 0) { + params.direction = filters.direction; + } else { + params.direction = ""; + } + + if (filters.protocol && filters.protocol.length > 0) { + params.protocol = filters.protocol; + } else { + params.protocol = ""; + } + + params.page = "1"; + updateURL(params); + }, + [updateURL], + ); + + const handleConnectionTypeFilterChange = useCallback( + (value: string) => { + updateURL({ + connection_type: value, + page: "1", + }); + }, + [updateURL], + ); + + const handleUserFilterChange = useCallback( + (selectedUserId: string) => { + updateURL({ + user_id: selectedUserId || "", + page: "1", + }); + }, + [updateURL], + ); + + const handleResetAllFilters = useCallback(() => { + const params = new URLSearchParams(); + params.set("page", "1"); + params.set("page_size", "10"); + + const newUrl = `${baseUrl}?${params.toString()}`; + setApiUrl(newUrl); + if (typeof window !== "undefined") { + window.history.replaceState(null, "", `${pathname}?${params.toString()}`); + } + }, [currentPageSize, baseUrl, pathname]); + + useEffect(() => { + if (dateFrom || dateTo) { + try { + const fromDate = dateFrom ? dayjs(dateFrom).toDate() : undefined; + const toDate = dateTo ? dayjs(dateTo).toDate() : undefined; + + const newDateRange = { + from: fromDate, + to: toDate, + }; + setDateRange(newDateRange); + } catch (error) {} + } else if (dateRange) { + setDateRange(undefined); + } + }, [dateFrom, dateTo]); + + const pagination = { + pageIndex: parseInt(currentPage, 10) - 1, + pageSize: parseInt(currentPageSize, 10), + }; + + const isEnabled = !!account?.settings?.extra?.network_traffic_logs_enabled; + + const tableFilters = { + type: typeFilter, + direction: directionFilter, + protocol: protocolFilter, + }; + + const trafficEvents = useMemo(() => { + // `data` may resolve to a non-array (e.g. an error body) on locked / + // self-hosted deployments; guard so `.map` never throws. + if (!Array.isArray(events?.data)) return undefined; + return events.data.map((event) => ({ + ...event, + id: event.flow_id, + })); + }, [events]); + + return ( + +
+ + } + /> + } + /> + + +

{`${events?.total_records ?? 0}`} Traffic Events

+ + + Traffic events is an experimental feature. Functionality and behavior + may evolve, including changes to how data is collected or reported. + + + + Learn more about{" "} + + Traffic Events + {" "} + in our documentation. + +
+ + +
+ +
+ + + + + + +
+
+ ); +} diff --git a/src/app/(dashboard)/group/page.tsx b/src/app/(dashboard)/group/page.tsx index 38118a4..0a6dde7 100644 --- a/src/app/(dashboard)/group/page.tsx +++ b/src/app/(dashboard)/group/page.tsx @@ -180,6 +180,7 @@ const GroupOverviewTabs = ({ group }: { group: Group }) => { {group.name !== "All" && ( { {group.name !== "All" && ( { { @@ -230,6 +234,7 @@ const GroupOverviewTabs = ({ group }: { group: Group }) => { { { { {group.name !== "All" && ( ) { className={"pb-0 mb-0"} > - + {singularize("Resources", network?.resources?.length)} - + ) { /> {singularize("Routing Peers", network?.routing_peers_count)} - + - @@ -222,6 +225,7 @@ function NetworkActions() { openEditNetworkModal(network)} disabled={!permission.networks.update} + data-testid="rename-network" >
diff --git a/src/app/(dashboard)/peer/page.tsx b/src/app/(dashboard)/peer/page.tsx index aedb19a..c9e10e1 100644 --- a/src/app/(dashboard)/peer/page.tsx +++ b/src/app/(dashboard)/peer/page.tsx @@ -49,6 +49,10 @@ import { toASCII } from "punycode"; import React, { useMemo, useState } from "react"; import Skeleton from "react-loading-skeleton"; import { useSWRConfig } from "swr"; +import { + TrafficEventsPeerTabContent, + TrafficEventsPeerTabTrigger, +} from "@/cloud/traffic-events/TrafficEventsPeerTabContent"; import RoundedFlag from "@/assets/countries/RoundedFlag"; import CircleIcon from "@/assets/icons/CircleIcon"; import NetBirdIcon from "@/assets/icons/NetBirdIcon"; @@ -361,7 +365,7 @@ const PeerOverviewTabs = () => { )} - {peer?.id && permission.peers.read && ( + {peer?.id && ( Accessible Peers @@ -384,6 +388,8 @@ const PeerOverviewTabs = () => { Remote Jobs )} + + {permission.events.read && } @@ -396,7 +402,7 @@ const PeerOverviewTabs = () => { )} - {peer?.id && permission.peers.read && ( + {peer?.id && ( @@ -421,6 +427,12 @@ const PeerOverviewTabs = () => { )} + + {permission.events.read && ( + + + + )} ); }; diff --git a/src/app/(dashboard)/peers/page.tsx b/src/app/(dashboard)/peers/page.tsx index 502c410..a6c321e 100644 --- a/src/app/(dashboard)/peers/page.tsx +++ b/src/app/(dashboard)/peers/page.tsx @@ -2,4 +2,4 @@ import { redirect } from "next/navigation"; export default function PeersIndex() { redirect("/peers/users"); -} +} \ No newline at end of file diff --git a/src/app/(dashboard)/peers/servers/page.tsx b/src/app/(dashboard)/peers/servers/page.tsx index 8d4945c..ab67d17 100644 --- a/src/app/(dashboard)/peers/servers/page.tsx +++ b/src/app/(dashboard)/peers/servers/page.tsx @@ -8,6 +8,9 @@ import { usePortalElement } from "@hooks/usePortalElement"; import { ExternalLinkIcon } from "lucide-react"; import React, { lazy, Suspense, useMemo } from "react"; import PeerIcon from "@/assets/icons/PeerIcon"; +import { useBypassedPeers } from "@/cloud/edr/useBypass"; +import useDistributorRedirect from "@/cloud/distributor/useDistributorRedirect"; +import FullScreenLoading from "@components/ui/FullScreenLoading"; import PeersProvider, { usePeers } from "@/contexts/PeersProvider"; import { usePermissions } from "@/contexts/PermissionsProvider"; import { useUsers } from "@/contexts/UsersProvider"; @@ -18,6 +21,8 @@ const PeersTable = lazy(() => import("@/modules/peers/PeersTable")); export default function ServersPage() { const { isRestricted } = usePermissions(); + const { isLoading: isDistributorRedirecting } = useDistributorRedirect(); + if (isDistributorRedirecting) return ; return ( @@ -35,6 +40,7 @@ export default function ServersPage() { function ServersView() { const { peers, isLoading: isPeersLoading } = usePeers(); const { users, isLoading: isUsersLoading } = useUsers(); + const { isBypassed } = useBypassedPeers(); const { ref: headingRef, portalTarget } = usePortalElement(); @@ -48,8 +54,9 @@ function ServersView() { return peers.map((peer) => ({ ...peer, user: users.find((u) => u.id === peer.user_id), + force_approved: peer.id ? isBypassed(peer.id) : false, })); - }, [peers, users]); + }, [peers, users, isBypassed]); return ( <> diff --git a/src/app/(dashboard)/peers/users/page.tsx b/src/app/(dashboard)/peers/users/page.tsx index 4cc6bd8..f0997d9 100644 --- a/src/app/(dashboard)/peers/users/page.tsx +++ b/src/app/(dashboard)/peers/users/page.tsx @@ -8,6 +8,9 @@ import { usePortalElement } from "@hooks/usePortalElement"; import { ExternalLinkIcon } from "lucide-react"; import React, { lazy, Suspense, useMemo } from "react"; import PeerIcon from "@/assets/icons/PeerIcon"; +import { useBypassedPeers } from "@/cloud/edr/useBypass"; +import useDistributorRedirect from "@/cloud/distributor/useDistributorRedirect"; +import FullScreenLoading from "@components/ui/FullScreenLoading"; import PeersProvider, { usePeers } from "@/contexts/PeersProvider"; import { usePermissions } from "@/contexts/PermissionsProvider"; import { useUsers } from "@/contexts/UsersProvider"; @@ -18,6 +21,8 @@ const PeersTable = lazy(() => import("@/modules/peers/PeersTable")); export default function UserDevicesPage() { const { isRestricted } = usePermissions(); + const { isLoading: isDistributorRedirecting } = useDistributorRedirect(); + if (isDistributorRedirecting) return ; return ( @@ -35,6 +40,7 @@ export default function UserDevicesPage() { function UserDevicesView() { const { peers, isLoading: isPeersLoading } = usePeers(); const { users, isLoading: isUsersLoading } = useUsers(); + const { isBypassed } = useBypassedPeers(); const { ref: headingRef, portalTarget } = usePortalElement(); @@ -48,8 +54,9 @@ function UserDevicesView() { return peers.map((peer) => ({ ...peer, user: users.find((u) => u.id === peer.user_id), + force_approved: peer.id ? isBypassed(peer.id) : false, })); - }, [peers, users]); + }, [peers, users, isBypassed]); return ( <> diff --git a/src/app/(dashboard)/reverse-proxy/services/page.tsx b/src/app/(dashboard)/reverse-proxy/services/page.tsx index 7355e05..cf2c714 100644 --- a/src/app/(dashboard)/reverse-proxy/services/page.tsx +++ b/src/app/(dashboard)/reverse-proxy/services/page.tsx @@ -14,7 +14,7 @@ import ReverseProxiesProvider from "@/contexts/ReverseProxiesProvider"; import { REVERSE_PROXY_DOCS_LINK } from "@/interfaces/ReverseProxy"; import PageContainer from "@/layouts/PageContainer"; import { Callout } from "@components/Callout"; -import { isNetBirdHosted } from "@utils/netbird"; +import { isNetBirdCloud } from "@utils/netbird"; const ReverseProxyTable = lazy( () => import("@/modules/reverse-proxy/table/ReverseProxyTable"), @@ -50,7 +50,7 @@ export default function ReverseProxyServicesPage() { - {isNetBirdHosted() ? ( + {isNetBirdCloud() ? ( NetBird's Reverse Proxy is currently in beta and available at no cost during this period. Features, functionality, and pricing are diff --git a/src/app/(dashboard)/settings/page.tsx b/src/app/(dashboard)/settings/page.tsx index 264c5ee..f85aa59 100644 --- a/src/app/(dashboard)/settings/page.tsx +++ b/src/app/(dashboard)/settings/page.tsx @@ -14,6 +14,7 @@ import { } from "lucide-react"; import { useSearchParams } from "next/navigation"; import React, { useEffect, useMemo, useState } from "react"; +import { useMSP } from "@/cloud/msp/contexts/MSPProvider"; import { usePermissions } from "@/contexts/PermissionsProvider"; import { useLoggedInUser } from "@/contexts/UsersProvider"; import PageContainer from "@/layouts/PageContainer"; @@ -26,6 +27,10 @@ import NetworkSettingsTab from "@/modules/settings/NetworkSettingsTab"; import PermissionsTab from "@/modules/settings/PermissionsTab"; import SetupKeysTab from "@/modules/settings/SetupKeysTab"; import GroupsSettings from "@/modules/settings/GroupsSettings"; +import { + CloudSettingsTabContent, + CloudSettingsTabTrigger, +} from "@/cloud/settings/CloudSettings"; export default function NetBirdSettings() { const queryParams = useSearchParams(); @@ -33,7 +38,8 @@ export default function NetBirdSettings() { const { permission } = usePermissions(); const initialTab = useMemo(() => { - if (permission.settings.read) return "authentication"; + if (permission?.settings?.read) return "authentication"; + if (permission?.billing?.update) return "plans-and-billing"; return "authentication"; }, [permission]); @@ -53,7 +59,7 @@ export default function NetBirdSettings() { {permission.settings.read && ( <> - + Authentication @@ -70,41 +76,42 @@ export default function NetBirdSettings() { Identity Providers )} - + Groups - + Permissions - + Networks - + Clients )} - +
{account && } {permission.setup_keys.read && } {account?.settings?.embedded_idp_enabled && - permission.identity_providers.read && } + permission?.identity_providers?.read && } {account && } {account && } {account && } {account && } {account && } +
@@ -115,6 +122,9 @@ export default function NetBirdSettings() { const DangerZoneTabTrigger = () => { const { isOwner } = useLoggedInUser(); + const { isAccountWithMSPParent } = useMSP(); + if (isAccountWithMSPParent) return; + return ( isOwner && ( diff --git a/src/app/(dashboard)/team/user/page.tsx b/src/app/(dashboard)/team/user/page.tsx index 39fd787..512c9dd 100644 --- a/src/app/(dashboard)/team/user/page.tsx +++ b/src/app/(dashboard)/team/user/page.tsx @@ -30,6 +30,7 @@ import { useRouter, useSearchParams } from "next/navigation"; import React, { useMemo, useState } from "react"; import { useSWRConfig } from "swr"; import TeamIcon from "@/assets/icons/TeamIcon"; +import { UserMfaListItem } from "@/cloud/mfa/UserMFAListItem"; import { usePermissions } from "@/contexts/PermissionsProvider"; import { useLoggedInUser } from "@/contexts/UsersProvider"; import { useHasChanges } from "@/hooks/useHasChanges"; @@ -125,6 +126,7 @@ function UserOverview({ user, initialGroups }: Readonly) { ) .then(() => { mutate(`/users?service_user=${isServiceUser}`); + mutate(`/integrations/msp/switcher`); updateChangesRef([role, selectedGroups]); }), loadingMessage: "Saving changes...", @@ -222,7 +224,7 @@ function UserOverview({ user, initialGroups }: Readonly) { className={"w-full"} disabled={!hasChanges || !permission.users.update} onClick={save} - data-cy={"save-changes"} + data-testid={"save-changes"} > Save Changes @@ -244,7 +246,7 @@ function UserOverview({ user, initialGroups }: Readonly) { onChange={setSelectedGroups} values={selectedGroups} hideAllGroup={true} - dataCy={"user-group-selector"} + data-testid={"user-group-selector"} />
)} @@ -279,13 +281,16 @@ function UserOverview({ user, initialGroups }: Readonly) { >